Files
worldmonitor/tests/market-service-symbol-casing.test.mjs
Sebastien Melki fcbb8bc0a1 feat(proto): unified OpenAPI bundle via sebuf v0.11.0 (#3341)
* feat(proto): generate unified worldmonitor.openapi.yaml bundle

Adds a third protoc-gen-openapiv3 invocation that merges every service
into a single docs/api/worldmonitor.openapi.yaml spanning all 68 RPCs,
using the new bundle support shipped in sebuf 0.11.0
(SebastienMelki/sebuf#158).

Per-service YAML/JSON files are untouched and continue to back the
Mintlify docs in docs/docs.json. The bundle runs with strategy: all and
bundle_only=true so only the aggregate file is emitted, avoiding
duplicate-output conflicts with the existing per-service generator.

Requires protoc-gen-openapiv3 >= v0.11.0 locally:
  go install github.com/SebastienMelki/sebuf/cmd/protoc-gen-openapiv3@v0.11.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(proto): bump sebuf to v0.11.0 and document unified OpenAPI bundle

- Makefile: SEBUF_VERSION v0.7.0 → v0.11.0 (required for bundle support).
- proto/buf.gen.yaml: point bundle_server at https://api.worldmonitor.app.
- CONTRIBUTING.md: new "OpenAPI Output" section covering per-service specs
  vs the unified worldmonitor.openapi.yaml bundle, plus a note that all
  three sebuf plugins must be installed from the pinned version.
- AGENTS.md: clarify that `make generate` also produces the unified spec
  and requires sebuf v0.11.0.
- CHANGELOG.md: Unreleased entry announcing the bundle and version bump.

Also regenerates the bundle with the updated server URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(codegen): regenerate TS client/server with sebuf v0.11.0

Mechanical output of the bumped protoc-gen-ts-client and
protoc-gen-ts-server. Two behavioral improvements roll in from sebuf:

- Proto enum fields now use the proper `*_UNSPECIFIED` sentinel in
  default-value checks instead of the empty string, so generated
  query-string serializers correctly omit enum params only when they
  actually equal the proto default.
- `repeated string` query params now serialize via
  `forEach(v => params.append(...))` instead of being coerced through
  `String(req.field)`, matching the existing `parseStringArray()`
  contract on the server side.

All files also drop the `// @ts-nocheck` header that earlier sebuf
versions emitted — 0.11.0 output type-checks cleanly under our tsconfig.

No hand edits. Reproduce with `make install-plugins && make generate`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(proto): bump sebuf v0.11.0 → v0.11.1, realign tests with repeated-param wire format

- Bump SEBUF_VERSION to v0.11.1, pulling in the OpenAPI fix for repeated
  scalar query params (SebastienMelki/sebuf#161). `repeated string` fields
  now emit `type: array` + `items.type: string` + `style: form` +
  `explode: true` instead of `type: string`, so SDK generators consuming
  the unified bundle produce correct array clients.
- Regenerate all 12 OpenAPI specs (unified bundle + Aviation, Economic,
  Infrastructure, Market, Trade per-service). TS client/server codegen
  is byte-identical to v0.11.0 — only the OpenAPI emitter was out of sync.
- Update three tests that asserted the pre-v0.11 comma-joined wire format
  (`symbols=AAPL,MSFT`) to match the current repeated-param form
  (`symbols=AAPL&symbols=MSFT`) produced by `params.append(...)`:
  - tests/market-service-symbol-casing.test.mjs (2 cases: getAll)
  - tests/stock-analysis-history.test.mts
  - tests/stock-backtest.test.mts

Locally: test:data 6619/6619 pass, typecheck clean, lint exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Apply suggestions from code review

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-23 16:24:03 +03:00

190 lines
5.9 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const MARKET_SERVICE_URL = pathToFileURL(resolve(root, 'src/services/market/index.ts')).href;
const CIRCUIT_BREAKER_URL = pathToFileURL(resolve(root, 'src/utils/circuit-breaker.ts')).href;
function freshImportUrl(url) {
return `${url}?t=${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function overrideGlobal(name, value) {
const original = Object.getOwnPropertyDescriptor(globalThis, name);
Object.defineProperty(globalThis, name, {
configurable: true,
writable: true,
value,
});
return () => {
if (original) Object.defineProperty(globalThis, name, original);
else delete globalThis[name];
};
}
function installBrowserEnv() {
const location = {
hostname: 'worldmonitor.app',
protocol: 'https:',
host: 'worldmonitor.app',
origin: 'https://worldmonitor.app',
};
const navigator = { userAgent: 'node-test', onLine: true };
const window = { location, navigator };
const restoreWindow = overrideGlobal('window', window);
const restoreLocation = overrideGlobal('location', location);
const restoreNavigator = overrideGlobal('navigator', navigator);
return () => {
restoreNavigator();
restoreLocation();
restoreWindow();
};
}
function getRequestUrl(input) {
if (typeof input === 'string') return new URL(input, 'http://localhost');
if (input instanceof URL) return new URL(input.toString());
return new URL(input.url, 'http://localhost');
}
function quote(symbol, price) {
return {
symbol,
name: symbol,
display: symbol,
price,
change: 0,
sparkline: [],
};
}
function marketResponse(quotes) {
return {
quotes,
finnhubSkipped: false,
skipReason: '',
rateLimited: false,
};
}
describe('market service symbol casing', () => {
it('preserves distinct-case symbols in the batched request and response mapping', async () => {
const restoreBrowserEnv = installBrowserEnv();
const { clearAllCircuitBreakers } = await import(freshImportUrl(CIRCUIT_BREAKER_URL));
clearAllCircuitBreakers();
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (input) => {
const url = getRequestUrl(input);
requests.push(url.searchParams.getAll('symbols'));
return new Response(JSON.stringify(marketResponse([
quote('btc-usd', 101),
quote('BTC-USD', 202),
])), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
try {
const { fetchMultipleStocks } = await import(freshImportUrl(MARKET_SERVICE_URL));
const result = await fetchMultipleStocks([
{ symbol: ' btc-usd ', name: 'Lower BTC', display: 'btc lower' },
{ symbol: 'BTC-USD', name: 'Upper BTC', display: 'BTC upper' },
]);
assert.deepEqual(requests[0], ['btc-usd', 'BTC-USD']);
assert.deepEqual(
result.data.map((entry) => entry.symbol),
['btc-usd', 'BTC-USD'],
);
assert.deepEqual(
result.data.map((entry) => entry.name),
['Lower BTC', 'Upper BTC'],
);
} finally {
globalThis.fetch = originalFetch;
clearAllCircuitBreakers();
restoreBrowserEnv();
}
});
it('keeps per-request cache keys isolated when symbols differ only by case', async () => {
const restoreBrowserEnv = installBrowserEnv();
const { clearAllCircuitBreakers } = await import(freshImportUrl(CIRCUIT_BREAKER_URL));
clearAllCircuitBreakers();
const originalFetch = globalThis.fetch;
let fetchCount = 0;
globalThis.fetch = async (input) => {
fetchCount += 1;
const url = getRequestUrl(input);
const [symbol = ''] = url.searchParams.getAll('symbols');
const price = symbol === 'BTC-USD' ? 222 : 111;
return new Response(JSON.stringify(marketResponse([quote(symbol, price)])), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
try {
const { fetchMultipleStocks } = await import(freshImportUrl(MARKET_SERVICE_URL));
const lower = await fetchMultipleStocks([
{ symbol: 'btc-usd', name: 'Lower BTC', display: 'btc lower' },
]);
const upper = await fetchMultipleStocks([
{ symbol: 'BTC-USD', name: 'Upper BTC', display: 'BTC upper' },
]);
assert.equal(fetchCount, 2, 'case-distinct symbol sets must not share one cache entry');
assert.equal(lower.data[0]?.symbol, 'btc-usd');
assert.equal(upper.data[0]?.symbol, 'BTC-USD');
assert.equal(upper.data[0]?.name, 'Upper BTC');
} finally {
globalThis.fetch = originalFetch;
clearAllCircuitBreakers();
restoreBrowserEnv();
}
});
it('keeps requested metadata when the backend normalizes symbol casing', async () => {
const restoreBrowserEnv = installBrowserEnv();
const { clearAllCircuitBreakers } = await import(freshImportUrl(CIRCUIT_BREAKER_URL));
clearAllCircuitBreakers();
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify(marketResponse([
quote('Btc-Usd', 101),
])), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
try {
const { fetchMultipleStocks } = await import(freshImportUrl(MARKET_SERVICE_URL));
const result = await fetchMultipleStocks([
{ symbol: 'btc-usd', name: 'Lower BTC', display: 'btc lower' },
{ symbol: 'BTC-USD', name: 'Upper BTC', display: 'BTC upper' },
]);
assert.equal(result.data[0]?.symbol, 'Btc-Usd');
assert.equal(result.data[0]?.name, 'Lower BTC');
assert.equal(result.data[0]?.display, 'btc lower');
} finally {
globalThis.fetch = originalFetch;
clearAllCircuitBreakers();
restoreBrowserEnv();
}
});
});