diff --git a/.github/workflows/copy-gate.yml b/.github/workflows/copy-gate.yml new file mode 100644 index 000000000..ceaeaec75 --- /dev/null +++ b/.github/workflows/copy-gate.yml @@ -0,0 +1,58 @@ +name: Copy gate + +on: + pull_request: + paths: + - 'frontend/**' + - 'scripts/copy-lint/**' + - '.github/workflows/copy-gate.yml' + - 'scripts/copy-lint/baseline.json' + push: + branches: [main] + paths: + - 'frontend/**' + - 'scripts/copy-lint/**' + - '.github/workflows/copy-gate.yml' + - 'scripts/copy-lint/baseline.json' + +permissions: + contents: read + +jobs: + gate: + name: Copy gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: '20' + - run: node --test scripts/copy-lint/check-copy.test.mjs + - name: Read base copy baseline + env: + BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + base_ref="origin/${BASE_REF}" + else + if [[ "$BEFORE_SHA" =~ ^0{40}$ ]]; then + exit 0 + fi + base_ref="$BEFORE_SHA" + fi + base_commit=$(git rev-parse --verify "${base_ref}^{commit}") || exit 1 + baseline_path="scripts/copy-lint/baseline.json" + if git cat-file -e "${base_commit}:${baseline_path}" 2>/dev/null; then + git show "${base_commit}:${baseline_path}" > "$RUNNER_TEMP/base-baseline.json" || exit 1 + else + baseline_entry=$(git ls-tree -r --name-only "$base_commit" -- "$baseline_path") || exit 1 + if [ -n "$baseline_entry" ]; then + echo "Unable to read base copy baseline" >&2 + exit 1 + fi + fi + - run: node scripts/copy-lint/check-copy.mjs . --gate --github --base-baseline "$RUNNER_TEMP/base-baseline.json" diff --git a/.github/workflows/engine-review-pass.yml b/.github/workflows/engine-review-pass.yml index 87ddb7d42..e84025c1f 100644 --- a/.github/workflows/engine-review-pass.yml +++ b/.github/workflows/engine-review-pass.yml @@ -7,15 +7,18 @@ on: types: [opened, synchronize, reopened] pull_request_review: types: [submitted, dismissed] + issue_comment: + types: [created, edited] # Review bursts must not cancel in-progress runs. concurrency: - group: engine-review-pass-${{ github.event.pull_request.number }} + group: engine-review-pass-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: false permissions: statuses: write pull-requests: read + issues: read contents: read jobs: @@ -30,7 +33,20 @@ jobs: script: | const owner = context.repo.owner; const repo = context.repo.repo; - const pr = context.payload.pull_request; + let pr = context.payload.pull_request; + + if (!pr) { + if (!context.payload.issue?.pull_request) { + core.info('Issue comment is not on a pull request. Skipping.'); + return; + } + const prResponse = await github.rest.pulls.get({ + owner, + repo, + pull_number: context.payload.issue.number, + }); + pr = prResponse.data; + } if (!pr) { core.setFailed('Could not determine pull request from event payload.'); @@ -50,6 +66,9 @@ jobs: 'grok', 'muse', 'kimi', + 'pi', + 'deepseek', + 'hermes', 'visual-designer', 'product-designer', ]); @@ -107,7 +126,29 @@ jobs: core.info(`Total reviews retrieved: ${reviews.length}`); - const eligibleReviews = reviews.filter((r) => { + core.info(`Fetching comments for pull request #${prNumber}...`); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + + core.info(`Total issue comments retrieved: ${comments.length}`); + + const normalizedComments = comments.map((c) => ({ + id: c.id, + body: c.body, + submitted_at: c.created_at, + author_association: c.author_association, + user: c.user, + state: 'COMMENTED', + })); + + const allReviews = reviews.concat(normalizedComments); + + const eligibleReviews = allReviews.filter((r) => { if (r.state === 'DISMISSED' || !r.submitted_at || !r.body) { return false; } @@ -131,7 +172,7 @@ jobs: }); // The marker must be alone on its own line. - const markerRegex = /^REVIEW: (PASS|BLOCKED|FAIL) ([A-Za-z0-9._-]+) ([0-9a-f]{7,40})\s*$/; + const markerRegex = /^REVIEW: (PASS|BLOCKED|FAIL) ([A-Za-z0-9._-]+) ([0-9a-f]{40})\s*$/; const headShaLower = headSha.toLowerCase(); const authorEngineLower = authorEngine.toLowerCase(); @@ -154,7 +195,7 @@ jobs: continue; } - if (!headShaLower.startsWith(markerSha.toLowerCase())) { + if (markerSha.toLowerCase() !== headShaLower) { core.info(`Ignoring marker for non-matching SHA ${markerSha} on head ${headSha}`); continue; } diff --git a/alembic/versions/20261024_b005ddbf1dba_v136_supplier_agreed_sending_hours.py b/alembic/versions/20261024_b005ddbf1dba_v136_supplier_agreed_sending_hours.py new file mode 100644 index 000000000..7e48ae019 --- /dev/null +++ b/alembic/versions/20261024_b005ddbf1dba_v136_supplier_agreed_sending_hours.py @@ -0,0 +1,43 @@ +"""Agreed sending time per supplier facility. + +Mirrors supabase/migrations/20261024080000_supplier_agreed_sending_hours.sql +exactly: adds public.supplier_facilities.agreed_sending_hours, integer NULL, +CHECK (agreed_sending_hours > 0). No backfill. The supplier data quality view +counts a CTE record as late when it is received more than this many hours +after its event time. + +Additive and idempotent (ADD COLUMN IF NOT EXISTS with an inline CHECK that is +only created together with the column), so the plain SQL file can run ahead of +it. Downgrade drops only what this revision created. + +Revision ID: b005ddbf1dba +Revises: 41f96588d87c +Create Date: 2026-10-24 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "b005ddbf1dba" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "41f96588d87c" # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ +alter table public.supplier_facilities + add column if not exists agreed_sending_hours integer null + constraint supplier_facilities_agreed_sending_hours_check + check (agreed_sending_hours > 0); +""" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE public.supplier_facilities DROP CONSTRAINT IF EXISTS supplier_facilities_agreed_sending_hours_check" + ) + op.execute("ALTER TABLE public.supplier_facilities DROP COLUMN IF EXISTS agreed_sending_hours") diff --git a/docs/schema/expected_schema.json b/docs/schema/expected_schema.json index 97739280e..95c52ff48 100644 --- a/docs/schema/expected_schema.json +++ b/docs/schema/expected_schema.json @@ -5525,6 +5525,55 @@ } ] }, + "public.data_gap_events": { + "columns": { + "closed_at": { + "nullable": true, + "type": "timestamp" + }, + "closing_record_hash": { + "nullable": true, + "type": "text" + }, + "closing_record_id": { + "nullable": true, + "type": "uuid" + }, + "cte_type": { + "nullable": false, + "type": "text" + }, + "gap_key": { + "nullable": false, + "type": "text" + }, + "gap_kind": { + "nullable": false, + "type": "text" + }, + "id": { + "nullable": false, + "type": "uuid" + }, + "lot_code": { + "nullable": false, + "type": "text" + }, + "opened_at": { + "nullable": false, + "type": "timestamp" + }, + "supplier_key": { + "nullable": false, + "type": "text" + }, + "tenant_id": { + "nullable": false, + "type": "uuid" + } + }, + "foreign_keys": [] + }, "public.food_traceability_list": { "columns": { "applicable_ctes": { @@ -6758,6 +6807,10 @@ }, "public.supplier_facilities": { "columns": { + "agreed_sending_hours": { + "nullable": true, + "type": "integer" + }, "city": { "nullable": false, "type": "text" diff --git a/docs/security/route-security-manifest.json b/docs/security/route-security-manifest.json index 00bbed8cc..fb55757a3 100644 --- a/docs/security/route-security-manifest.json +++ b/docs/security/route-security-manifest.json @@ -2956,6 +2956,19 @@ "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", "test_adapter": "tenant_dual_session_csrf" }, + { + "operation": "admin:PUT:/v1/supplier/facilities/{facility_id}/agreed-sending-hours", + "owner": "admin", + "method": "PUT", + "path": "/v1/supplier/facilities/{facility_id}/agreed-sending-hours", + "classification": "tenant-scoped", + "authentication": "bearer-token-or-opaque-session-cookie", + "required_permissions": [ + "authenticated" + ], + "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", + "test_adapter": "tenant_dual_session_csrf" + }, { "operation": "admin:POST:/v1/supplier/facilities/{facility_id}/cte-events", "owner": "admin", @@ -4944,13 +4957,13 @@ } ], "summary": { - "enabled_operation_count": 257, + "enabled_operation_count": 258, "disabled_operation_count": 180, "disabled_unique_path_count": 163, "enabled_by_classification": { "global-sysadmin": 16, "public": 30, - "tenant-scoped": 204, + "tenant-scoped": 205, "workload-only": 7 } } diff --git a/frontend/package.json b/frontend/package.json index 1089ebec2..272542906 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "src/lib/capabilities.ts" ] }, + "//knip": "src/lib/capabilities.ts is read by scripts/copy-lint/check-copy.mjs (the SEL-452 copy gate registry), not imported by frontend code.", "//": "Pinning policy (#569): next, eslint-config-next, @sentry/nextjs, and @supabase/supabase-js are pinned to exact versions (no ^ or ~ prefix) because breaking changes in these packages have caused production incidents. Bump them deliberately after testing. All other deps use ^ for minor/patch auto-updates.", "dependencies": { "@radix-ui/react-checkbox": "^1.4.1", diff --git a/frontend/public/samples/sample_recall_drill.csv b/frontend/public/samples/sample_recall_drill.csv deleted file mode 100644 index daa15cd42..000000000 --- a/frontend/public/samples/sample_recall_drill.csv +++ /dev/null @@ -1,11 +0,0 @@ -Traceability Lot Code (TLC),Product Description,Quantity,Unit of Measure,Event Type,Event Date,Event Time,Facility Name,Facility GLN,Facility Address,Confidence Score -SV-20240115-001,Romaine Lettuce,500,cases,SHIPPING,2024-01-15,14:00:00,Sunny Valley Farms,1234567890128,"123 Farm Road, Salinas CA",0.98 -SV-20240115-001,Romaine Lettuce,500,cases,RECEIVING,2024-01-16,08:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.97 -FC-20240116-A,Chopped Romaine,250,cases,TRANSFORMATION,2024-01-16,10:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.95 -FC-20240116-B,Romaine Hearts,250,cases,TRANSFORMATION,2024-01-16,11:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.95 -FC-20240116-A,Chopped Romaine,100,cases,SHIPPING,2024-01-17,06:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.96 -FC-20240116-A,Chopped Romaine,100,cases,RECEIVING,2024-01-17,10:00:00,Metro Foods,3456789012340,"789 Distribution Dr, Oakland CA",0.94 -FC-20240116-B,Romaine Hearts,150,cases,SHIPPING,2024-01-17,07:00:00,Fresh Cut Co,2345678901234,"456 Processing Lane, Fresno CA",0.96 -FC-20240116-B,Romaine Hearts,150,cases,RECEIVING,2024-01-17,11:00:00,Valley Wholesale,4567890123456,"321 Wholesale Ave, Stockton CA",0.93 -FC-20240116-A,Chopped Romaine,50,cases,SHIPPING,2024-01-18,05:00:00,Metro Foods,3456789012340,"789 Distribution Dr, Oakland CA",0.95 -FC-20240116-A,Chopped Romaine,50,cases,RECEIVING,2024-01-18,08:00:00,SuperMart #42,5678901234562,"100 Retail St, San Jose CA",0.92 diff --git a/frontend/src/__tests__/components/demo-banner.test.tsx b/frontend/src/__tests__/components/demo-banner.test.tsx index 91ca22ed7..410c79984 100644 --- a/frontend/src/__tests__/components/demo-banner.test.tsx +++ b/frontend/src/__tests__/components/demo-banner.test.tsx @@ -9,13 +9,13 @@ import { DemoBanner } from '@/components/control-plane/demo-banner'; describe('DemoBanner Component', () => { it('renders when visible=true', () => { render(); - expect(screen.getByText('Sample Data')).toBeInTheDocument(); + expect(screen.getByText('No data yet')).toBeInTheDocument(); }); - it('shows "Sample Data" text and description', () => { + it('shows the empty-state text and a next-step link', () => { render(); - expect(screen.getByText('Sample Data')).toBeInTheDocument(); - expect(screen.getByText(/backend unavailable/i)).toBeInTheDocument(); + expect(screen.getByText('No data yet')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /connect real data/i })).toHaveAttribute('href', '/ingest'); }); it('does not render when visible=false', () => { diff --git a/frontend/src/__tests__/components/evidence-chain.test.tsx b/frontend/src/__tests__/components/evidence-chain.test.tsx deleted file mode 100644 index e677d6995..000000000 --- a/frontend/src/__tests__/components/evidence-chain.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { - EvidenceChain, - type EvidenceChainNode, - type EvidenceChainProps, -} from '@/app/tools/inflow-lab/components/EvidenceChain'; - -const CTES = ['Harvesting', 'Cooling', 'Initial packing', 'Shipping', 'DC receiving']; - -/** Mirrors `toLineage`: five slots, present events complete, the rest missing. */ -function lineage(completeCount: number): EvidenceChainNode[] { - return CTES.map((cte, index) => ({ - cte, - location: index < completeCount ? `Facility ${index + 1}` : 'Not captured', - time: index < completeCount ? `May 7, 2026, 10:0${index} AM` : 'Missing CTE', - state: index < completeCount ? 'complete' : 'missing', - })); -} - -function renderChain(overrides: Partial = {}) { - const props: EvidenceChainProps = { - nodes: lineage(3), - hasData: true, - isBlocked: true, - isBusy: false, - lot: { code: 'lot-code', product: 'product-name', supplier: 'supplier-name', cases: 1 }, - missingEvidence: 'Shipping, DC receiving', - patternLabel: 'Missing handoff evidence', - fixPath: 'Map the missing handoff fields once, then re-run the lot group.', - lotsAffected: 2, - rowsRead: 11, - lotCount: 3, - runKey: 1, - onRun: vi.fn(), - onOpenEditor: vi.fn(), - onFocusDefect: null, - ...overrides, - }; - return { props, ...render() }; -} - -describe('EvidenceChain', () => { - describe('with no data yet', () => { - // `toLineage([])` returns five *missing* nodes. The chain must read that - // as "not traced yet", never as five defects — the panel is not allowed - // to invent a failure before an evaluation has run. - const noData = { nodes: lineage(0), hasData: false, isBlocked: false, lot: null }; - - it('holds the export gate open rather than reporting a block', () => { - renderChain(noData); - expect(screen.getByText('Awaiting trace')).toBeInTheDocument(); - expect(screen.queryByText('Blocked')).not.toBeInTheDocument(); - }); - - it('does not invent a defect from an untraced chain', () => { - renderChain(noData); - expect(screen.queryByText(/records? never arrived/)).not.toBeInTheDocument(); - expect(screen.queryByText('Blocked now')).not.toBeInTheDocument(); - expect(screen.queryByText('no record received')).not.toBeInTheDocument(); - // Every CTE is announced as untraced, not as missing evidence. - CTES.forEach((cte) => { - expect(screen.getByText(cte).textContent).toContain('not yet traced'); - }); - }); - - it('counts nothing as proven', () => { - renderChain(noData); - expect(screen.getByText('0/5')).toBeInTheDocument(); - }); - }); - - describe('with a blocked lot', () => { - it('names how many records are missing and what they were', () => { - renderChain(); - expect(screen.getByText('2 records never arrived.')).toBeInTheDocument(); - expect(screen.getByText('Blocked')).toBeInTheDocument(); - expect(screen.getByText('Shipping, DC receiving')).toBeInTheDocument(); - expect(screen.getByText('Missing handoff evidence')).toBeInTheDocument(); - }); - - it('singularises the heading for a single missing record', () => { - renderChain({ nodes: lineage(4), missingEvidence: 'DC receiving' }); - expect(screen.getByText('One record never arrived.')).toBeInTheDocument(); - }); - - it('reports proven links and the rows behind them', () => { - renderChain(); - expect(screen.getByText('3/5')).toBeInTheDocument(); - expect(screen.getByText('11')).toBeInTheDocument(); - }); - - it('marks the missing CTEs for assistive tech, not by colour alone', () => { - renderChain(); - expect(screen.getByText('Shipping').textContent).toContain('missing'); - expect(screen.getByText('Harvesting').textContent).toContain('proven'); - }); - - it('reports a shared pattern across lots', () => { - renderChain({ lotsAffected: 2 }); - expect(screen.getByText('2 lots, same pattern')).toBeInTheDocument(); - }); - - it('does not overstate the blast radius for a lone lot', () => { - renderChain({ lotsAffected: 1 }); - expect(screen.getByText('This lot only')).toBeInTheDocument(); - }); - - it('offers the defect jump only when there is a group to jump to', async () => { - const onFocusDefect = vi.fn(); - const { unmount } = renderChain({ onFocusDefect }); - await userEvent.click(screen.getByRole('button', { name: 'Focus this defect' })); - expect(onFocusDefect).toHaveBeenCalledOnce(); - unmount(); - - renderChain({ onFocusDefect: null }); - expect(screen.queryByRole('button', { name: 'Focus this defect' })).not.toBeInTheDocument(); - }); - }); - - describe('with a clean lot', () => { - const clean = { nodes: lineage(5), isBlocked: false }; - - it('clears the export gate and says so without hedging', () => { - renderChain(clean); - expect(screen.getByText('Preview-ready')).toBeInTheDocument(); - expect(screen.getByText('The chain closes end to end.')).toBeInTheDocument(); - expect(screen.getByText('5/5')).toBeInTheDocument(); - }); - - it('reports no defect when there is none', () => { - renderChain(clean); - expect(screen.queryByText(/never arrived/)).not.toBeInTheDocument(); - expect(screen.queryByText('Blocked now')).not.toBeInTheDocument(); - }); - }); - - describe('controls', () => { - it('runs the trace and opens the data editor, with no sample on offer', async () => { - const onRun = vi.fn(); - const onOpenEditor = vi.fn(); - renderChain({ onRun, onOpenEditor }); - - await userEvent.click(screen.getByRole('button', { name: /Run trace again/ })); - expect(onRun).toHaveBeenCalledOnce(); - - await userEvent.click(screen.getByRole('button', { name: 'Open data editor' })); - expect(onOpenEditor).toHaveBeenCalledOnce(); - expect(screen.queryByRole('button', { name: /sample/i })).not.toBeInTheDocument(); - }); - - it('invites a first run before any data exists', () => { - renderChain({ nodes: lineage(0), hasData: false, isBlocked: false, lot: null }); - expect(screen.getByRole('button', { name: /Run trace$/ })).toBeInTheDocument(); - expect(screen.getByText('Awaiting supplier rows')).toBeInTheDocument(); - expect(screen.getByText('No data yet')).toBeInTheDocument(); - }); - - it('locks the run control while a trace is in flight', () => { - renderChain({ isBusy: true }); - const chain = screen.getByTestId('inflow-evidence-chain'); - expect(within(chain).getByRole('button', { name: /Tracing/ })).toBeDisabled(); - }); - }); -}); diff --git a/frontend/src/__tests__/components/supplier-data-quality.test.tsx b/frontend/src/__tests__/components/supplier-data-quality.test.tsx index 27947d94f..8ea93ef32 100644 --- a/frontend/src/__tests__/components/supplier-data-quality.test.tsx +++ b/frontend/src/__tests__/components/supplier-data-quality.test.tsx @@ -4,11 +4,13 @@ import type { SupplierDataQuality, SupplierQualityMetric, SupplierQualityRecords const mocks = vi.hoisted(() => ({ getSupplierDataQualityRecords: vi.fn(), + setFacilityAgreedSendingHours: vi.fn(), })); vi.mock('@/lib/api-client', () => ({ apiClient: { getSupplierDataQualityRecords: (...args: unknown[]) => mocks.getSupplierDataQualityRecords(...args), + setFacilityAgreedSendingHours: (...args: unknown[]) => mocks.setFacilityAgreedSendingHours(...args), }, })); @@ -38,21 +40,35 @@ const LOT_IDS = uuids(MIN_RECORDS - 1); const UNLINKED_LOT_IDS = LOT_IDS.filter((_, index) => index % 2 === 0); const MISSING = { numerator: FLAGGED_RECORD_IDS.length, denominator: RECORD_IDS.length }; const UNLINKED = { numerator: UNLINKED_LOT_IDS.length, denominator: LOT_IDS.length }; +const LATE_RECORD_IDS = uuids(MIN_RECORDS); +const LATE_FLAGGED_IDS = LATE_RECORD_IDS.filter((_, index) => index % 2 === 0); +const LATE = { numerator: LATE_FLAGGED_IDS.length, denominator: LATE_RECORD_IDS.length }; +// Any positive whole number of hours; derived rather than picked. +const AGREED_HOURS = MIN_RECORDS; const LATE_REASON = crypto.randomUUID(); const EPCIS_REASON = crypto.randomUUID(); const missingShare = MISSING.numerator / MISSING.denominator; const formatShare = (share: number) => `${Math.round(share * 1000) / 10}%`; -const summary = (metrics: SupplierQualityMetric[]): SupplierDataQuality => ({ supplier_key: FACILITY_ID, metrics }); +const summary = (metrics: SupplierQualityMetric[], agreedSendingHours: number | null = null): SupplierDataQuality => ({ + supplier_key: FACILITY_ID, + agreed_sending_hours: agreedSendingHours, + metrics, +}); const fourMetrics = summary([ metric({ key: 'missing_kde', status: 'ok', ...MISSING, share: missingShare }), metric({ key: 'unlinked_lot', status: 'not_enough_data', ...UNLINKED, share: null }), - metric({ key: 'late_cte', status: 'unavailable', numerator: null, denominator: null, reason: LATE_REASON }), + metric({ key: 'late_cte', status: 'not_enough_data', numerator: null, denominator: null, reason: LATE_REASON }), metric({ key: 'epcis_failed', status: 'unavailable', numerator: null, denominator: null, reason: EPCIS_REASON }), ]); -function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false) { +const lateMeasured = summary( + [metric({ key: 'late_cte', status: 'ok', ...LATE, share: LATE.numerator / LATE.denominator })], + AGREED_HOURS, +); + +function renderQuality(data: SupplierDataQuality | null = fourMetrics, unavailable = false, onSendingHoursSaved?: () => void) { return render( , ); } @@ -79,6 +96,7 @@ const page = (overrides: Partial): SupplierQuali describe('DataQuality', () => { beforeEach(() => { mocks.getSupplierDataQualityRecords.mockReset(); + mocks.setFacilityAgreedSendingHours.mockReset(); }); it('shows count of denominator and a share for each metric, with no combined score', () => { @@ -97,7 +115,7 @@ describe('DataQuality', () => { it('shows unavailable metrics with their reason and the late CTE label', () => { renderQuality(); - expect(screen.getByText(`Unavailable. ${LATE_REASON}`)).toBeInTheDocument(); + expect(screen.getByText(`Not enough data. ${LATE_REASON}`)).toBeInTheDocument(); expect(screen.getByText(`Unavailable. ${EPCIS_REASON}`)).toBeInTheDocument(); expect(screen.getByText(LATE_LABEL)).toBeInTheDocument(); }); @@ -211,4 +229,87 @@ describe('DataQuality', () => { renderQuality(null, false); expect(screen.getByText('Data quality: No data')).toBeInTheDocument(); }); + + it('shows the late share against the agreed sending time and offers its records', async () => { + const [lateId] = uuids(1); + mocks.getSupplierDataQualityRecords.mockResolvedValueOnce( + page({ + metric: 'late_cte', + total: [lateId].length, + items: [ + { + record_id: lateId, + lot_id: null, + lot_code: crypto.randomUUID(), + cte_type: 'shipping', + event_time: null, + received_at: null, + missing_kdes: [], + }, + ], + }), + ); + renderQuality(lateMeasured); + expect( + screen.getByText(`${LATE.numerator} of ${LATE.denominator} records (${formatShare(LATE.numerator / LATE.denominator)})`), + ).toBeInTheDocument(); + expect(screen.getByText(`Agreed sending time: ${AGREED_HOURS} hours after the event`)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'View records' })); + await waitFor(() => expect(screen.getByText(lateId)).toBeInTheDocument()); + expect(mocks.getSupplierDataQualityRecords).toHaveBeenCalledWith(FACILITY_ID, 'late_cte', expect.anything()); + }); + + it('offers no late drill down without an agreed sending time', () => { + renderQuality(summary([fourMetrics.metrics[2]])); + expect(screen.getByText('Agreed sending time: Not set')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'View records' })).not.toBeInTheDocument(); + }); + + it('saves a new agreed sending time and reloads the measures', async () => { + const onSaved = vi.fn(); + mocks.setFacilityAgreedSendingHours.mockResolvedValueOnce({ + facility_id: FACILITY_ID, + agreed_sending_hours: AGREED_HOURS, + }); + renderQuality(summary([fourMetrics.metrics[2]]), false, onSaved); + + fireEvent.click(screen.getByRole('button', { name: 'Set' })); + fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: String(AGREED_HOURS) } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(mocks.setFacilityAgreedSendingHours).toHaveBeenCalledWith(FACILITY_ID, AGREED_HOURS); + }); + + it.each(['0', `-${AGREED_HOURS}`, `${AGREED_HOURS}.5`, ''])('refuses %j as an agreed sending time', (draft) => { + renderQuality(summary([fourMetrics.metrics[2]])); + fireEvent.click(screen.getByRole('button', { name: 'Set' })); + fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: draft } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole number of hours above zero'); + expect(mocks.setFacilityAgreedSendingHours).not.toHaveBeenCalled(); + }); + + it('clears the agreed sending time with null', async () => { + const onSaved = vi.fn(); + mocks.setFacilityAgreedSendingHours.mockResolvedValueOnce({ facility_id: FACILITY_ID, agreed_sending_hours: null }); + renderQuality(lateMeasured, false, onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + expect(screen.getByLabelText('Hours after the event')).toHaveValue(AGREED_HOURS); + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(mocks.setFacilityAgreedSendingHours).toHaveBeenCalledWith(FACILITY_ID, null); + }); + + it('says so when saving the agreed sending time fails', async () => { + const onSaved = vi.fn(); + mocks.setFacilityAgreedSendingHours.mockRejectedValueOnce(new Error(crypto.randomUUID())); + renderQuality(summary([fourMetrics.metrics[2]]), false, onSaved); + fireEvent.click(screen.getByRole('button', { name: 'Set' })); + fireEvent.change(screen.getByLabelText('Hours after the event'), { target: { value: String(AGREED_HOURS) } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('The agreed sending time could not be saved')); + expect(onSaved).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/__tests__/lib/inflow-error.test.ts b/frontend/src/__tests__/lib/inflow-error.test.ts deleted file mode 100644 index da5e926c1..000000000 --- a/frontend/src/__tests__/lib/inflow-error.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { inflowErrorMessage } from '@/lib/inflow-error'; - -describe('Inflow connection errors', () => { - it('explains an empty disabled-route 404 without claiming the backend is down', () => { - expect(inflowErrorMessage(404, {})).toContain('Core backend health does not include the simulator'); - }); - it('preserves a specific upstream error', () => { - expect(inflowErrorMessage(404, { error: 'Fixture missing' })).toBe('Fixture missing'); - }); - it('reports other status failures', () => { - expect(inflowErrorMessage(502, {})).toBe('Inflow Lab request failed: 502'); - }); -}); diff --git a/frontend/src/__tests__/lib/inflow-lab-exception-grouping.test.ts b/frontend/src/__tests__/lib/inflow-lab-exception-grouping.test.ts deleted file mode 100644 index 785fb46d4..000000000 --- a/frontend/src/__tests__/lib/inflow-lab-exception-grouping.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { - classifyExceptionLot, - groupExceptionsByPattern, - summarizeSourceSuppliers, - type LotForGrouping, -} from "@/app/tools/inflow-lab/lib/exception-grouping"; - -function lot(id: string, missingCtes: string[], opts: Partial = {}): LotForGrouping { - return { - lotCode: id, - product: opts.product ?? "Romaine", - missingCtes, - readiness: opts.readiness ?? { - state: "warning", - deliveryLabel: "posted", - exportReady: false, - }, - events: opts.events ?? 4, - cases: opts.cases ?? 4, - supplier: opts.supplier, - }; -} - -function buildHandoffLots(count: number): LotForGrouping[] { - return Array.from({ length: count }, (_, i) => { - const missing = i % 3 === 0 ? ["Shipping"] : i % 3 === 1 ? ["Initial packing"] : ["DC receiving"]; - return lot(`TLC-HANDOFF-${i.toString().padStart(3, "0")}`, missing, { product: "Romaine" }); - }); -} - -function buildSourceLots(count: number, supplierSplit: { harvestSupplier: string; coolingSupplier: string }) { - return Array.from({ length: count }, (_, i) => { - const role = i % 5; - if (role === 0 || role === 1 || role === 2) { - return lot(`TLC-SOURCE-H-${i}`, ["Harvesting"], { supplier: supplierSplit.harvestSupplier }); - } - if (role === 3) { - return lot(`TLC-SOURCE-C-${i}`, ["Cooling"], { supplier: supplierSplit.coolingSupplier }); - } - return lot(`TLC-SOURCE-B-${i}`, ["Harvesting", "Cooling"], { supplier: supplierSplit.harvestSupplier }); - }); -} - -function buildMixedLots(count: number) { - return Array.from({ length: count }, (_, i) => { - const combos = [ - ["Harvesting", "Shipping"], - ["Cooling", "DC receiving"], - ["Initial packing", "Harvesting"], - ["Shipping", "Cooling"], - ]; - return lot(`TLC-MIXED-${i}`, combos[i % combos.length]); - }); -} - -describe("classifyExceptionLot", () => { - it("classifies a lot with only handoff CTEs missing as handoff", () => { - expect(classifyExceptionLot(lot("a", ["Shipping"]))).toBe("handoff"); - expect(classifyExceptionLot(lot("a", ["Initial packing", "DC receiving"]))).toBe("handoff"); - }); - - it("classifies a lot with only source CTEs missing as source", () => { - expect(classifyExceptionLot(lot("a", ["Harvesting"]))).toBe("source"); - expect(classifyExceptionLot(lot("a", ["Harvesting", "Cooling"]))).toBe("source"); - }); - - it("classifies a lot with both handoff and source CTEs missing as mixed", () => { - expect(classifyExceptionLot(lot("a", ["Harvesting", "Shipping"]))).toBe("mixed"); - expect(classifyExceptionLot(lot("a", ["Cooling", "DC receiving"]))).toBe("mixed"); - }); - - it("classifies a lot with no missing CTEs but otherwise blocked as mixed", () => { - expect(classifyExceptionLot(lot("a", []))).toBe("mixed"); - }); -}); - -describe("groupExceptionsByPattern", () => { - it("returns three groups in canonical order: handoff, source, mixed", () => { - const groups = groupExceptionsByPattern([]); - expect(groups.map((g) => g.id)).toEqual(["handoff", "source", "mixed"]); - }); - - it("partitions a 35-lot fixture into the right counts (18 / 10 / 7)", () => { - const fixture: LotForGrouping[] = [ - ...buildHandoffLots(18), - ...buildSourceLots(10, { harvestSupplier: "Supplier B", coolingSupplier: "Supplier C" }), - ...buildMixedLots(7), - ]; - expect(fixture).toHaveLength(35); - - const groups = groupExceptionsByPattern(fixture); - - const byId = Object.fromEntries(groups.map((g) => [g.id, g])); - expect(byId.handoff.lots).toHaveLength(18); - expect(byId.source.lots).toHaveLength(10); - expect(byId.mixed.lots).toHaveLength(7); - }); - - it("flags handoff and source groups as bulk-actionable but mixed as not", () => { - const fixture: LotForGrouping[] = [ - ...buildHandoffLots(2), - ...buildSourceLots(2, { harvestSupplier: "Supplier B", coolingSupplier: "Supplier C" }), - ...buildMixedLots(2), - ]; - - const groups = groupExceptionsByPattern(fixture); - const byId = Object.fromEntries(groups.map((g) => [g.id, g])); - - expect(byId.handoff.bulkActionable).toBe(true); - expect(byId.source.bulkActionable).toBe(true); - expect(byId.mixed.bulkActionable).toBe(false); - }); - - it("preserves lot identity within each group (no reordering across groups)", () => { - const handoffLot = lot("HO-1", ["Shipping"]); - const sourceLot = lot("SR-1", ["Harvesting"]); - const mixedLot = lot("MX-1", ["Harvesting", "Shipping"]); - - const groups = groupExceptionsByPattern([mixedLot, sourceLot, handoffLot]); - const byId = Object.fromEntries(groups.map((g) => [g.id, g])); - - expect(byId.handoff.lots[0].lotCode).toBe("HO-1"); - expect(byId.source.lots[0].lotCode).toBe("SR-1"); - expect(byId.mixed.lots[0].lotCode).toBe("MX-1"); - }); -}); - -describe("summarizeSourceSuppliers", () => { - it("breaks source lots down by supplier and missing-KDE field with overlap", () => { - const sourceLots: LotForGrouping[] = [ - ...Array.from({ length: 4 }, (_, i) => - lot(`B-${i}`, ["Harvesting"], { supplier: "Supplier B" }) - ), - ...Array.from({ length: 2 }, (_, i) => - lot(`C-${i}`, ["Cooling"], { supplier: "Supplier C" }) - ), - ...Array.from({ length: 4 }, (_, i) => - lot(`BC-${i}`, ["Harvesting", "Cooling"], { supplier: "Supplier B" }) - ), - ]; - - const summary = summarizeSourceSuppliers(sourceLots); - const harvestB = summary.harvestSuppliers.find((s) => s.supplier === "Supplier B"); - const coolingC = summary.coolingSuppliers.find((s) => s.supplier === "Supplier C"); - - expect(harvestB?.lotCount).toBe(8); - expect(coolingC?.lotCount).toBe(2); - expect(summary.overlapCount).toBe(4); - }); -}); diff --git a/frontend/src/__tests__/pages/dashboard-navigation.test.tsx b/frontend/src/__tests__/pages/dashboard-navigation.test.tsx index d7a450d9f..f31990594 100644 --- a/frontend/src/__tests__/pages/dashboard-navigation.test.tsx +++ b/frontend/src/__tests__/pages/dashboard-navigation.test.tsx @@ -67,20 +67,14 @@ describe('Dashboard navigation discoverability', () => { expect(screen.queryByText('All Systems Operational')).not.toBeInTheDocument(); }); - it('exposes Inflow Lab as a top-level active dashboard link', async () => { + it('does not surface Inflow Lab in the dashboard navigation', async () => { render(
Inflow content
, ); - const inflowLink = screen - .getAllByText('Inflow Lab') - .map((node) => node.closest('a')) - .find((link) => link?.getAttribute('aria-current') === 'page'); - expect(inflowLink).not.toBeNull(); - expect(inflowLink).toHaveAttribute('href', '/dashboard/inflow-lab'); - expect(inflowLink).toHaveAttribute('aria-current', 'page'); + expect(screen.queryByText('Inflow Lab')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /Intake/i })).toBeInTheDocument(); expect(screen.getAllByText('Document Ingestion')[0].closest('a')).toHaveAttribute('href', '/ingest'); }); diff --git a/frontend/src/__tests__/pages/data-import.test.tsx b/frontend/src/__tests__/pages/data-import.test.tsx index 282f46f90..29cfa6759 100644 --- a/frontend/src/__tests__/pages/data-import.test.tsx +++ b/frontend/src/__tests__/pages/data-import.test.tsx @@ -50,6 +50,12 @@ function csvResult(accepted: number, rejected: number) { describe('Data Import Hub CSV results', () => { beforeEach(() => vi.clearAllMocks()); + it('does not render the sample dataset simulation control', () => { + render(); + expect(screen.queryByText('Load Sample Dataset')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /run simulation/i })).not.toBeInTheDocument(); + }); + it.each([ [2, 0, 'CSV processed'], [1, 1, 'CSV partially accepted'], diff --git a/frontend/src/__tests__/pages/docs-contract.test.tsx b/frontend/src/__tests__/pages/docs-contract.test.tsx index 812b5dcdd..8500feffa 100644 --- a/frontend/src/__tests__/pages/docs-contract.test.tsx +++ b/frontend/src/__tests__/pages/docs-contract.test.tsx @@ -6,7 +6,6 @@ import AlphaRedirect from '@/app/alpha/page'; import DevelopersPage from '@/app/developers/page'; import DocsHomePage from '@/app/docs/page'; import AuthenticationDocsPage from '@/app/docs/authentication/page'; -import InflowLabConnectorPage from '@/app/docs/connectors/inflow-lab/page'; import ErrorCodesPage from '@/app/docs/errors/page'; import FSMA204GuidePage from '@/app/docs/fsma-204/page'; import QuickstartPage from '@/app/docs/quickstart/page'; @@ -15,7 +14,6 @@ import SDKsDocsPage, { metadata as sdksMetadata } from '@/app/docs/sdks/page'; import WalkthroughPage from '@/app/docs/walkthrough/page'; import WebhooksDocsPage, { metadata as webhooksMetadata } from '@/app/docs/webhooks/page'; import { ApiReferenceClient } from '@/app/docs/api/ApiReferenceClient'; -import ingestReceiving from '@/lib/api-samples/ingest-receiving.json'; import requiredKdes from '@/lib/api-samples/required-kdes.json'; import { INGEST_CURL_SAMPLE, @@ -63,14 +61,6 @@ describe('docs API key prefix is unified across surfaces (issue #2309)', () => { } }); - it('inflow-lab connector doc uses only rge_live_', () => { - render(); - const pageText = document.body.textContent ?? ''; - expect(pageText).toContain('rge_live_'); - for (const bad of FORBIDDEN_PREFIXES) { - expect(pageText).not.toContain(bad); - } - }); }); /** @@ -88,7 +78,6 @@ const DEVELOPER_PAGES: [string, () => ReactElement][] = [ ['developers', () => ], ['FSMA 204 guide', () => ], ['walkthrough', () => ], - ['Inflow Lab connector', () => ], ]; /** Pages that show a full ingest call. Each must render it from src/lib/api-samples, never carry its own payload. */ @@ -96,7 +85,6 @@ const INGEST_CALL_PAGES: [string, () => ReactElement][] = [ ['quickstart', () => ], ['FSMA 204 guide', () => ], ['walkthrough', () => ], - ['Inflow Lab connector', () => ], ]; /** The API reference hides endpoint detail until a card is opened. */ @@ -148,11 +136,12 @@ describe('developer docs stay true against the backend', () => { expect(pageText).toContain('"timestamp": "$NOW"'); // The unsigned samples sent their payload with an inline -d '{...}'. expect(pageText).not.toMatch(/-d '\{/); - // A created lot is permanent: every lot code in a sample carries the TEST- prefix. + // Docs carry no invented records: every lot code shown is a `<...>` field + // description to fill in, never a fabricated lot (a posted lot is permanent). const lots = Array.from(pageText.matchAll(/"traceability_lot_code":\s*"([^"]+)"/g)).map((m) => m[1]); - expect(lots.length).toBeGreaterThan(0); for (const lot of lots) { - expect(lot.startsWith('TEST-'), lot).toBe(true); + expect(lot.startsWith('<'), lot).toBe(true); + expect(lot.startsWith('TEST-'), lot).toBe(false); } }); @@ -243,27 +232,22 @@ describe('developer docs stay true against the backend', () => { }); describe('shared ingest sample', () => { - it('keeps placeholders in the JSON and a TEST- lot code (writes are permanent and billed)', () => { - const raw = JSON.stringify(ingestReceiving); - - expect(raw).not.toMatch(/\d{4}-\d{2}-\d{2}/); - expect(raw).toContain('{{EVENT_TIMESTAMP}}'); - expect(raw).toContain('{{EVENT_DATE}}'); - for (const event of ingestReceiving.events) { - expect(event.traceability_lot_code.startsWith('TEST-')).toBe(true); + it('carries no invented records: no TEST- lots, no fabricated suppliers, no fixed dates', () => { + for (const sample of [INGEST_CURL_SAMPLE, INGEST_PYTHON_SAMPLE, INGEST_JS_SAMPLE]) { + expect(sample).not.toContain('TEST-'); + expect(sample).not.toContain('{{'); + expect(sample).not.toMatch(/"timestamp":\s*"\d{4}-\d{2}-\d{2}/); + expect(sample).not.toMatch(/"\w+_date":\s*"\d{4}-\d{2}-\d{2}"/); + // Every value in the body skeleton is a `<...>` field description. + expect(sample).toContain('"<'); } }); - it('renders every language from the same payload with a clock-derived timestamp', () => { - const lot = ingestReceiving.events[0].traceability_lot_code; - + it('renders every language from the same skeleton with a clock-derived timestamp', () => { for (const sample of [INGEST_CURL_SAMPLE, INGEST_PYTHON_SAMPLE, INGEST_JS_SAMPLE]) { - expect(sample).toContain(lot); - expect(sample).not.toContain('{{'); expect(sample).toContain('X-Webhook-Signature'); expect(sample).toContain('Idempotency-Key'); expect(sample).not.toMatch(/X-Tenant-ID/); - expect(sample).not.toMatch(/"timestamp":\s*"\d{4}-\d{2}-\d{2}/); } expect(INGEST_CURL_SAMPLE).toContain('"timestamp": "$NOW"'); diff --git a/frontend/src/__tests__/pages/experimental-gating.test.tsx b/frontend/src/__tests__/pages/experimental-gating.test.tsx index 16ea91e3b..9d8bf72b6 100644 --- a/frontend/src/__tests__/pages/experimental-gating.test.tsx +++ b/frontend/src/__tests__/pages/experimental-gating.test.tsx @@ -8,11 +8,14 @@ import DashboardScanPage from '@/app/dashboard/scan/page'; import FSMAFieldCapturePage from '@/app/fsma/field-capture/page'; import { ToolsLandingClient } from '@/app/tools/components/ToolsLandingClient'; import { useAuth } from '@/lib/auth-context'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname, useRouter, notFound } from 'next/navigation'; vi.mock('next/navigation', () => ({ usePathname: vi.fn(), useRouter: vi.fn(), + notFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND'); + }), })); vi.mock('@/lib/auth-context', () => ({ @@ -27,10 +30,6 @@ vi.mock('@/components/dashboard/error-boundary', () => ({ DashboardErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}, })); -vi.mock('@/app/tools/inflow-lab/components/DashboardInflowLab', () => ({ - DashboardInflowLab: () =>
, -})); - vi.mock('@/components/mobile/FieldCaptureClient', () => ({ FieldCaptureClient: () =>
, })); @@ -65,12 +64,6 @@ describe('experimental feature gating (flag off)', () => { expect(screen.getByText(/Audit review is not enabled/i)).toBeInTheDocument(); }); - it('renders a feature-not-enabled state instead of the dashboard Inflow Lab', () => { - render(); - expect(screen.getByTestId('feature-not-enabled')).toBeInTheDocument(); - expect(screen.queryByTestId('inflow-lab-dashboard')).not.toBeInTheDocument(); - }); - it('renders a feature-not-enabled state instead of the field capture scanner', () => { render(); expect(screen.getByTestId('feature-not-enabled')).toBeInTheDocument(); @@ -128,15 +121,14 @@ describe('experimental feature gating (flag on)', () => {
content
, ); - expect(screen.getAllByText('Inflow Lab').length).toBeGreaterThan(0); + expect(screen.queryByText('Inflow Lab')).not.toBeInTheDocument(); expect(screen.getAllByText('FSMA 204 Dashboard').length).toBeGreaterThan(0); expect(screen.getAllByText('Field Capture').length).toBeGreaterThan(0); }); - it('renders the real dashboard Inflow Lab page', () => { - render(); - expect(screen.getByTestId('inflow-lab-dashboard')).toBeInTheDocument(); - expect(screen.queryByTestId('feature-not-enabled')).not.toBeInTheDocument(); + it('returns 404 for the retired dashboard Inflow Lab route even when the flag is on', () => { + expect(() => DashboardInflowLabPage()).toThrow('NEXT_NOT_FOUND'); + expect(notFound).toHaveBeenCalled(); }); it('renders the real field capture scanner', () => { diff --git a/frontend/src/__tests__/pages/inflow-lab.test.tsx b/frontend/src/__tests__/pages/inflow-lab.test.tsx deleted file mode 100644 index c04f28428..000000000 --- a/frontend/src/__tests__/pages/inflow-lab.test.tsx +++ /dev/null @@ -1,761 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -vi.mock('@/lib/auth-context', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useAuth: vi.fn().mockReturnValue({ - isAuthenticated: false, - isHydrated: true, - tenantId: null, - }), - }; -}); - -vi.mock('@/lib/tenant-context', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useTenant: vi.fn().mockReturnValue({ tenantId: null }), - }; -}); - -vi.mock('@/lib/fetch-with-csrf', () => ({ - fetchWithCsrf: vi.fn((input: RequestInfo | URL, init?: RequestInit) => - (globalThis as unknown as { fetch: typeof fetch }).fetch(input, init), - ), -})); - -import DashboardInflowLabPage from '@/app/dashboard/inflow-lab/page'; -import { AuthProvider } from '@/lib/auth-context'; -import { TenantProvider } from '@/lib/tenant-context'; -import { useAuth } from '@/lib/auth-context'; -import { useTenant } from '@/lib/tenant-context'; - -const completeLot = '00614141000012-20260426-000001'; -const partialLot = '00614141000012-20260426-000003'; - -function inflowRecord(sequence_no: number, lotCode: string, cteType: string) { - return { - sequence_no, - destination_mode: 'mock', - delivery_status: 'posted', - delivery_attempts: 1, - event: { - cte_type: cteType, - traceability_lot_code: lotCode, - product_description: lotCode === partialLot ? 'Green Leaf Lettuce' : 'Spring Mix', - location_name: `${cteType} station`, - timestamp: '2026-04-26T18:12:00.000Z', - }, - }; -} - -const completeLotRecords = [ - inflowRecord(5, completeLot, 'harvesting'), - inflowRecord(4, completeLot, 'cooling'), - inflowRecord(3, completeLot, 'initial_packing'), - inflowRecord(2, completeLot, 'shipping'), - inflowRecord(1, completeLot, 'receiving'), -]; - -const serviceRecords = [ - ...completeLotRecords, - inflowRecord(7, partialLot, 'harvesting'), - inflowRecord(6, partialLot, 'cooling'), -]; - -const sandboxEvaluation = { - total_events: 1, - compliant_events: 1, - non_compliant_events: 0, - total_kde_errors: 0, - total_rule_failures: 0, - submission_blocked: false, - blocking_reasons: [], - events: [ - { - event_index: 0, - cte_type: 'shipping', - traceability_lot_code: 'TLC-FEED-001', - product_description: 'Romaine Lettuce', - kde_errors: [], - rules_failed: 0, - rules_warned: 0, - compliant: true, - all_results: [], - }, - ], -}; - -const savedWorkbenchRun = { - run_id: 'run-123456789abc', - tenant_id: 'tenant-123', - source: 'inflow-lab-data-feeder', - csv: 'cte_type,traceability_lot_code\nshipping,TLC-FEED-001', - result: sandboxEvaluation, - readiness: { - score: 94, - label: 'export ready after authenticated commit', - components: [], - }, - fix_queue: [], - commit_gate: { - mode: 'staging', - allowed: true, - export_eligible: false, - reasons: ['Ready to request authenticated production commit.'], - next_state: 'production_evidence', - }, - saved_at: '2026-05-06T18:00:00.000Z', - input_hash: 'abc123', - result_hash: 'def456', - commit_decision_id: 'run-123456789abc:staging', -}; - -const savedWorkbenchSummary = { - tenant_id: 'tenant-123', - score: 94, - label: 'export ready after authenticated commit', - run_id: savedWorkbenchRun.run_id, - saved_at: savedWorkbenchRun.saved_at, - unresolved_fix_count: 0, - export_eligible: false, - source: savedWorkbenchRun.source, -}; - -function jsonResponse(payload: unknown, init?: ResponseInit) { - return Promise.resolve( - new Response(JSON.stringify(payload), { - status: 200, - headers: { 'content-type': 'application/json' }, - ...init, - }), - ); -} - -// Stands in for rows the user pastes: header plus one opaque row, no record values. -const USER_CSV = 'cte_type,traceability_lot_code\nreceiving,user-lot'; - -async function enterUserCsv(user: ReturnType) { - await user.click(await screen.findByRole('button', { name: 'Open data editor' })); - const textarea = await screen.findByRole('textbox', { name: 'Inbound CSV data' }); - fireEvent.change(textarea, { target: { value: USER_CSV } }); -} - -function renderDashboardInflowLabPage() { - return render( - - - - - , - ); -} - -describe('Dashboard Inflow Lab', () => { - const mockFetch = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - window.HTMLElement.prototype.scrollIntoView = vi.fn(); - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: false, - isHydrated: true, - tenantId: null, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ - tenantId: null, - setTenantId: vi.fn(), - isSystemTenant: false, - } as unknown as ReturnType); - const localStore: Record = {}; - const sessionStore: Record = {}; - vi.stubGlobal('localStorage', { - getItem: vi.fn((key: string) => localStore[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - localStore[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete localStore[key]; - }), - clear: vi.fn(() => { - Object.keys(localStore).forEach((key) => delete localStore[key]); - }), - length: 0, - key: vi.fn(() => null), - }); - vi.stubGlobal('sessionStorage', { - getItem: vi.fn((key: string) => sessionStore[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - sessionStore[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete sessionStore[key]; - }), - clear: vi.fn(() => { - Object.keys(sessionStore).forEach((key) => delete sessionStore[key]); - }), - length: 0, - key: vi.fn(() => null), - }); - mockFetch.mockImplementation((input: RequestInfo | URL) => { - const url = String(input); - if (url.includes('/api/ingestion/api/v1/inflow-workbench/readiness/summary')) { - return jsonResponse({ - tenant_id: 'tenant-123', - unresolved_fix_count: 0, - export_eligible: false, - source: 'none', - }); - } - if (url.includes(`/api/ingestion/api/v1/inflow-workbench/runs/${savedWorkbenchRun.run_id}`)) { - return jsonResponse(savedWorkbenchRun); - } - if (url.endsWith('/api/ingestion/api/v1/sandbox/evaluate')) { - return jsonResponse(sandboxEvaluation); - } - if (url.endsWith('/api/ingestion/api/v1/inflow-workbench/readiness/preview')) { - return jsonResponse({ - score: 94, - label: 'export ready after authenticated commit', - components: [], - }); - } - if (url.endsWith('/api/ingestion/api/v1/inflow-workbench/runs')) { - return jsonResponse(savedWorkbenchRun); - } - if (url.endsWith('/api/healthz')) { - return jsonResponse({ ok: true, build: { version: 'test-build' } }); - } - if (url.endsWith('/api/simulate/status')) { - return jsonResponse({ - running: false, - stats: { - total_records: serviceRecords.length, - unique_lots: 2, - delivery: { posted: serviceRecords.length, failed: 0, generated: 0, attempts: serviceRecords.length }, - }, - }); - } - if (url.includes('/api/events')) { - return jsonResponse({ events: serviceRecords }); - } - if (url.includes(`/api/lineage/${encodeURIComponent(completeLot)}`)) { - return jsonResponse({ records: completeLotRecords }); - } - if (url.includes(`/api/lineage/${encodeURIComponent(partialLot)}`)) { - return jsonResponse({ - records: serviceRecords.filter( - (record) => record.event.traceability_lot_code === partialLot, - ), - }); - } - return jsonResponse({}); - }); - vi.stubGlobal('fetch', mockFetch); - }); - - it('explains an empty release-disabled simulator 404', async () => { - mockFetch.mockResolvedValue(new Response(null, { status: 404 })); - renderDashboardInflowLabPage(); - expect(await screen.findByText(/Inflow Lab service connection:.*unavailable/)).toHaveTextContent('Core backend health does not include the simulator'); - }); - - it('renders the Inflow Lab header with one Sandbox chip and a single boundary banner', async () => { - renderDashboardInflowLabPage(); - - expect(screen.getByRole('heading', { name: 'Inflow Lab' })).toBeInTheDocument(); - - const sandboxChips = screen.getAllByText('Sandbox'); - expect(sandboxChips).toHaveLength(1); - - const banner = screen.getByRole('status'); - expect(banner).toHaveTextContent('Sandbox preview only.'); - expect(banner).toHaveTextContent( - /Production FDA-oriented exports stay sealed and review-ready\. Nothing here writes to them\./, - ); - - expect(screen.queryByText('Mock environment')).not.toBeInTheDocument(); - expect(screen.queryByText('Boundary active')).not.toBeInTheDocument(); - expect(screen.queryByText('Sandbox diagnosis')).not.toBeInTheDocument(); - }); - - it('shows the business activity as a traced chain without exposing internal scoring details', async () => { - renderDashboardInflowLabPage(); - - const chain = await screen.findByTestId('inflow-evidence-chain'); - // The handoff is shown as evidence rather than described in prose: every - // CTE from harvest to receive, ending in the consequence. - // Matched loosely because each label carries an sr-only state suffix, - // and the mock's location names ("cooling station") repeat the CTE word. - for (const cte of ['Harvesting', 'Cooling', 'Initial packing', 'Shipping', 'DC receiving']) { - expect(within(chain).getAllByText(cte, { exact: false }).length).toBeGreaterThan(0); - } - expect(within(chain).getByText('Export gate')).toBeInTheDocument(); - - // The scoring recipe stays internal — the chain reports what is missing - // and what to do about it, never how readiness is computed. - expect( - within(chain).queryByText(/weighting|coefficient|scoring model|formula/i), - ).not.toBeInTheDocument(); - }); - - it('reports one lead defect and one fix path', async () => { - renderDashboardInflowLabPage(); - - const chain = await screen.findByTestId('inflow-evidence-chain'); - expect(await within(chain).findByText(/records? never arrived\./)).toBeInTheDocument(); - expect(within(chain).getByText('Missing')).toBeInTheDocument(); - expect(within(chain).getByText('Pattern')).toBeInTheDocument(); - expect(within(chain).getByText('Lots affected')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Focus this defect' })).toBeInTheDocument(); - }); - - it('uses the proof-lead action to select the blocked lot and open its story in the rail', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await user.click(await screen.findByRole('button', { name: 'Focus this defect' })); - - expect(await screen.findByText(/This proof lead is blocked on/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Show matching queue group' })).toBeInTheDocument(); - expect(document.querySelector('[data-exception-group="handoff"]')).toHaveClass('bg-rose-100/70'); - expect(document.querySelector('[data-selected-lot-panel]')).toHaveAttribute('data-proof-lead-highlight', 'true'); - }); - - it('renders the 5-step funnel with a single Run sandbox test CTA in the active step', async () => { - renderDashboardInflowLabPage(); - - const funnel = await screen.findByTestId('inflow-funnel'); - expect(within(funnel).getByText('Loaded')).toBeInTheDocument(); - expect(within(funnel).getByText('Validated')).toBeInTheDocument(); - expect(within(funnel).getByText('Passing')).toBeInTheDocument(); - expect(within(funnel).getByText('Preview-ready')).toBeInTheDocument(); - expect(within(funnel).getByText('Production import')).toBeInTheDocument(); - - const sandboxButtons = screen.getAllByRole('button', { name: /Run sandbox test/i }); - expect(sandboxButtons).toHaveLength(1); - - const activeStep = funnel.querySelector('[data-funnel-step="passing"]'); - expect(activeStep).not.toBeNull(); - expect(activeStep!).toContainElement(sandboxButtons[0]); - }); - - it('renders the readiness panel with five stacked-bar factors', async () => { - renderDashboardInflowLabPage(); - - await waitFor(() => { - expect(screen.getByText('KDE completeness')).toBeInTheDocument(); - }); - expect(screen.getByText('CTE lifecycle')).toBeInTheDocument(); - expect(screen.getByText('Delivery quality')).toBeInTheDocument(); - expect(screen.getByText('Sandbox pass rate')).toBeInTheDocument(); - expect(screen.getByText('Connection health')).toBeInTheDocument(); - expect(screen.getByText('not run')).toBeInTheDocument(); - }); - - it('renders exceptions as failure-pattern groups, hiding empty groups on the all filter', async () => { - renderDashboardInflowLabPage(); - - // The fixture has one handoff-style partial lot; the handoff group must - // render. Anchor on the rendered group node directly instead of a - // parent
, because the card structure around the Exceptions - // heading is layout-driven and has changed over time. - const handoffNode = await waitFor(() => { - const node = document.querySelector('[data-exception-group="handoff"]'); - if (!node) throw new Error('handoff group not rendered'); - return node as HTMLElement; - }); - expect(within(handoffNode as HTMLElement).getByText('Missing handoff evidence')).toBeInTheDocument(); - expect(screen.queryByText('Missing source evidence')).not.toBeInTheDocument(); - expect(screen.queryByText('Mixed / multi-cause')).not.toBeInTheDocument(); - }); - - it('shows an empty-filter state when the active filter selects a group with 0 lots', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await waitFor(() => { - expect(screen.getByText('Exceptions')).toBeInTheDocument(); - }); - // Click the 'mixed' filter chip; the fixture has no mixed lots so the - // section must surface a clear empty-filter state with a Clear filter - // affordance. - await user.click(screen.getByRole('button', { name: 'mixed' })); - const empty = await waitFor(() => { - const node = document.querySelector('[data-empty-filter-state]'); - if (!node) throw new Error('empty-filter state not rendered'); - return node as HTMLElement; - }); - expect(empty).toHaveTextContent(/No lots match the/); - expect(within(empty).getByRole('button', { name: 'Clear filter' })).toBeInTheDocument(); - }); - - it('handoff group has a Fix all bulk CTA, mixed group never does', async () => { - renderDashboardInflowLabPage(); - const handoff = await waitFor(() => { - const node = document.querySelector('[data-exception-group="handoff"]'); - if (!node) throw new Error('handoff group not rendered'); - return node as HTMLElement; - }); - // "Fix all" is a navigation into the fix queue, so it renders as a - // link (role=link) rather than an inert button. - const fixAll = within(handoff).getByRole('link', { name: /^Fix all /i }); - expect(fixAll).toHaveAttribute('href', '/exceptions'); - - // The mixed group, even when populated, must never carry a Fix all - // bulk CTA per the audit (per-lot triage only). Filter to mixed and - // confirm the empty-filter card shows up rather than a Fix all. - // (When the fixture produces mixed lots in a future test, the - // assertion is the same: data-exception-group="mixed" never contains - // a "Fix all" button.) - const mixed = document.querySelector('[data-exception-group="mixed"]'); - if (mixed) { - expect(within(mixed as HTMLElement).queryByText(/^Fix all /)).not.toBeInTheDocument(); - } - }); - - it('keeps the selected lot detail in a sticky right rail with Lifecycle and KDE tiles', async () => { - renderDashboardInflowLabPage(); - - // ``Selected lot`` is the section heading that renders even when no - // lot is loaded yet. Wait for ``Lifecycle`` instead — it only appears - // once selectedLotData is non-null (i.e. a lot has been picked from - // the fetched events). Use findByText for built-in timeout. - expect(await screen.findByText('Lifecycle')).toBeInTheDocument(); - expect(screen.getByText('Selected lot')).toBeInTheDocument(); - expect(screen.getByText('Lot KDE summary')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Fix this lot' })).toBeInTheDocument(); - expect(screen.getByText(/Proof lead lot .* is dragging down/i)).toBeInTheDocument(); - }); - - it('triggers a sandbox evaluation when Run sandbox test is pressed in the funnel', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - expect(screen.getByText(/Paste CSV text or upload a CSV file before evaluating\./i)).toBeInTheDocument(); - }); - - it('offers no sample data loader, only Upload CSV for the user\'s own rows', () => { - renderDashboardInflowLabPage(); - - expect(screen.queryByRole('button', { name: /sample/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /example/i })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Upload CSV' })).toBeInTheDocument(); - }); - - it('does not evaluate anything when Run trace is pressed before the user adds rows', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await user.click(await screen.findByRole('button', { name: /Run trace/ })); - - expect(screen.getByText(/Paste CSV text or upload a CSV file before evaluating\./i)).toBeInTheDocument(); - const textarea = await screen.findByRole('textbox', { name: 'Inbound CSV data' }); - expect((textarea as HTMLTextAreaElement).value).toBe(''); - expect( - mockFetch.mock.calls.some(([url]) => String(url) === '/api/ingestion/api/v1/sandbox/evaluate'), - ).toBe(false); - }); - - it('runs the trace on the user\'s own rows without saving until requested', async () => { - const user = userEvent.setup(); - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: true, - isHydrated: true, - tenantId: 'tenant-123', - user: { - id: 'writer-1', - email: 'writer@example.com', - is_sysadmin: false, - status: 'active', - role_name: 'member', - permissions: ['inflow.write'], - }, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ tenantId: 'tenant-123' } as ReturnType); - - renderDashboardInflowLabPage(); - - await enterUserCsv(user); - await user.click(screen.getByRole('button', { name: /Run trace/ })); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/sandbox/evaluate', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ csv: USER_CSV }), - }), - ); - }); - expect(await screen.findByText(/Sandbox evaluator processed 1 event/i)).toBeInTheDocument(); - expect( - mockFetch.mock.calls.filter( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/runs', - ), - ).toHaveLength(0); - - await user.click(screen.getByRole('button', { name: 'Save test run' })); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/inflow-workbench/runs', - expect.objectContaining({ method: 'POST' }), - ); - }); - }); - - it('keeps public guided proof runs local without calling authenticated readiness preview', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - await enterUserCsv(user); - await user.click(screen.getByRole('button', { name: /Run trace/ })); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/sandbox/evaluate', - expect.objectContaining({ method: 'POST' }), - ); - }); - expect( - mockFetch.mock.calls.some( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/readiness/preview', - ), - ).toBe(false); - expect( - mockFetch.mock.calls.some( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/runs', - ), - ).toBe(false); - }); - - it('loads the latest saved workbench run when the dashboard opens with an authenticated tenant session', async () => { - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: true, - isHydrated: true, - tenantId: 'tenant-123', - user: { - id: 'reader-1', - email: 'reader@example.com', - is_sysadmin: false, - status: 'active', - role_name: 'member', - permissions: ['inflow.read'], - }, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ tenantId: 'tenant-123' } as ReturnType); - mockFetch.mockImplementation((input: RequestInfo | URL) => { - const url = String(input); - if (url.includes('/api/ingestion/api/v1/inflow-workbench/readiness/summary')) { - return jsonResponse(savedWorkbenchSummary); - } - if (url.includes(`/api/ingestion/api/v1/inflow-workbench/runs/${savedWorkbenchRun.run_id}`)) { - return jsonResponse(savedWorkbenchRun); - } - if (url.endsWith('/api/healthz')) { - return jsonResponse({ ok: true, build: { version: 'test-build' } }); - } - if (url.endsWith('/api/simulate/status')) { - return jsonResponse({ - running: false, - stats: { - total_records: serviceRecords.length, - unique_lots: 2, - delivery: { posted: serviceRecords.length, failed: 0, generated: 0, attempts: serviceRecords.length }, - }, - }); - } - if (url.includes('/api/events')) { - return jsonResponse({ events: serviceRecords }); - } - if (url.includes(`/api/lineage/${encodeURIComponent(completeLot)}`)) { - return jsonResponse({ records: completeLotRecords }); - } - if (url.includes(`/api/lineage/${encodeURIComponent(partialLot)}`)) { - return jsonResponse({ - records: serviceRecords.filter( - (record) => record.event.traceability_lot_code === partialLot, - ), - }); - } - return jsonResponse({}); - }); - - renderDashboardInflowLabPage(); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/inflow-workbench/readiness/summary?tenant_id=tenant-123', - expect.objectContaining({ method: 'GET' }), - ); - }); - expect( - await screen.findByText(/Latest saved workbench run/i), - ).toBeInTheDocument(); - expect(screen.getByText('run-123456789abc')).toBeInTheDocument(); - expect(screen.getByText(/0 open fixes remain in workbench\./i)).toBeInTheDocument(); - expect(screen.queryByText(/Last test run · never/i)).not.toBeInTheDocument(); - }); - - it('keeps sandbox evaluation non-persistent until Save test run is pressed', async () => { - const user = userEvent.setup(); - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: true, - isHydrated: true, - tenantId: 'tenant-123', - user: { - id: 'writer-1', - email: 'writer@example.com', - is_sysadmin: false, - status: 'active', - role_name: 'member', - permissions: ['inflow.write'], - }, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ tenantId: 'tenant-123' } as ReturnType); - - renderDashboardInflowLabPage(); - - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - const textarea = await screen.findByRole('textbox', { name: 'Inbound CSV data' }); - await user.type(textarea, 'cte_type,traceability_lot_code\nshipping,TLC-FEED-001'); - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - - await waitFor(() => { - expect(screen.getByText(/Sandbox evaluator processed 1 event/i)).toBeInTheDocument(); - }); - expect( - mockFetch.mock.calls.filter( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/runs', - ), - ).toHaveLength(0); - expect(screen.getByRole('button', { name: 'Save test run' })).toBeInTheDocument(); - expect( - screen.getByText(/Sandbox evaluation is stateless until you explicitly save a test run\./i), - ).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Save test run' })); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/ingestion/api/v1/inflow-workbench/runs', - expect.objectContaining({ method: 'POST' }), - ); - }); - expect(await screen.findByText(/Saved to Inflow Workbench as/i)).toBeInTheDocument(); - expect(screen.getByText('run-123456789abc')).toBeInTheDocument(); - expect(screen.getByText(/0 open fixes remain for this saved test run\./i)).toBeInTheDocument(); - expect(screen.queryByText(/Last test run · never/i)).not.toBeInTheDocument(); - }); - - it('disables saving when the session lacks inflow.write even if the role name looks privileged', async () => { - const user = userEvent.setup(); - vi.mocked(useAuth).mockReturnValue({ - isAuthenticated: true, - isHydrated: true, - tenantId: 'tenant-123', - user: { - id: 'owner-readonly-1', - email: 'readonly-owner@example.com', - is_sysadmin: false, - status: 'active', - role_name: 'owner', - permissions: ['inflow.read'], - }, - } as ReturnType); - vi.mocked(useTenant).mockReturnValue({ tenantId: 'tenant-123' } as ReturnType); - - renderDashboardInflowLabPage(); - - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - const textarea = await screen.findByRole('textbox', { name: 'Inbound CSV data' }); - await user.type(textarea, 'cte_type,traceability_lot_code\nshipping,TLC-FEED-001'); - await user.click(screen.getByRole('button', { name: /Run sandbox test/i })); - - const saveButton = await screen.findByRole('button', { name: 'Save unavailable' }); - expect(saveButton).toBeDisabled(); - - expect( - await screen.findByText( - /You can evaluate sandbox data here, but only users with write access can save an Inflow Workbench test run\./i, - ), - ).toBeInTheDocument(); - expect( - mockFetch.mock.calls.filter( - ([url]) => String(url) === '/api/ingestion/api/v1/inflow-workbench/runs', - ), - ).toHaveLength(0); - expect(screen.queryByText(/Saved to Inflow Workbench as/i)).not.toBeInTheDocument(); - }); - - it('opens fix queue navigation from the section-level button', async () => { - renderDashboardInflowLabPage(); - const link = await screen.findByRole('link', { name: 'Open fix queue' }); - // Canonical exceptions route is top-level /exceptions (mirrors the - // dashboard sidebar entry); /dashboard/exceptions does not exist. - expect(link).toHaveAttribute('href', '/exceptions'); - }); - - it('shows a clickable tutorial banner that opens the step-by-step walkthrough', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - const banner = await screen.findByTestId('inflow-tutorial-banner'); - expect(banner).toHaveTextContent('New to Inflow Lab? Learn to operate it step by step'); - - await user.click( - within(banner).getByRole('button', { name: /Learn to operate it step by step/i }), - ); - - expect(await screen.findByText('Step 1 of 7')).toBeInTheDocument(); - expect(screen.getByText('What Inflow Lab is')).toBeInTheDocument(); - }); - - it('walks forward through the tutorial steps', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - const banner = await screen.findByTestId('inflow-tutorial-banner'); - await user.click( - within(banner).getByRole('button', { name: /Learn to operate it step by step/i }), - ); - await screen.findByText('Step 1 of 7'); - - await user.click(screen.getByRole('button', { name: /Next/i })); - expect(await screen.findByText('Step 2 of 7')).toBeInTheDocument(); - expect(screen.getByText('Bring supplier data in')).toBeInTheDocument(); - }); - - it('dismisses the tutorial banner without disabling the How to use entry point', async () => { - const user = userEvent.setup(); - renderDashboardInflowLabPage(); - - const banner = await screen.findByTestId('inflow-tutorial-banner'); - await user.click(within(banner).getByRole('button', { name: 'Dismiss tutorial banner' })); - - await waitFor(() => { - expect(screen.queryByTestId('inflow-tutorial-banner')).not.toBeInTheDocument(); - }); - - // The walkthrough must stay reachable from the header after dismissal. - await user.click(screen.getByRole('button', { name: /How to use/i })); - expect(await screen.findByText('Step 1 of 7')).toBeInTheDocument(); - }); - - it('exposes filter chips for all/handoff/source/mixed', async () => { - renderDashboardInflowLabPage(); - await waitFor(() => { - expect(screen.getByText('Filter')).toBeInTheDocument(); - }); - expect(screen.getByRole('button', { name: 'all' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'handoff' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'source' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'mixed' })).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/__tests__/pages/ingest-epcis.test.tsx b/frontend/src/__tests__/pages/ingest-epcis.test.tsx index 04750adbb..ccdacf7aa 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -136,7 +136,7 @@ describe('EPCIS 2.0 import on /ingest', () => { it('offers an EPCIS 2.0 tab with a JSON/XML chooser and no sample file link', async () => { await openEpcisTab(); - expect(screen.getByText('CSV uploads skip bad rows. EPCIS files are all or nothing: if any event fails, nothing is saved.')).toBeInTheDocument(); + expect(screen.getByText('CSV uploads skip bad rows. EPCIS (Preview) files are all or nothing: if any event fails, nothing is saved.')).toBeInTheDocument(); expect(document.getElementById('epcis-file-upload')).toHaveAttribute( 'accept', '.json,application/json,.xml,application/xml,text/xml', @@ -284,7 +284,7 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(mocks.validateEpcis).not.toHaveBeenCalled(); expect(mocks.ingestFile).not.toHaveBeenCalled(); - expect(screen.queryByText('EPCIS processed')).not.toBeInTheDocument(); + expect(screen.queryByText('EPCIS (Preview) processed')).not.toBeInTheDocument(); }); it('shows backend failures with the same error pattern as the CSV tab', async () => { @@ -296,7 +296,7 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); expect(screen.getByText('Batch ingest failed')).toBeInTheDocument(); - expect(screen.queryByText('EPCIS processed')).not.toBeInTheDocument(); + expect(screen.queryByText('EPCIS (Preview) processed')).not.toBeInTheDocument(); expect(mocks.refresh).not.toHaveBeenCalled(); }); diff --git a/frontend/src/__tests__/pages/pre-revenue-copy.test.ts b/frontend/src/__tests__/pages/pre-revenue-copy.test.ts index 8d6a5b488..52a2ae3a7 100644 --- a/frontend/src/__tests__/pages/pre-revenue-copy.test.ts +++ b/frontend/src/__tests__/pages/pre-revenue-copy.test.ts @@ -48,14 +48,15 @@ describe('pre-revenue public copy', () => { } }); - it('keeps the published $425/mo price on pricing surfaces with the Founding rate wording', () => { + it('shows no hardcoded prices on pricing surfaces', () => { const client = read('src/components/pricing/PricingPageClient.tsx'); - expect(client).toContain('RegEngine's Founding rate starts at $425/mo (billed annually).'); - expect(client).toContain('The Founding rate starts at $425/mo, billed annually.'); + expect(client).not.toContain('$425'); expect(client).toContain('Founding rate\n'); const page = read('src/app/pricing/page.tsx'); - expect(page).toContain('FSMA 204 traceability readiness from $425/mo.'); + expect(page).not.toContain('$425'); + expect(page).not.toMatch(/gaMonthly: \d/); + expect(page).toContain('Pricing is temporarily unavailable'); expect(page).toContain('What does the Founding rate include?'); }); diff --git a/frontend/src/__tests__/pages/proof-recall-drill.test.tsx b/frontend/src/__tests__/pages/proof-recall-drill.test.tsx deleted file mode 100644 index 2b0f65497..000000000 --- a/frontend/src/__tests__/pages/proof-recall-drill.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import RecallDrillPage from '@/app/proof/recall-drill/page'; - -describe('public proof recall drill page', () => { - beforeEach(() => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - steps: [ - { - name: 'Identifying from API', - duration_ms: 100, - status: 'ok', - detail: 'Endpoint loaded the frozen public dataset.', - }, - ], - time_to_trace_seconds: 0.1, - output_csv_url: '/samples/sample_recall_drill.csv', - lot_code: 'SV-20240115-001', - fda_24h_mandate_seconds: 86400, - timestamp: '2026-05-19T00:00:00Z', - }), - }), - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('starts the public backend drill endpoint and renders returned steps', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /start drill/i })); - - expect(fetch).toHaveBeenCalledWith('/api/ingestion/api/v1/proof/recall-drill', { - method: 'POST', - }); - expect(await screen.findByText(/Identifying from API/)).toBeInTheDocument(); - expect( - screen.getByText(/Endpoint loaded the frozen public dataset/i), - ).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/__tests__/pages/recall-report.test.tsx b/frontend/src/__tests__/pages/recall-report.test.tsx index f7db17565..0afa3c454 100644 --- a/frontend/src/__tests__/pages/recall-report.test.tsx +++ b/frontend/src/__tests__/pages/recall-report.test.tsx @@ -2,9 +2,11 @@ * Recall Report Page Tests * * Tests for the recall investigation / readiness report: - * - Renders heading and demo scenario - * - Renders dimension scores - * - Falls back to demo data when API is unavailable + * - Renders heading + * - Shows the "No data yet" empty state for the backend PR #3536 no-data + * contract (status "no_data", score 0, grade "N/A", demo_mode false, + * demo_disclaimer null) + * - Never renders fabricated data or a demo disclaimer * - Auth guard (skips fetch without credentials) */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -22,30 +24,20 @@ function createWrapper() { ); } -// ── Mock demo report matching backend shape ── +// ── No-data report matching the backend PR #3536 contract ── -const MOCK_DEMO_REPORT = { +const MOCK_NO_DATA_REPORT = { tenant_id: 'tenant-123', generated_at: new Date().toISOString(), - overall_score: 72, - overall_grade: 'C', - overall_status: 'needs_improvement', - time_to_respond_estimate: '4.2 hours', - dimensions: [ - { id: 'trace_speed', name: 'Trace Speed', score: 85, grade: 'B', status: 'good', findings: ['Fast lookup'], recommendations: ['Add caching'] }, - { id: 'data_completeness', name: 'Data Completeness', score: 68, grade: 'C', status: 'needs_improvement', findings: ['Missing KDEs'], recommendations: ['Onboard suppliers'] }, - { id: 'chain_integrity', name: 'Chain Integrity', score: 90, grade: 'A', status: 'excellent', findings: ['Hash chain valid'], recommendations: [] }, - { id: 'supplier_coverage', name: 'Supplier Coverage', score: 55, grade: 'D', status: 'at_risk', findings: ['3 of 8 onboarded'], recommendations: ['Prioritize top suppliers'] }, - { id: 'export_readiness', name: 'Export Readiness', score: 78, grade: 'C', status: 'needs_improvement', findings: ['CSV ready'], recommendations: ['Add EPCIS'] }, - { id: 'team_readiness', name: 'Team Readiness', score: 60, grade: 'C', status: 'needs_improvement', findings: ['No drills run'], recommendations: ['Schedule monthly drill'] }, - ], - executive_summary: 'Your facility has basic recall capabilities but gaps remain.', - action_items: [ - { priority: 'HIGH', action: 'Onboard remaining suppliers', impact: 'Coverage +40%', effort: 'Medium' }, - { priority: 'MEDIUM', action: 'Run mock recall drill', impact: 'Team readiness', effort: 'Low' }, - ], - demo_mode: true, - demo_disclaimer: 'Sample Scenario — Explore a realistic recall investigation.', + overall_score: 0, + overall_grade: 'N/A', + overall_status: 'no_data', + time_to_respond_estimate: '', + dimensions: [], + executive_summary: '', + action_items: [], + demo_mode: false, + demo_disclaimer: null, }; // ── Mocks ── @@ -135,8 +127,8 @@ describe('RecallReportPage', () => { vi.clearAllMocks(); mockUseAuth.mockReturnValue({ apiKey: null, isAuthenticated: true }); mockUseTenant.mockReturnValue({ tenantId: 'tenant-123', isSystemTenant: false }); - // Default: API returns demo report with dimensions - mockFetchRecallReport.mockResolvedValue(MOCK_DEMO_REPORT); + // Default: API returns the no-data contract (status "no_data") + mockFetchRecallReport.mockResolvedValue(MOCK_NO_DATA_REPORT); }); afterEach(() => { @@ -157,37 +149,43 @@ describe('RecallReportPage', () => { }); }); - it('renders demo investigation scenario data', async () => { + it('shows the "No data yet" empty state for the no-data contract', async () => { render(, { wrapper: createWrapper() }); + // "No data yet" appears twice: the empty-state title and the grade + // placeholder (backend grade "N/A" renders as the placeholder, not a badge). await waitFor(() => { - expect(screen.getByText(/Romaine Lettuce/)).toBeInTheDocument(); + expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); + expect(screen.queryByText('N/A')).not.toBeInTheDocument(); }); - it('renders readiness dimension scores', async () => { + it('never renders a demo disclaimer', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { - expect(screen.getByText('Trace Speed')).toBeInTheDocument(); + expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); - expect(screen.getByText('Data Completeness')).toBeInTheDocument(); - expect(screen.getByText('Chain Integrity')).toBeInTheDocument(); - expect(screen.getByText('Supplier Coverage')).toBeInTheDocument(); + expect(screen.queryByText(/demo/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/sample scenario/i)).not.toBeInTheDocument(); }); - it('renders affected lots table', async () => { + it('renders no readiness dimensions or action items when the report has none', async () => { render(, { wrapper: createWrapper() }); await waitFor(() => { - expect(screen.getByText(/LOT-ROM-2026-0312A/)).toBeInTheDocument(); + expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); + expect(screen.queryByText('Trace Speed')).not.toBeInTheDocument(); + expect(screen.queryByText('Data Completeness')).not.toBeInTheDocument(); + expect(screen.queryByText('Chain Integrity')).not.toBeInTheDocument(); + expect(screen.queryByText('Supplier Coverage')).not.toBeInTheDocument(); + expect(screen.queryByText('Prioritized Actions')).not.toBeInTheDocument(); }); - it('falls back to demo when API returns empty data', async () => { - mockFetchRecallReport.mockResolvedValue({ ...MOCK_DEMO_REPORT, demo_mode: true }); + it('shows the empty state with a next-step link', async () => { render(, { wrapper: createWrapper() }); - // Should still show the demo scenario since demo_mode is true await waitFor(() => { - expect(screen.getByText(/Romaine Lettuce/)).toBeInTheDocument(); + expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); + expect(screen.getByRole('link', { name: /import records/i })).toHaveAttribute('href', '/tools/data-import'); }); it('shows an error without fabricated results when the API fails', async () => { @@ -196,8 +194,7 @@ describe('RecallReportPage', () => { await waitFor(() => { expect(screen.getByText('Recall report unavailable')).toBeInTheDocument(); }); - expect(screen.queryByText(/Romaine Lettuce/)).not.toBeInTheDocument(); - expect(screen.queryByText(/SLA Met/)).not.toBeInTheDocument(); + expect(screen.queryByText('No data yet')).not.toBeInTheDocument(); }); it('does not fetch when credentials are missing', () => { @@ -208,11 +205,6 @@ describe('RecallReportPage', () => { }); it('exports the FDA response through the cookie-backed ingestion proxy without browser auth headers', async () => { - // Override the default demo mock — the export button is disabled and - // relabelled "Connect Live Data To Enable FDA Export" while demo_mode is - // true, so we need a non-demo report to exercise the export path. - mockFetchRecallReport.mockResolvedValue({ ...MOCK_DEMO_REPORT, demo_mode: false }); - const fetchSpy = vi.fn().mockResolvedValue({ ok: true, blob: async () => new Blob(['csv']), diff --git a/frontend/src/app/buyer-programs/page.tsx b/frontend/src/app/buyer-programs/page.tsx index 145fe7dba..f1e3f2871 100644 --- a/frontend/src/app/buyer-programs/page.tsx +++ b/frontend/src/app/buyer-programs/page.tsx @@ -137,7 +137,7 @@ export default function BuyerProgramsPage() {
  • • Advisory coverage/completeness checklist
  • • Append-only commit
  • • FDA-oriented sortable spreadsheet
  • -
  • • CSV upload, JSON events API (EPCIS export)
  • +
  • • CSV upload, JSON events API, EPCIS export (Preview)
  • • Hash manifest re-checkable at /verify
  • diff --git a/frontend/src/app/compliance/page.tsx b/frontend/src/app/compliance/page.tsx index 7dc5b5266..38177035e 100644 --- a/frontend/src/app/compliance/page.tsx +++ b/frontend/src/app/compliance/page.tsx @@ -257,7 +257,7 @@ export default function CompliancePage() {
    FSMA 204 - EPCIS 2.0 export + EPCIS 2.0 export (Preview) GS1 FDA CTEs KDEs diff --git a/frontend/src/app/dashboard/compliance/page.tsx b/frontend/src/app/dashboard/compliance/page.tsx index be965d0f8..0ee189b69 100644 --- a/frontend/src/app/dashboard/compliance/page.tsx +++ b/frontend/src/app/dashboard/compliance/page.tsx @@ -33,7 +33,7 @@ import { useAuth } from '@/lib/auth-context'; import { useTenant } from '@/lib/tenant-context'; import { fetchComplianceScore, fetchWorkbenchReadinessSummary } from '@/lib/api-hooks'; import { ErrorState } from '@/components/ui/error-state'; -import { experimentalFeaturesEnabled, filterExperimentalLinks } from '@/lib/experimental-features'; +import { filterExperimentalLinks } from '@/lib/experimental-features'; import { NO_DATA_YET } from '@/lib/empty-values'; /* ── Types matching ComplianceScoreResponse from backend ── */ @@ -127,9 +127,9 @@ const STATUS_CONFIG = { const COMPLIANCE_SETUP_STEPS = [ { title: 'Load traceability records', - detail: 'Import a CSV, scan labels, or run Inflow Lab so the score has CTE and KDE evidence to evaluate.', - href: '/dashboard/inflow-lab', - action: 'Open Inflow Lab', + detail: 'Import a CSV or scan labels so the score has CTE and KDE evidence to evaluate.', + href: '/ingest', + action: 'Import records', icon: Upload, }, { @@ -141,7 +141,7 @@ const COMPLIANCE_SETUP_STEPS = [ }, { title: 'Preview export readiness', - detail: 'Once records exist, configure FDA and EPCIS export jobs for audit-response workflows.', + detail: 'Once records exist, configure FDA and EPCIS (Preview) export jobs for audit-response workflows.', href: '/dashboard/export-jobs', action: 'Set up exports', icon: Download, @@ -439,13 +439,6 @@ export default function ComplianceDashboardPage() { If this workspace is new, there may not be enough records yet. You can keep this page open, or jump directly to the setup paths below.

    - {experimentalFeaturesEnabled() && ( - - - - )}
    - {experimentalFeaturesEnabled() && ( - - - - )} @@ -596,7 +582,7 @@ export default function ComplianceDashboardPage() { @@ -616,7 +602,7 @@ export default function ComplianceDashboardPage() { diff --git a/frontend/src/app/dashboard/export-jobs/page.tsx b/frontend/src/app/dashboard/export-jobs/page.tsx index d4a54ad8a..8ede51ad7 100644 --- a/frontend/src/app/dashboard/export-jobs/page.tsx +++ b/frontend/src/app/dashboard/export-jobs/page.tsx @@ -69,9 +69,9 @@ export default function ExportJobsPage() { const exportPrereqs = filterExperimentalLinks([ { title: 'Connect source data', - detail: 'Use Inflow Lab or import tools to build the record set export jobs will package once scheduling ships.', - href: '/dashboard/inflow-lab', - action: 'Open Inflow Lab', + detail: 'Use the import tools to build the record set export jobs will package once scheduling ships.', + href: '/ingest', + action: 'Import records', icon: Database, }, { @@ -182,7 +182,7 @@ export default function ExportJobsPage() { className={`${fieldControlClasses} h-11`} > - + @@ -250,7 +250,7 @@ export default function ExportJobsPage() {
    {exportPrereqs.map((step) => ( @@ -279,7 +279,7 @@ export default function ExportJobsPage() {
    {[ ['Cadence', 'Weekly / Daily / Monthly'], - ['Bundle', 'FDA or EPCIS'], + ['Bundle', 'FDA or EPCIS (Preview)'], ['Integrity', 'Manifest hash'], ].map(([term, detail]) => (
    diff --git a/frontend/src/app/dashboard/inflow-lab/page.tsx b/frontend/src/app/dashboard/inflow-lab/page.tsx index 7d381be63..1e29a5146 100644 --- a/frontend/src/app/dashboard/inflow-lab/page.tsx +++ b/frontend/src/app/dashboard/inflow-lab/page.tsx @@ -1,23 +1,5 @@ -import { Metadata } from "next"; -import { DashboardInflowLab } from "@/app/tools/inflow-lab/components/DashboardInflowLab"; -import { FeatureNotEnabled } from "@/components/experimental/FeatureNotEnabled"; -import { isExperimentalPageEnabled } from "@/lib/experimental-features"; - -export const metadata: Metadata = { - title: "Inflow Lab | RegEngine Dashboard", - description: - "Test supplier traceability data before it flows into production exports: sandbox-only diagnostics, grouped exceptions, and a sticky lot lifecycle.", -}; +import { notFound } from "next/navigation"; export default function DashboardInflowLabPage() { - if (!isExperimentalPageEnabled("/dashboard/inflow-lab")) { - return ( - - ); - } - return ; + notFound(); } diff --git a/frontend/src/app/dashboard/integrations/page.tsx b/frontend/src/app/dashboard/integrations/page.tsx index 337081ddd..3a3449785 100644 --- a/frontend/src/app/dashboard/integrations/page.tsx +++ b/frontend/src/app/dashboard/integrations/page.tsx @@ -7,16 +7,12 @@ import { ArrowRight, Archive, Braces, - CheckCircle2, ClipboardCheck, FileSpreadsheet, - FlaskConical, - GitBranch, Play, RefreshCcw, Save, ShieldCheck, - Terminal, } from 'lucide-react'; import { CAPABILITY_REGISTRY, @@ -44,7 +40,7 @@ import { cn } from '@/lib/utils'; import { apiClient } from '@/lib/api-client'; import { useAuth } from '@/lib/auth-context'; import { useTenant } from '@/lib/tenant-context'; -import { experimentalFeaturesEnabled, filterExperimentalLinks } from '@/lib/experimental-features'; +import { filterExperimentalLinks } from '@/lib/experimental-features'; import type { CreateIntegrationProfileRequest, IntegrationProfile, @@ -72,19 +68,6 @@ const STATUS_TONES: Record = { contract_only: 'neutral', }; -const INFLOW_CONNECTOR = CAPABILITY_REGISTRY.find((item) => item.id === 'inflow-lab'); - -const INFLOW_FACTS = [ - ['Public slug', 'inflow-lab'], -]; - -const INFLOW_PATH = [ - ['Simulator', 'FSMA 204 event batch'], - ['JSON events API', '/api/v1/webhooks/ingest'], - ['Alias resolution', 'inflow-lab -> inflow_lab'], - ['Dashboard source', 'Tagged as Inflow Lab'], -]; - const ROUTE_GUIDANCE = [ { title: 'Import Data', @@ -98,12 +81,6 @@ const ROUTE_GUIDANCE = [ href: '/ingest/curation', icon: ClipboardCheck, }, - { - title: 'Inflow Lab', - description: 'Run FSMA event simulations and event replay without mixing test traffic into normal imports.', - href: '/dashboard/inflow-lab', - icon: FlaskConical, - }, { title: 'FDA Export', description: 'Generate export jobs after records are curated and ready for regulator response.', @@ -144,20 +121,6 @@ const DEFAULT_PROFILE_MAPPING = { reference_document: 'bol_number', }; -const DEFAULT_PREVIEW_EVENT = JSON.stringify([ - { - event_type: 'shipping', - lot_code: 'TLC-ROMAINE-0426', - item_description: 'Romaine hearts', - case_count: 24, - uom: 'cases', - ship_date: '2026-04-30T15:00:00Z', - origin_facility: 'FreshPack Central', - destination_facility: 'DC-17', - bol_number: 'BOL-88921', - }, -], null, 2); - export default function DashboardIntegrationsPage() { const { isAuthenticated } = useAuth(); const { tenantId } = useTenant(); @@ -167,15 +130,15 @@ export default function DashboardIntegrationsPage() { const [profiles, setProfiles] = useState([]); const [profilesStatus, setProfilesStatus] = useState<'idle' | 'loading' | 'error'>('loading'); const [profileError, setProfileError] = useState(null); - const [profileName, setProfileName] = useState('FreshPack shipping CSV'); - const [supplierName, setSupplierName] = useState('FreshPack Central'); + const [profileName, setProfileName] = useState(''); + const [supplierName, setSupplierName] = useState(''); const [sourceType, setSourceType] = useState('csv'); const [defaultCteType, setDefaultCteType] = useState('shipping'); - const [profileNotes, setProfileNotes] = useState('Supplier uses BOL number for FSMA reference document.'); + const [profileNotes, setProfileNotes] = useState(''); const [fieldMapping, setFieldMapping] = useState>({ ...DEFAULT_PROFILE_MAPPING }); const [savingProfile, setSavingProfile] = useState(false); const [previewProfileId, setPreviewProfileId] = useState(''); - const [previewEventText, setPreviewEventText] = useState(DEFAULT_PREVIEW_EVENT); + const [previewEventText, setPreviewEventText] = useState(''); const [previewResult, setPreviewResult] = useState(null); const [previewError, setPreviewError] = useState(null); const [previewing, setPreviewing] = useState(false); @@ -345,7 +308,7 @@ export default function DashboardIntegrationsPage() { window.location.reload()}>