mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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>
190 lines
5.9 KiB
JavaScript
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();
|
|
}
|
|
});
|
|
});
|