import { strict as assert } from "node:assert"; import { test } from "node:test"; import { BACKEND_API_BASE_URLS, getOfficialPositionResolverConfig, } from "../lib/jrDataSystemEnvironment"; import { createOfficialPositionResolver, parseOfficialPositionResponse, type OfficialPositionCacheStore, } from "../lib/officialPositionService"; import { officialPositionLookupKey } from "../lib/domain/railway"; const betaConfig = getOfficialPositionResolverConfig("experimental"); const backendPayload = (posNum: string | number = "109", extra: Record = {}) => ({ data: { position: { position_id: "b754e5fc-789b-419c-b5bc-97585cba793f", pos: "大歩危", line: "dosan", pos_num: posNum, position_type: "station", platform: "3", track: "3", note: null, enrichment_status: "unreviewed", first_seen_at: null, last_seen_at: null, revision: 2, ...extra, }, }, }); const input = { line: "dosan", pos: "大歩危", posNum: "109" }; test("persistent cache renders the warm value before refreshing the API", async () => { const cachedPosition = parseOfficialPositionResponse(backendPayload()); const cacheKey = betaConfig.backendApiBaseUrl + ":" + officialPositionLookupKey(input); let requestCount = 0; let savedEntries = new Map(); const cacheStore: OfficialPositionCacheStore = { load: async () => new Map([[cacheKey, cachedPosition]]), save: async (entries) => { savedEntries = new Map(entries); }, }; const resolver = createOfficialPositionResolver({ persistentCache: cacheStore, fetchJson: async () => { requestCount += 1; return backendPayload("110"); }, }); const warmValue = await resolver.resolve(input, betaConfig); assert.equal(warmValue?.posNum, "109"); assert.equal(requestCount, 0); const refreshedValue = await resolver.resolve(input, betaConfig); assert.equal(refreshedValue?.posNum, "110"); assert.equal(requestCount, 1); await new Promise((resolve) => setImmediate(resolve)); assert.equal(savedEntries.get(cacheKey)?.posNum, "110"); }); test("experimental resolver uses beta endpoint and preserves the canonical fields", async () => { const requests: Array<{ url: string; source: string | undefined }> = []; const resolver = createOfficialPositionResolver({ fetchJson: async (url, options) => { requests.push({ url, source: options.source }); return backendPayload(); }, }); const position = await resolver.resolve(input, betaConfig); assert.equal(position?.positionId, "b754e5fc-789b-419c-b5bc-97585cba793f"); assert.equal(position?.pos, "大歩危"); assert.equal(position?.line, "dosan"); assert.equal(position?.posNum, "109"); assert.equal(position?.platform, "3"); assert.equal(position?.track, "3"); assert.equal(position?.note, null); assert.equal(requests.length, 1); const url = new URL(requests[0].url); assert.equal(url.origin, BACKEND_API_BASE_URLS.experimental); assert.equal(url.pathname, "/api/v1/official-positions/resolve"); assert.equal(url.searchParams.get("line"), "dosan"); assert.equal(url.searchParams.get("pos"), "大歩危"); assert.equal(url.searchParams.get("pos_num"), "109"); assert.equal(requests[0].source, "backend_api"); }); test("resolveMany keeps Takamatsu observations separate by line", async () => { const requests: string[] = []; const resolver = createOfficialPositionResolver({ fetchJson: async (url) => { requests.push(url); const parsed = new URL(url); return backendPayload(parsed.searchParams.get("pos_num") ?? "279", { pos: parsed.searchParams.get("pos") ?? "高松", line: parsed.searchParams.get("line") ?? "", }); }, }); const inputs = [ { line: "yosan", pos: "高松", posNum: 279 }, { line: "koutoku", pos: "高松", posNum: 279 }, ]; const result = await resolver.resolveMany(inputs, betaConfig); assert.equal(result.size, 2); assert.deepEqual( requests.map((url) => new URL(url).searchParams.get("line")).sort(), ["koutoku", "yosan"], ); assert.equal( result.get(officialPositionLookupKey(inputs[0]))?.line, "yosan", ); assert.equal( result.get(officialPositionLookupKey(inputs[1]))?.line, "koutoku", ); }); test("pos_num stays textual and the complete tuple forms the lookup key", () => { const withLeadingZero = parseOfficialPositionResponse( backendPayload("0279"), ); const withLetters = parseOfficialPositionResponse(backendPayload("ABC")); assert.equal(withLeadingZero.posNum, "0279"); assert.equal(withLetters.posNum, "ABC"); assert.notEqual( officialPositionLookupKey({ ...input, pos: "大歩危" }), officialPositionLookupKey({ ...input, pos: "小歩危" }), ); assert.notEqual( officialPositionLookupKey({ ...input, line: "dosan" }), officialPositionLookupKey({ ...input, line: "dosan2" }), ); }); test("ten simultaneous requests for one tuple share one HTTP request and later calls hit cache", async () => { let requestCount = 0; let release: (() => void) | undefined; const pending = new Promise((resolve) => { release = resolve; }); const resolver = createOfficialPositionResolver({ fetchJson: async () => { requestCount += 1; await pending; return backendPayload(); }, }); const requests = Array.from({ length: 10 }, () => resolver.resolve(input, betaConfig)); assert.equal(requestCount, 1); release?.(); const positions = await Promise.all(requests); assert.equal(positions.filter(Boolean).length, 10); assert.equal(resolver.getDiagnostics().inFlightDedupeCount, 9); await resolver.resolve(input, betaConfig); assert.equal(requestCount, 1); assert.equal(resolver.getDiagnostics().cacheHitCount, 1); }); test("resolveMany limits initial fan-out to the configured concurrency", async () => { let active = 0; let maxActive = 0; let requestCount = 0; const resolver = createOfficialPositionResolver({ maxConcurrency: 4, fetchJson: async () => { requestCount += 1; active += 1; maxActive = Math.max(maxActive, active); await new Promise((resolve) => setTimeout(resolve, 1)); active -= 1; return backendPayload(); }, }); const inputs = Array.from({ length: 12 }, (_, index) => ({ line: "dosan", pos: "大歩危" + index, posNum: String(index), })); await resolver.resolveMany(inputs, betaConfig); assert.equal(requestCount, 12); assert.ok(maxActive <= 4); }); test("404 becomes unresolved metadata and is cached without throwing", async () => { let requestCount = 0; const resolver = createOfficialPositionResolver({ fetchJson: async () => { requestCount += 1; throw Object.assign(new Error("not found"), { status: 404 }); }, }); assert.equal(await resolver.resolve(input, betaConfig), undefined); assert.equal(await resolver.resolve(input, betaConfig), undefined); assert.equal(requestCount, 1); assert.equal(resolver.getDiagnostics().unresolvedCount, 1); }); test("network failure keeps a cached value during forced refresh", async () => { let shouldFail = false; const resolver = createOfficialPositionResolver({ fetchJson: async () => { if (shouldFail) throw new Error("temporary network failure"); return backendPayload(); }, }); const first = await resolver.resolve(input, betaConfig); shouldFail = true; const refreshed = await resolver.resolve(input, betaConfig, { forceRefresh: true }); assert.equal(refreshed?.positionId, first?.positionId); assert.equal(resolver.getDiagnostics().networkFailureCount, 1); }); test("description remains compatible when the resolver field is renamed", () => { const parsed = parseOfficialPositionResponse( backendPayload("109", { note: undefined, description: "西側ホーム" }), ); assert.equal(parsed.note, "西側ホーム"); }); test("null metadata is accepted", () => { const parsed = parseOfficialPositionResponse( backendPayload("109", { platform: null, track: null, note: null }), ); assert.equal(parsed.platform, null); assert.equal(parsed.track, null); assert.equal(parsed.note, null); }); test("production release uses the production OfficialPosition Backend", async () => { const requests: Array<{ url: string; source: string | undefined }> = []; const resolver = createOfficialPositionResolver({ fetchJson: async (url, options) => { requests.push({ url, source: options.source }); return backendPayload(); }, }); const metadata = await resolver.resolve( input, getOfficialPositionResolverConfig("production_release"), ); assert.equal(metadata?.positionId, "b754e5fc-789b-419c-b5bc-97585cba793f"); assert.equal(requests.length, 1); assert.equal(requests[0].source, "backend_api"); const url = new URL(requests[0].url); assert.equal(url.origin, BACKEND_API_BASE_URLS.production); assert.equal(url.pathname, "/api/v1/official-positions/resolve"); assert.equal(url.searchParams.get("line"), "dosan"); assert.equal(url.searchParams.get("pos"), "大歩危"); assert.equal(url.searchParams.get("pos_num"), "109"); });