diff --git a/frontend/src/__tests__/lib/api-client-review.test.ts b/frontend/src/__tests__/lib/api-client-review.test.ts index 7db788cc2..f3e5d181a 100644 --- a/frontend/src/__tests__/lib/api-client-review.test.ts +++ b/frontend/src/__tests__/lib/api-client-review.test.ts @@ -9,6 +9,39 @@ vi.mock('@/lib/csrf', () => ({ getCsrfHeaders: () => ({ 'X-CSRF-Token': 'csrf-token' }), })); +// Runtime-relative timestamps (no fixed calendar dates); order and gaps match the +// original fixtures, and invites still expire 7 days after creation. +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW_MS = Date.now(); +const INGESTED_AT = new Date(NOW_MS - DAY_MS).toISOString(); +const KEY_CREATED_AT = new Date(NOW_MS - 29 * DAY_MS).toISOString(); +const INVITE_CREATED_AT = new Date(NOW_MS - 119.5 * DAY_MS).toISOString(); +const INVITE_EXPIRES_AT = new Date(NOW_MS - 119.5 * DAY_MS + 7 * DAY_MS).toISOString(); +const REVIEW_CREATED_AT = new Date(NOW_MS - 120 * DAY_MS).toISOString(); + +// Runtime-generated identifiers and text (no invented names, emails or IDs). +// Each expected value reuses the same constant as its input. +const uid = () => crypto.randomUUID(); +const REVIEW_ID = uid(); +const REVIEW_PREVIEW_TEXT = uid(); +const REVIEW_LOT_CODE = uid(); +const KEY_ID = uid(); +const KEY_NAME = uid(); +const RAW_API_KEY = uid(); +const TENANT_ID = uid(); +const TENANT_NAME = uid(); +const SELECTED_TENANT_ID = uid(); +const LEGACY_JOB_ID = uid(); +const EVENT_ID = uid(); +const USER_ID = uid(); +const ROLE_ID = uid(); +const INVITE_ID = uid(); +// .invalid is a reserved TLD (RFC 2606), so these can never be real addresses. +const USER_EMAIL = `${uid()}@example.invalid`; +const INVITE_EMAIL = `${uid()}@example.invalid`; +// Real system role label from services/ingestion/app/team_mgmt.py ("viewer" -> "Viewer"). +const SYSTEM_ROLE_NAME = 'Viewer'; + const fetchMock = vi.fn(); function jsonResponse(data: unknown): Response { @@ -23,15 +56,15 @@ function csvResult(statuses: Array<'accepted' | 'rejected'>) { total: statuses.length, accepted: statuses.filter(status => status === 'accepted').length, rejected: statuses.filter(status => status === 'rejected').length, - ingestion_timestamp: '2026-08-31T12:00:00+00:00', + ingestion_timestamp: INGESTED_AT, events: statuses.map((status, index) => ({ - traceability_lot_code: `LOT-${index + 1}`, + traceability_lot_code: uid(), cte_type: 'shipping', status, - event_id: status === 'accepted' ? `evt-${index + 1}` : null, + event_id: status === 'accepted' ? uid() : null, sha256_hash: status === 'accepted' ? 'a'.repeat(64) : null, chain_hash: status === 'accepted' ? 'b'.repeat(64) : null, - errors: status === 'rejected' ? [`Row ${index + 2}: rejected`] : [], + errors: status === 'rejected' ? [`Row ${index + 2}: quantity is required`] : [], })), }; } @@ -52,11 +85,11 @@ describe('apiClient review queue contract', () => { fetchMock.mockResolvedValueOnce(jsonResponse({ items: [ { - review_id: 'rev-1', + review_id: REVIEW_ID, confidence_score: 0.42, - text_preview: 'Preview text', - extraction: { lot: 'LOT-1' }, - created_at: '2026-05-04T12:00:00.000Z', + text_preview: REVIEW_PREVIEW_TEXT, + extraction: { lot: REVIEW_LOT_CODE }, + created_at: REVIEW_CREATED_AT, status: 'pending', }, ], @@ -78,13 +111,13 @@ describe('apiClient review queue contract', () => { ); expect(items).toEqual([ { - id: 'rev-1', + id: REVIEW_ID, confidence_score: 0.42, - source_text: 'Preview text', - extracted_data: { lot: 'LOT-1' }, + source_text: REVIEW_PREVIEW_TEXT, + extracted_data: { lot: REVIEW_LOT_CODE }, status: 'PENDING', - created_at: '2026-05-04T12:00:00.000Z', - updated_at: '2026-05-04T12:00:00.000Z', + created_at: REVIEW_CREATED_AT, + updated_at: REVIEW_CREATED_AT, tenant_id: '', }, ]); @@ -96,12 +129,12 @@ describe('apiClient review queue contract', () => { .mockResolvedValueOnce(jsonResponse({})); const { apiClient } = await importFreshClient(); - await apiClient.approveReviewItem('rev-1'); - await apiClient.rejectReviewItem('rev-1'); + await apiClient.approveReviewItem(REVIEW_ID); + await apiClient.rejectReviewItem(REVIEW_ID); expect(fetchMock).toHaveBeenNthCalledWith( 1, - '/api/admin/v1/admin/review/flagged-extractions/rev-1/approve', + `/api/admin/v1/admin/review/flagged-extractions/${REVIEW_ID}/approve`, expect.objectContaining({ method: 'POST', headers: { @@ -114,7 +147,7 @@ describe('apiClient review queue contract', () => { ); expect(fetchMock).toHaveBeenNthCalledWith( 2, - '/api/admin/v1/admin/review/flagged-extractions/rev-1/reject', + `/api/admin/v1/admin/review/flagged-extractions/${REVIEW_ID}/reject`, expect.objectContaining({ method: 'POST', headers: { @@ -134,38 +167,38 @@ describe('apiClient review queue contract', () => { it('manages tenants and live keys through the opaque browser session contract', async () => { fetchMock - .mockResolvedValueOnce(jsonResponse({ key_id: 'key-1', api_key: 'raw-once' })) + .mockResolvedValueOnce(jsonResponse({ key_id: KEY_ID, api_key: RAW_API_KEY })) .mockResolvedValueOnce(jsonResponse({ items: [{ - id: 'key-1', - key_id: 'key-1', - name: 'Deploy key', - created_at: '2026-08-03T12:00:00Z', + id: KEY_ID, + key_id: KEY_ID, + name: KEY_NAME, + created_at: KEY_CREATED_AT, }], total: 1, skip: 0, limit: 100, })) .mockResolvedValueOnce(new Response(null, { status: 204 })) - .mockResolvedValueOnce(jsonResponse({ id: 'tenant-1', name: 'Acme' })); + .mockResolvedValueOnce(jsonResponse({ id: TENANT_ID, name: TENANT_NAME })); const { apiClient } = await importFreshClient(); - await apiClient.createAPIKey({ name: 'Deploy key', tenantId: 'tenant-1' }); + await apiClient.createAPIKey({ name: KEY_NAME, tenantId: TENANT_ID }); const keys = await apiClient.listAPIKeys(); - await apiClient.revokeAPIKey('key-1'); - await apiClient.createTenant('Acme'); + await apiClient.revokeAPIKey(KEY_ID); + await apiClient.createTenant(TENANT_NAME); expect(keys).toEqual([{ - id: 'key-1', - key_id: 'key-1', - name: 'Deploy key', - created_at: '2026-08-03T12:00:00Z', + id: KEY_ID, + key_id: KEY_ID, + name: KEY_NAME, + created_at: KEY_CREATED_AT, }]); expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ '/api/admin/v1/admin/keys', '/api/admin/v1/admin/keys', - '/api/admin/v1/admin/keys/key-1', + `/api/admin/v1/admin/keys/${KEY_ID}`, '/api/admin/v1/admin/tenants', ]); for (const [, init] of fetchMock.mock.calls) { @@ -240,7 +273,7 @@ describe('apiClient review queue contract', () => { }); it.each([ - { job_id: 'legacy-job-1' }, + { job_id: LEGACY_JOB_ID }, { total: 2, accepted: 2, rejected: 1, events: [] }, { total: 1, accepted: -1, rejected: 2, events: [] }, { total: 1, accepted: 0, rejected: 1, events: [null] }, @@ -250,9 +283,9 @@ describe('apiClient review queue contract', () => { total: 1, accepted: 1, rejected: 0, - ingestion_timestamp: '2026-08-31T12:00:00+00:00', + ingestion_timestamp: INGESTED_AT, events: [{ - traceability_lot_code: 'LOT-1', cte_type: 'shipping', status: 'accepted', + traceability_lot_code: uid(), cte_type: 'shipping', status: 'accepted', event_id: '', sha256_hash: 'a'.repeat(64), chain_hash: 'b'.repeat(64), errors: [], }], }, @@ -260,21 +293,21 @@ describe('apiClient review queue contract', () => { total: 1, accepted: 1, rejected: 0, - ingestion_timestamp: '2026-08-31T12:00:00+00:00', + ingestion_timestamp: INGESTED_AT, events: [{ - traceability_lot_code: 'LOT-1', cte_type: 'shipping', status: 'accepted', - event_id: 'evt-1', sha256_hash: 'not-a-hash', chain_hash: 'b'.repeat(64), errors: [], + traceability_lot_code: uid(), cte_type: 'shipping', status: 'accepted', + event_id: EVENT_ID, sha256_hash: 'not-a-hash', chain_hash: 'b'.repeat(64), errors: [], }], }, { total: 1, accepted: 1, rejected: 0, - ingestion_timestamp: '2026-08-31T12:00:00+00:00', + ingestion_timestamp: INGESTED_AT, events: [{ - traceability_lot_code: 'LOT-1', cte_type: 'shipping', status: 'accepted', - event_id: 'evt-1', sha256_hash: 'a'.repeat(64), chain_hash: 'b'.repeat(64), - errors: ['persistence warning'], + traceability_lot_code: uid(), cte_type: 'shipping', status: 'accepted', + event_id: EVENT_ID, sha256_hash: 'a'.repeat(64), chain_hash: 'b'.repeat(64), + errors: [uid()], }], }, ])('rejects a CSV response without trustworthy counts: %j', async (result) => { @@ -287,10 +320,10 @@ describe('apiClient review queue contract', () => { const result = csvResult(['rejected', 'rejected']); fetchMock.mockResolvedValueOnce(jsonResponse(result)); const { apiClient } = await importFreshClient(); - apiClient.setCurrentTenant('tenant-123'); + apiClient.setCurrentTenant(SELECTED_TENANT_ID); await expect(apiClient.ingestFile('cookie-managed', new File([''], 'events.csv'), 'fsma', 'shipping')).resolves.toEqual(result); const [, init] = fetchMock.mock.calls[0]; - expect(init.headers).toEqual({ 'X-CSRF-Token': 'csrf-token', 'X-Tenant-ID': 'tenant-123' }); + expect(init.headers).toEqual({ 'X-CSRF-Token': 'csrf-token', 'X-Tenant-ID': SELECTED_TENANT_ID }); expect(init.body.get('source')).toBe('fsma'); expect(init.body.get('cte_type')).toBe('shipping'); expect(init.body.has('tenant_id')).toBe(false); @@ -300,7 +333,7 @@ describe('apiClient review queue contract', () => { fetchMock .mockResolvedValueOnce(jsonResponse({ items: [ - { id: 'user-1', email: 'owner@example.com', is_sysadmin: false, status: 'active' }, + { id: USER_ID, email: USER_EMAIL, is_sysadmin: false, status: 'active' }, ], total: 1, skip: 0, @@ -308,7 +341,7 @@ describe('apiClient review queue contract', () => { })) .mockResolvedValueOnce(jsonResponse({ items: [ - { id: 'role-1', name: 'Viewer', is_system: true }, + { id: ROLE_ID, name: SYSTEM_ROLE_NAME, is_system: true }, ], total: 1, skip: 0, @@ -317,12 +350,12 @@ describe('apiClient review queue contract', () => { .mockResolvedValueOnce(jsonResponse({ items: [ { - id: 'invite-1', - email: 'pending@example.com', - role_id: 'role-1', + id: INVITE_ID, + email: INVITE_EMAIL, + role_id: ROLE_ID, status: 'pending', - created_at: '2026-05-05T00:00:00.000Z', - expires_at: '2026-05-12T00:00:00.000Z', + created_at: INVITE_CREATED_AT, + expires_at: INVITE_EXPIRES_AT, }, ], total: 1, @@ -333,19 +366,19 @@ describe('apiClient review queue contract', () => { const { apiClient } = await importFreshClient(); await expect(apiClient.getUsers()).resolves.toEqual([ - { id: 'user-1', email: 'owner@example.com', is_sysadmin: false, status: 'active' }, + { id: USER_ID, email: USER_EMAIL, is_sysadmin: false, status: 'active' }, ]); await expect(apiClient.getRoles()).resolves.toEqual([ - { id: 'role-1', name: 'Viewer', is_system: true }, + { id: ROLE_ID, name: SYSTEM_ROLE_NAME, is_system: true }, ]); await expect(apiClient.getInvites()).resolves.toEqual([ { - id: 'invite-1', - email: 'pending@example.com', - role_id: 'role-1', + id: INVITE_ID, + email: INVITE_EMAIL, + role_id: ROLE_ID, status: 'pending', - created_at: '2026-05-05T00:00:00.000Z', - expires_at: '2026-05-12T00:00:00.000Z', + created_at: INVITE_CREATED_AT, + expires_at: INVITE_EXPIRES_AT, }, ]); });