Skip to content

Commit d875369

Browse files
committed
fix coverage facet contract validation
1 parent c856c93 commit d875369

2 files changed

Lines changed: 27 additions & 1 deletion

File tree

frontend/src/api/FacetsRepository.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export type FacetResponse = Readonly<{ profile: LocalProfile; releaseId: string;
99
const fail = (kind: ApiError['kind'], message: string): ApiError => Object.assign(new Error(message), { kind });
1010
export type FacetFilters = Readonly<{ country_code?: string | undefined; region?: string | undefined; category?: string | undefined; source_type?: string | undefined; display_precision?: string | undefined; lifecycle_status?: string | undefined }>;
1111

12+
const normalizedFilter = (value: string | null | undefined): string | null => {
13+
const normalized = value?.trim();
14+
return normalized ? normalized : null;
15+
};
16+
1217
export class FacetsRepository {
1318
constructor(private readonly fetcher: FetchLike = globalThis.fetch, private readonly baseUrl = '') {}
1419
async get(profile: LocalProfile, filters: FacetFilters, expected: { releaseId: string; ruleset: string }, signal?: AbortSignal): Promise<FacetResponse> {
@@ -18,9 +23,15 @@ export class FacetsRepository {
1823
try { const init: RequestInit = { cache: 'no-store' }; if (signal) init.signal = signal; response = await this.fetcher.call(globalThis, `${this.baseUrl}/api/v2/discovery/facets?${params}`, init); }
1924
catch (error) { if (error instanceof DOMException && error.name === 'AbortError') throw fail('aborted', 'Facet request was aborted.'); throw fail('network', 'Coverage summary could not connect to the V2 service.'); }
2025
if (!response.ok) throw fail(response.status >= 500 ? 'unavailable' : response.status === 429 ? 'rate-limited' : 'http', `Coverage summary request failed with status ${response.status}.`);
21-
const parsed = responseSchema.safeParse(await response.json());
26+
let payload: unknown;
27+
try { payload = await response.json(); } catch { throw fail('invalid-contract', 'Coverage summary response was not valid JSON.'); }
28+
const parsed = responseSchema.safeParse(payload);
2229
if (!parsed.success) throw fail('invalid-contract', 'Coverage summary response was rejected safely.');
2330
if (parsed.data.meta.profile !== profile || parsed.data.meta.release_id !== expected.releaseId || parsed.data.meta.ruleset_version !== expected.ruleset) throw fail('invalid-contract', 'Coverage summary belongs to a different profile or promoted release.');
31+
const responseFilters = parsed.data.meta.filters;
32+
for (const key of ['country_code', 'region', 'category', 'source_type', 'display_precision', 'lifecycle_status'] as const) {
33+
if (normalizedFilter(filters[key]) !== normalizedFilter(responseFilters[key])) throw fail('invalid-contract', 'Coverage summary belongs to a different filter scope.');
34+
}
2435
return { profile, releaseId: parsed.data.meta.release_id, ruleset: parsed.data.meta.ruleset_version, coverageScope: parsed.data.meta.coverage_scope, countSemantics: parsed.data.meta.count_semantics, dimensions: parsed.data.dimensions };
2536
}
2637
}

frontend/tests/unit/facetsRepository.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,19 @@ describe('FacetsRepository', () => {
1515
const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(body({ meta: { ...body().meta, release_id: 'old-release' } }))));
1616
await expect(new FacetsRepository(fetcher).get('official', {}, { releaseId: 'release-1', ruleset: 'rules-1' })).rejects.toThrow(/different profile or promoted release/);
1717
});
18+
19+
it('rejects a facet snapshot from a different requested filter scope', async () => {
20+
const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(body({ meta: { ...body().meta, filters: { ...body().meta.filters, category: 'slaughter' } } }))));
21+
await expect(new FacetsRepository(fetcher).get('official', { category: 'dairy' }, { releaseId: 'release-1', ruleset: 'rules-1' })).rejects.toMatchObject({ kind: 'invalid-contract' });
22+
});
23+
24+
it('accepts normalized filter metadata from the backend', async () => {
25+
const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(body({ meta: { ...body().meta, filters: { ...body().meta.filters, region: 'North Coast' } } }))));
26+
await expect(new FacetsRepository(fetcher).get('official', { region: ' North Coast ' }, { releaseId: 'release-1', ruleset: 'rules-1' })).resolves.toMatchObject({ releaseId: 'release-1' });
27+
});
28+
29+
it('classifies malformed successful JSON as an invalid contract', async () => {
30+
const fetcher = vi.fn().mockResolvedValue(new Response('{not-json'));
31+
await expect(new FacetsRepository(fetcher).get('official', {}, { releaseId: 'release-1', ruleset: 'rules-1' })).rejects.toMatchObject({ kind: 'invalid-contract' });
32+
});
1833
});

0 commit comments

Comments
 (0)