From b8be8c63975cf90874de991fc436b08018c8f27e Mon Sep 17 00:00:00 2001 From: ZackFan Date: Tue, 4 Aug 2026 15:43:38 +0800 Subject: [PATCH] fix(client)!: separate baseUrl from the API version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baseUrl carried two meanings at once: which host to talk to, and which version to use. Anyone who only wanted to change host was therefore made to manage the version by hand, and the SDK needed a precedence rule to decide which of the two options won. Every URL segment now has exactly one owner. baseUrl is the host and path prefix; the version always comes from the resolved `version` option and is appended by the SDK. The default and custom-baseUrl paths run the same code, so a custom endpoint is written exactly like the public one. Three traps went with it: - `version: undefined` kept whatever version was in the URL while `version: {}` resolved per-product. Both now mean per-product latest. - applyVersionToBaseUrl left a version-less URL alone, so a clean baseUrl made the version option a no-op. The new withVersion always appends. - The scalar form (`version: 'v1.1'`) threw as soon as a client that doesn't serve that version was taken off the factory, and only ever worked when a caller happened to touch one product. Removed in favour of the per-product map, which is checked at compile time. REST follows the same rule, using the single version it serves. BREAKING CHANGE: a baseUrl ending in a version segment is now rejected with a TypeError naming the prefix to use instead — pass the host and path prefix only. The scalar `version` form is removed; use the per-product map, e.g. version: { futopt: 'v1.1' }. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VGBMNGKfgHPyNGMkHMcaJH --- src/base-url.ts | 26 ++++++++++ src/rest/factory.ts | 12 ++++- src/websocket/factory.ts | 21 ++++----- src/websocket/version.ts | 53 ++++++++++----------- test/rest-client.spec.ts | 42 ++++++++++------- test/websocket-client.spec.ts | 89 ++++++++++++++++++----------------- 6 files changed, 138 insertions(+), 105 deletions(-) create mode 100644 src/base-url.ts diff --git a/src/base-url.ts b/src/base-url.ts new file mode 100644 index 0000000..b665e69 --- /dev/null +++ b/src/base-url.ts @@ -0,0 +1,26 @@ +const VERSION_SEGMENT = /\/v\d+\.\d+$/; + +/** + * Join a caller-supplied `baseUrl` with the version the SDK resolved. + * + * `baseUrl` carries the host and path prefix and nothing else; the version + * segment is always appended here. A version written into `baseUrl` is + * rejected rather than swapped or appended on top of, because letting two + * options decide the same path segment is what forced the old precedence + * rules — and those rules meant anyone who only wanted to change host was + * made to manage the version by hand. + */ +export const withVersion = (baseUrl: string, version: string, hint = ''): string => { + const trimmed = baseUrl.replace(/\/+$/, ''); + const existing = trimmed.match(VERSION_SEGMENT); + + if (existing) { + throw new TypeError( + `baseUrl must not include a version segment (found '${existing[0]}'). ` + + `Pass the host and path prefix only: '${trimmed.slice(0, -existing[0].length)}'.` + + (hint ? ` ${hint}` : '') + ); + } + + return `${trimmed}/${version}`; +}; diff --git a/src/rest/factory.ts b/src/rest/factory.ts index c5c1e8b..b2b3371 100644 --- a/src/rest/factory.ts +++ b/src/rest/factory.ts @@ -2,6 +2,7 @@ import { RestClient } from './client'; import { RestStockClient } from './stock/client'; import { RestFutOptClient } from './futopt/client'; import { ClientFactory } from '../client-factory'; +import { withVersion } from '../base-url'; import { FUGLE_MARKETDATA_API_REST_BASE_URL, FUGLE_MARKETDATA_API_VERSION } from '../constants'; export class RestClientFactory extends ClientFactory { @@ -19,8 +20,15 @@ export class RestClientFactory extends ClientFactory { let client = this.clients.get(type); if (!client) { - const baseUrl = this.options.baseUrl || `${FUGLE_MARKETDATA_API_REST_BASE_URL}/${FUGLE_MARKETDATA_API_VERSION}`; - const url = `${baseUrl.replace(/\/+$/, '')}/${type}`; + // Same rule as streaming: baseUrl is host and path prefix, the SDK owns + // the version segment. REST serves one version, so there's no option to + // choose it with — but a version written into baseUrl is still rejected + // rather than silently doubled. + const baseUrl = withVersion( + this.options.baseUrl || FUGLE_MARKETDATA_API_REST_BASE_URL, + FUGLE_MARKETDATA_API_VERSION, + ); + const url = `${baseUrl}/${type}`; /* istanbul ignore else */ if (type === 'stock') { diff --git a/src/websocket/factory.ts b/src/websocket/factory.ts index 90e824a..1805c21 100644 --- a/src/websocket/factory.ts +++ b/src/websocket/factory.ts @@ -1,9 +1,10 @@ import { WebSocketClient } from './client'; import { WebSocketStockClient } from './stock/client'; import { WebSocketFutOptClient } from './futopt/client'; -import { WebSocketProduct, applyVersionToBaseUrl, resolveVersion } from './version'; +import { WebSocketProduct, VERSION_OPTION_HINT, resolveVersion } from './version'; import { FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL } from '../constants'; import { ClientFactory } from '../client-factory'; +import { withVersion } from '../base-url'; export class WebSocketClientFactory extends ClientFactory { private readonly clients = new Map(); @@ -17,21 +18,15 @@ export class WebSocketClientFactory extends ClientFactory { } /** - * A `baseUrl` is only re-versioned when `version` was explicitly supplied. - * Left alone otherwise, the version the caller wrote into their URL wins — - * which keeps custom and internal deployments (whose paths need not follow - * the public versioning at all) working exactly as before. + * `baseUrl` picks the host and path prefix; `version` picks the version. + * Nothing else: a custom endpoint is written the same way as the public one, + * and pointing at a different host never forces the caller to track versions + * by hand. */ private resolveBaseUrl(type: WebSocketProduct) { const version = resolveVersion(type, this.options.version); - - if (!this.options.baseUrl) { - return `${FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL}/${version}`; - } - - return this.options.version !== undefined - ? applyVersionToBaseUrl(this.options.baseUrl, version) - : this.options.baseUrl; + const baseUrl = this.options.baseUrl || FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL; + return withVersion(baseUrl, version, VERSION_OPTION_HINT); } private getClient(type: WebSocketProduct) { diff --git a/src/websocket/version.ts b/src/websocket/version.ts index 845db4a..7efe9ba 100644 --- a/src/websocket/version.ts +++ b/src/websocket/version.ts @@ -6,20 +6,24 @@ export type WebSocketVersion = typeof FUGLE_MARKETDATA_WS_SUPPORTED_VERSIONS[Web /** * Per-product form. Each product is narrowed to the versions it actually - * serves, so `{ stock: 'v1.1' }` is a compile-time error. + * serves, so `{ stock: 'v1.1' }` is a compile-time error. A product left out + * of the map — including the empty map — gets that product's latest. */ export type WebSocketVersionMap = { [P in WebSocketProduct]?: typeof FUGLE_MARKETDATA_WS_SUPPORTED_VERSIONS[P][number]; }; /** - * Either a single version applied to every product, or a per-product map. + * The `version` option: always a per-product map. * - * The scalar form can't be checked at compile time — which product it ends up - * applying to isn't known until a client is taken off the factory — so an - * unsupported combination throws when that client is created. + * A bare version string used to be accepted as "this version for every + * product", but which product it applied to wasn't known until a client was + * taken off the factory, so an unsupported pairing only surfaced then — and + * with the products serving different version sets, the only scalar that never + * throws is the one every product happens to share. The map form says the same + * thing without the trap. */ -export type WebSocketVersionOption = WebSocketVersion | WebSocketVersionMap; +export type WebSocketVersionOption = WebSocketVersionMap; const supportedVersions = (product: WebSocketProduct): readonly string[] => FUGLE_MARKETDATA_WS_SUPPORTED_VERSIONS[product]; @@ -45,19 +49,23 @@ const assertSupported = (product: WebSocketProduct, version: string, hint: strin /** * Resolve the streaming version for a product from the `version` option. * - * Omitted entirely, or omitted for this product in the map form, means the - * product's latest. Nothing is ever silently clamped: asking for a version a - * product doesn't serve throws rather than quietly handing back an older one. + * Omitted entirely, an empty map, or omitted for this product all mean the same + * thing: that product's latest. Nothing is ever silently clamped — asking for a + * version a product doesn't serve throws rather than quietly handing back an + * older one. */ export const resolveVersion = (product: WebSocketProduct, version?: WebSocketVersionOption): string => { if (version === undefined) return latestVersion(product); + // Guard for JavaScript callers, who don't get the compile-time rejection. if (typeof version === 'string') { const alternatives = productsSupporting(version); - assertSupported(product, version, alternatives.length - ? `Use version: { ${alternatives[0]}: '${version}' } to target a single product.` - : `No product serves ${version}.`); - return version; + throw new TypeError( + `version must be a per-product map, not the bare string '${version}'. ` + + (alternatives.length + ? `Use version: { ${alternatives.map(p => `${p}: '${version}'`).join(', ')} }.` + : `No product serves ${version}.`) + ); } const requested = version[product]; @@ -67,19 +75,6 @@ export const resolveVersion = (product: WebSocketProduct, version?: WebSocketVer return requested; }; -const VERSION_SEGMENT = /\/v\d+\.\d+$/; - -/** - * Point an explicitly supplied `baseUrl` at `version` by swapping its trailing - * version segment (`.../marketdata/v1.0` → `.../marketdata/v1.1`). - * - * A baseUrl without a recognizable version segment is left alone — it may be a - * proxy or an internal deployment that doesn't encode a version in its path, - * and inventing one would break it. - */ -export const applyVersionToBaseUrl = (baseUrl: string, version: string): string => { - const trimmed = baseUrl.replace(/\/+$/, ''); - return VERSION_SEGMENT.test(trimmed) - ? trimmed.replace(VERSION_SEGMENT, `/${version}`) - : trimmed; -}; +/** Appended to `baseUrl` rejections, pointing at the option that owns the version. */ +export const VERSION_OPTION_HINT = + "The version comes from the `version` option, e.g. version: { futopt: 'v1.1' }."; diff --git a/test/rest-client.spec.ts b/test/rest-client.spec.ts index 033e752..83fdc37 100644 --- a/test/rest-client.spec.ts +++ b/test/rest-client.spec.ts @@ -43,7 +43,7 @@ describe('RestClient', () => { }); it('should create a RestClient instance with custom baseUrl', () => { - const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com/v2.0' }); + const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com' }); expect(client).toBeInstanceOf(RestClient); }); }); @@ -63,19 +63,19 @@ describe('RestClient', () => { }); it('should use custom baseUrl for stock client', () => { - const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com/v2.0' }); + const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com' }); const stock = client.stock; expect(stock).toBeInstanceOf(RestStockClient); // @ts-ignore - accessing private property for testing - expect(stock.options.baseUrl).toBe('https://custom-api.example.com/v2.0/stock'); + expect(stock.options.baseUrl).toBe('https://custom-api.example.com/v1.0/stock'); }); it('should use custom baseUrl for futopt client', () => { - const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com/v2.0' }); + const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com' }); const futopt = client.futopt; expect(futopt).toBeInstanceOf(RestFutOptClient); // @ts-ignore - accessing private property for testing - expect(futopt.options.baseUrl).toBe('https://custom-api.example.com/v2.0/futopt'); + expect(futopt.options.baseUrl).toBe('https://custom-api.example.com/v1.0/futopt'); }); describe('.intraday', () => { @@ -123,11 +123,11 @@ describe('RestClient', () => { }); it('should request with custom baseUrl', async () => { - const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com/v2.0' }); + const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://custom-api.example.com' }); const stock = client.stock as RestStockClient; await stock.intraday.tickers({ type: 'INDEX' }); expect(fetch).toBeCalledWith( - 'https://custom-api.example.com/v2.0/stock/intraday/tickers?type=INDEX', + 'https://custom-api.example.com/v1.0/stock/intraday/tickers?type=INDEX', { headers: { 'X-API-KEY': 'api-key' } }, ); }); @@ -929,35 +929,43 @@ describe('RestClient', () => { describe('URL normalization', () => { it('should handle baseUrl without trailing slash', () => { - const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/v1' }); + const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/marketdata' }); const stock = client.stock; // @ts-ignore - accessing private property for testing - expect(stock.options.baseUrl).toBe('https://api.example.com/v1/stock'); + expect(stock.options.baseUrl).toBe('https://api.example.com/marketdata/v1.0/stock'); }); it('should handle baseUrl with single trailing slash', () => { - const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/v1/' }); + const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/marketdata/' }); const stock = client.stock; // @ts-ignore - accessing private property for testing - expect(stock.options.baseUrl).toBe('https://api.example.com/v1/stock'); + expect(stock.options.baseUrl).toBe('https://api.example.com/marketdata/v1.0/stock'); }); it('should handle baseUrl with multiple trailing slashes', () => { - const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/v1///' }); + const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/marketdata///' }); const stock = client.stock; // @ts-ignore - accessing private property for testing - expect(stock.options.baseUrl).toBe('https://api.example.com/v1/stock'); + expect(stock.options.baseUrl).toBe('https://api.example.com/marketdata/v1.0/stock'); }); - it('should handle baseUrl with complex path and trailing slash', () => { + it('should treat a path segment that is not a vX.Y version as part of the prefix', () => { const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.example.com/api/v2/' }); const stock = client.stock; // @ts-ignore - accessing private property for testing - expect(stock.options.baseUrl).toBe('https://api.example.com/api/v2/stock'); - + expect(stock.options.baseUrl).toBe('https://api.example.com/api/v2/v1.0/stock'); + const futopt = client.futopt; // @ts-ignore - accessing private property for testing - expect(futopt.options.baseUrl).toBe('https://api.example.com/api/v2/futopt'); + expect(futopt.options.baseUrl).toBe('https://api.example.com/api/v2/v1.0/futopt'); + }); + + it('should reject a baseUrl carrying its own version segment', () => { + const client = new RestClient({ apiKey: 'api-key', baseUrl: 'https://api.fugle.tw/marketdata/v1.0' }); + expect(() => client.stock).toThrowError( + "baseUrl must not include a version segment (found '/v1.0'). " + + "Pass the host and path prefix only: 'https://api.fugle.tw/marketdata'." + ); }); }); }); diff --git a/test/websocket-client.spec.ts b/test/websocket-client.spec.ts index 7389da4..5ba9e93 100644 --- a/test/websocket-client.spec.ts +++ b/test/websocket-client.spec.ts @@ -54,7 +54,7 @@ describe('WebSocketClient', () => { }); it('should create a WebSocketClient instance with custom baseUrl', () => { - const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://custom-ws.example.com/v2.0' }); + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://custom-ws.example.com' }); expect(client).toBeInstanceOf(WebSocketClient); }); @@ -81,19 +81,19 @@ describe('WebSocketClient', () => { }); it('should use custom baseUrl for stock client', () => { - const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://custom-ws.example.com/v2.0' }); + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://custom-ws.example.com' }); const stock = client.stock; expect(stock).toBeInstanceOf(WebSocketStockClient); // @ts-ignore - accessing private property for testing - expect(stock.options.url).toBe('wss://custom-ws.example.com/v2.0/stock/streaming'); + expect(stock.options.url).toBe('wss://custom-ws.example.com/v1.0/stock/streaming'); }); it('should use custom baseUrl for futopt client', () => { - const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://custom-ws.example.com/v2.0' }); + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://custom-ws.example.com' }); const futopt = client.futopt; expect(futopt).toBeInstanceOf(WebSocketFutOptClient); // @ts-ignore - accessing private property for testing - expect(futopt.options.url).toBe('wss://custom-ws.example.com/v2.0/futopt/streaming'); + expect(futopt.options.url).toBe('wss://custom-ws.example.com/v1.1/futopt/streaming'); }); describe('.connect()', () => { @@ -467,35 +467,35 @@ describe('WebSocketClient', () => { describe('URL normalization', () => { it('should handle baseUrl without trailing slash', () => { - const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://ws.example.com/v1' }); + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://ws.example.com/marketdata' }); const stock = client.stock; // @ts-ignore - accessing private property for testing - expect(stock.options.url).toBe('wss://ws.example.com/v1/stock/streaming'); + expect(stock.options.url).toBe('wss://ws.example.com/marketdata/v1.0/stock/streaming'); }); it('should handle baseUrl with single trailing slash', () => { - const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://ws.example.com/v1/' }); + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://ws.example.com/marketdata/' }); const stock = client.stock; // @ts-ignore - accessing private property for testing - expect(stock.options.url).toBe('wss://ws.example.com/v1/stock/streaming'); + expect(stock.options.url).toBe('wss://ws.example.com/marketdata/v1.0/stock/streaming'); }); it('should handle baseUrl with multiple trailing slashes', () => { - const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://ws.example.com/v1///' }); + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://ws.example.com/marketdata///' }); const stock = client.stock; // @ts-ignore - accessing private property for testing - expect(stock.options.url).toBe('wss://ws.example.com/v1/stock/streaming'); + expect(stock.options.url).toBe('wss://ws.example.com/marketdata/v1.0/stock/streaming'); }); - it('should handle baseUrl with complex path and trailing slash', () => { + it('should treat a path segment that is not a vX.Y version as part of the prefix', () => { const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://ws.example.com/api/v2/' }); const stock = client.stock; // @ts-ignore - accessing private property for testing - expect(stock.options.url).toBe('wss://ws.example.com/api/v2/stock/streaming'); + expect(stock.options.url).toBe('wss://ws.example.com/api/v2/v1.0/stock/streaming'); const futopt = client.futopt; // @ts-ignore - accessing private property for testing - expect(futopt.options.url).toBe('wss://ws.example.com/api/v2/futopt/streaming'); + expect(futopt.options.url).toBe('wss://ws.example.com/api/v2/v1.1/futopt/streaming'); }); }); @@ -509,21 +509,25 @@ describe('WebSocketClient', () => { expect(urlOf(client, 'stock')).toBe(`${FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL}/v1.0/stock/streaming`); }); - it('should apply a scalar version to futopt', () => { - const client = new WebSocketClient({ apiKey: 'api-key', version: 'v1.1' }); + it('should treat an empty map the same as no version at all', () => { + const client = new WebSocketClient({ apiKey: 'api-key', version: {} }); expect(urlOf(client, 'futopt')).toBe(`${FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL}/v1.1/futopt/streaming`); - }); - - it('should apply a scalar version to every product when all support it', () => { - const client = new WebSocketClient({ apiKey: 'api-key', version: 'v1.0' }); - expect(urlOf(client, 'futopt')).toBe(`${FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL}/v1.0/futopt/streaming`); expect(urlOf(client, 'stock')).toBe(`${FUGLE_MARKETDATA_API_WEBSOCKET_BASE_URL}/v1.0/stock/streaming`); }); - it('should throw for a product that does not serve the scalar version', () => { + it('should reject a bare version string at compile time', () => { + // @ts-expect-error - version is a per-product map, not a scalar const client = new WebSocketClient({ apiKey: 'api-key', version: 'v1.1' }); + expect(() => client.futopt).toThrowError( + "version must be a per-product map, not the bare string 'v1.1'. Use version: { futopt: 'v1.1' }." + ); + }); + + it('should name every product that serves a rejected bare version string', () => { + // @ts-expect-error - version is a per-product map, not a scalar + const client = new WebSocketClient({ apiKey: 'api-key', version: 'v1.0' }); expect(() => client.stock).toThrowError( - "stock streaming does not support v1.1 (supported: v1.0). Use version: { futopt: 'v1.1' } to target a single product." + "version must be a per-product map, not the bare string 'v1.0'. Use version: { stock: 'v1.0', futopt: 'v1.0' }." ); }); @@ -544,37 +548,34 @@ describe('WebSocketClient', () => { expect(() => client.stock).toThrowError('stock streaming does not support v1.1'); }); - it('should leave a custom baseUrl alone when no version is given', () => { - const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://custom-ws.example.com/v2.0' }); - expect(urlOf(client, 'futopt')).toBe('wss://custom-ws.example.com/v2.0/futopt/streaming'); + it('should version a custom baseUrl per product, with no version option', () => { + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://fubon-api.fugle.tw/marketdata' }); + expect(urlOf(client, 'futopt')).toBe('wss://fubon-api.fugle.tw/marketdata/v1.1/futopt/streaming'); + expect(urlOf(client, 'stock')).toBe('wss://fubon-api.fugle.tw/marketdata/v1.0/stock/streaming'); }); - it('should swap the version segment of a custom baseUrl when a version is given', () => { + it('should apply the version option to a custom baseUrl', () => { const client = new WebSocketClient({ apiKey: 'api-key', - baseUrl: 'wss://api-dev.fugle.tw/marketdata/v1.0', - version: { futopt: 'v1.1' }, + baseUrl: 'wss://api-dev.fugle.tw/marketdata', + version: { futopt: 'v1.0' }, }); - expect(urlOf(client, 'futopt')).toBe('wss://api-dev.fugle.tw/marketdata/v1.1/futopt/streaming'); + expect(urlOf(client, 'futopt')).toBe('wss://api-dev.fugle.tw/marketdata/v1.0/futopt/streaming'); expect(urlOf(client, 'stock')).toBe('wss://api-dev.fugle.tw/marketdata/v1.0/stock/streaming'); }); - it('should swap the version segment of a custom baseUrl with trailing slashes', () => { - const client = new WebSocketClient({ - apiKey: 'api-key', - baseUrl: 'wss://api-dev.fugle.tw/marketdata/v1.0//', - version: { futopt: 'v1.1' }, - }); - expect(urlOf(client, 'futopt')).toBe('wss://api-dev.fugle.tw/marketdata/v1.1/futopt/streaming'); + it('should reject a baseUrl carrying its own version segment', () => { + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://api-dev.fugle.tw/marketdata/v1.0' }); + expect(() => client.futopt).toThrowError( + "baseUrl must not include a version segment (found '/v1.0'). " + + "Pass the host and path prefix only: 'wss://api-dev.fugle.tw/marketdata'. " + + "The version comes from the `version` option, e.g. version: { futopt: 'v1.1' }." + ); }); - it('should leave a custom baseUrl without a version segment untouched', () => { - const client = new WebSocketClient({ - apiKey: 'api-key', - baseUrl: 'wss://ws.example.com/api', - version: { futopt: 'v1.1' }, - }); - expect(urlOf(client, 'futopt')).toBe('wss://ws.example.com/api/futopt/streaming'); + it('should reject a versioned baseUrl after trailing slashes are trimmed', () => { + const client = new WebSocketClient({ apiKey: 'api-key', baseUrl: 'wss://api-dev.fugle.tw/marketdata/v1.0//' }); + expect(() => client.futopt).toThrowError("baseUrl must not include a version segment (found '/v1.0')"); }); });