From 11eb28005771d0977e1be3c05b68f416e2064822 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:05:10 -0700 Subject: [PATCH 1/9] feat(suppliers): agreed sending hours per facility and live late CTE metric Adds supplier_facilities.agreed_sending_hours (nullable, positive) with Alembic and Supabase migrations and downgrades, a tenant scoped PUT route and dashboard control, and computes the late CTE metric against the agreed sending time. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- ...1dba_v136_supplier_agreed_sending_hours.py | 43 +++++ docs/security/route-security-manifest.json | 17 +- .../components/supplier-data-quality.test.tsx | 109 +++++++++++- .../app/dashboard/suppliers/data-quality.tsx | 141 ++++++++++++++- frontend/src/app/dashboard/suppliers/page.tsx | 25 +-- frontend/src/lib/api-client.ts | 12 ++ frontend/src/types/api.ts | 17 +- services/admin/app/sqlalchemy_models.py | 6 + .../admin/app/supplier_compliance_routes.py | 65 ++++++- .../test_supplier_data_quality_routes.py | 165 ++++++++++++++++-- services/shared/supplier_data_quality.py | 76 +++++++- .../tests/test_supplier_data_quality.py | 100 ++++++++++- ...24070000_supplier_agreed_sending_hours.sql | 22 +++ .../security/test_route_security_manifest.py | 6 +- 14 files changed, 745 insertions(+), 59 deletions(-) create mode 100644 alembic/versions/20261024_b005ddbf1dba_v136_supplier_agreed_sending_hours.py create mode 100644 supabase/migrations/20261024070000_supplier_agreed_sending_hours.sql 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..9acd74f04 --- /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/20261024070000_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/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/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/app/dashboard/suppliers/data-quality.tsx b/frontend/src/app/dashboard/suppliers/data-quality.tsx index 79db05b5f..78a479db9 100644 --- a/frontend/src/app/dashboard/suppliers/data-quality.tsx +++ b/frontend/src/app/dashboard/suppliers/data-quality.tsx @@ -1,6 +1,7 @@ -import { useState, type ReactNode } from 'react'; +import { useId, useState, type FormEvent, type ReactNode } from 'react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { apiClient } from '@/lib/api-client'; import type { SupplierDataQuality, @@ -20,7 +21,15 @@ const METRIC_COPY: Record {state.items.map((item) => - metric === 'missing_kde' ? ( + metric === 'late_cte' ? ( +
  • + {item.record_id} + Lot {item.lot_code} + {item.cte_type && {item.cte_type}} + Event {formatTime(item.event_time)} + Received {formatTime(item.received_at)} +
  • + ) : metric === 'missing_kde' ? (
  • {item.record_id} Lot {item.lot_code} @@ -61,18 +78,113 @@ function DrilldownList({ metric, state }: { metric: SupplierQualityDrilldownMetr ); } +function SendingHoursEditor({ + facilityId, + hours, + onSaved, +}: { + facilityId: string; + hours: number | null; + onSaved?: () => void; +}) { + const inputId = useId(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const start = () => { + setDraft(hours === null ? '' : String(hours)); + setError(null); + setEditing(true); + }; + + const save = async (value: number | null) => { + setSaving(true); + setError(null); + try { + await apiClient.setFacilityAgreedSendingHours(facilityId, value); + setEditing(false); + onSaved?.(); + } catch { + setError('The agreed sending time could not be saved'); + } finally { + setSaving(false); + } + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + const trimmed = draft.trim(); + const value = Number(trimmed); + if (!/^\d+$/.test(trimmed) || !Number.isSafeInteger(value) || value <= 0) { + setError('Enter a whole number of hours above zero'); + return; + } + void save(value); + }; + + if (!editing) { + return ( +
    + + Agreed sending time: {hours === null ? 'Not set' : `${formatHours(hours)} after the event`} + + +
    + ); + } + return ( +
    + + setDraft(event.target.value)} + className="w-24" + /> + + {hours !== null && ( + + )} + + {error && ( +

    + {error} +

    + )} +
    + ); +} + function MetricRow({ facilityId, metric, windowDays, minRecords, lateCteLabel, + agreedSendingHours, + onSendingHoursSaved, }: { facilityId: string; metric: SupplierQualityMetric; windowDays: number; minRecords: number; lateCteLabel: string; + agreedSendingHours: number | null; + onSendingHoursSaved?: () => void; }) { const copy = METRIC_COPY[metric.key]; const [open, setOpen] = useState(false); @@ -107,6 +219,8 @@ function MetricRow({ let value: ReactNode; if (metric.status === 'unavailable') { value = Unavailable. {metric.reason}; + } else if (metric.numerator === null || metric.denominator === null) { + value = Not enough data. {metric.reason}; } else { const counts = `${metric.numerator} of ${metric.denominator} ${copy.unit}`; value = @@ -134,7 +248,12 @@ function MetricRow({ )} - {metric.key === 'late_cte' &&

    {lateCteLabel}

    } + {metric.key === 'late_cte' && ( + <> +

    {lateCteLabel}

    + + + )} {open && isDrilldown(metric.key) && (
    @@ -162,10 +281,20 @@ interface DataQualityProps { windowDays: number; minRecords: number; lateCteLabel: string; + /** Called after the agreed sending time is saved, to reload the measures. */ + onSendingHoursSaved?: () => void; } /** Four separate data quality measures for one facility. No combined score. */ -export function DataQuality({ facilityId, summary, unavailable, windowDays, minRecords, lateCteLabel }: DataQualityProps) { +export function DataQuality({ + facilityId, + summary, + unavailable, + windowDays, + minRecords, + lateCteLabel, + onSendingHoursSaved, +}: DataQualityProps) { if (unavailable) { return

    Data quality unavailable

    ; } @@ -184,6 +313,8 @@ export function DataQuality({ facilityId, summary, unavailable, windowDays, minR windowDays={windowDays} minRecords={minRecords} lateCteLabel={lateCteLabel} + agreedSendingHours={summary.agreed_sending_hours ?? null} + onSendingHoursSaved={onSendingHoursSaved} /> ))} diff --git a/frontend/src/app/dashboard/suppliers/page.tsx b/frontend/src/app/dashboard/suppliers/page.tsx index c2b394bee..aace5472a 100644 --- a/frontend/src/app/dashboard/suppliers/page.tsx +++ b/frontend/src/app/dashboard/suppliers/page.tsx @@ -210,6 +210,18 @@ export default function SupplierDashboardPage() { const [newPostalCode, setNewPostalCode] = useState(''); const [adding, setAdding] = useState(false); + const loadDataQuality = useCallback(() => { + apiClient.getSupplierDataQuality() + .then((res) => { + setDataQuality(res); + setDataQualityUnavailable(false); + }) + .catch(() => { + setDataQuality(null); + setDataQualityUnavailable(true); + }); + }, []); + /* ---------- Fetch all data ------------------------------------ */ // Phase 1: Load facility list fast (1 API call) // Phase 2: Lazy-enrich with compliance data in background (batched, non-blocking) @@ -244,15 +256,7 @@ export default function SupplierDashboardPage() { setGapTimeToFixUnavailable(true); }); - apiClient.getSupplierDataQuality() - .then((res) => { - setDataQuality(res); - setDataQualityUnavailable(false); - }) - .catch(() => { - setDataQuality(null); - setDataQualityUnavailable(true); - }); + loadDataQuality(); if (tenantId) { fetchWorkbenchReadinessSummary(tenantId) @@ -311,7 +315,7 @@ export default function SupplierDashboardPage() { setError(friendlyError(err, 'Unable to load supplier data. Please try again.')); setLoading(false); } - }, [isLoggedIn, tenantId]); + }, [isLoggedIn, tenantId, loadDataQuality]); useEffect(() => { loadData(); }, [loadData]); @@ -906,6 +910,7 @@ export default function SupplierDashboardPage() { windowDays={dataQuality?.window_days ?? 30} minRecords={dataQuality?.min_records ?? 20} lateCteLabel={dataQuality?.late_cte_label ?? ''} + onSendingHoursSaved={loadDataQuality} /> )} {comp.gaps.length > 0 && ( diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index b7a858815..e0b20fbf9 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -30,6 +30,7 @@ import type { SupplierComplianceScore, SupplierGapTimeToFixResponse, SupplierDataQualityResponse, + AgreedSendingHoursResponse, SupplierQualityDrilldownMetric, SupplierQualityRecordsResponse, SupplierBulkUploadCommitResponse, @@ -887,6 +888,17 @@ class APIClient { return data; } + async setFacilityAgreedSendingHours( + facilityId: string, + agreedSendingHours: number | null, + ): Promise { + const { data } = await this.adminClient.put( + `/v1/supplier/facilities/${encodeURIComponent(facilityId)}/agreed-sending-hours`, + { agreed_sending_hours: agreedSendingHours }, + ); + return data; + } + async getSupplierDataQualityRecords( facilityId: string, metric: SupplierQualityDrilldownMetric, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 5339336bb..80f723da9 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -511,25 +511,36 @@ export interface SupplierGapTimeToFixResponse { } export type SupplierQualityMetricKey = 'missing_kde' | 'unlinked_lot' | 'late_cte' | 'epcis_failed'; -export type SupplierQualityDrilldownMetric = 'missing_kde' | 'unlinked_lot'; +export type SupplierQualityDrilldownMetric = 'missing_kde' | 'unlinked_lot' | 'late_cte'; export interface SupplierQualityMetric { key: SupplierQualityMetricKey; - /** not_enough_data: denominator is below min_records, so share is null. */ + /** + * not_enough_data: denominator is below min_records, so share is null. For + * late_cte with no agreed sending time, the counts are null too and reason + * says why. + */ status: 'ok' | 'not_enough_data' | 'unavailable'; numerator: number | null; denominator: number | null; share: number | null; - /** Set when status is unavailable. */ + /** Set when status is unavailable, or late_cte has no agreed sending time. */ reason: string | null; } export interface SupplierDataQuality { /** Supplier facility id. */ supplier_key: string; + /** Hours after the event time the supplier agreed to send a record; null when not set. */ + agreed_sending_hours: number | null; metrics: SupplierQualityMetric[]; } +export interface AgreedSendingHoursResponse { + facility_id: string; + agreed_sending_hours: number | null; +} + export interface SupplierDataQualityResponse { suppliers: SupplierDataQuality[]; window_days: number; diff --git a/services/admin/app/sqlalchemy_models.py b/services/admin/app/sqlalchemy_models.py index 5f470f352..b0fbb9bd8 100644 --- a/services/admin/app/sqlalchemy_models.py +++ b/services/admin/app/sqlalchemy_models.py @@ -422,12 +422,18 @@ class SupplierFacilityModel(Base): postal_code = Column(String, nullable=False) fda_registration_number = Column(String, nullable=True) roles = Column(JSONType(), nullable=False, default=list) + # Hours after a CTE's event time the supplier agreed to send it. NULL: none. + agreed_sending_hours = Column(Integer, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) __table_args__ = ( Index("ix_supplier_facilities_tenant", "tenant_id"), Index("ix_supplier_facilities_user", "supplier_user_id"), + CheckConstraint( + "agreed_sending_hours > 0", + name="supplier_facilities_agreed_sending_hours_check", + ), ) diff --git a/services/admin/app/supplier_compliance_routes.py b/services/admin/app/supplier_compliance_routes.py index 1559b9a34..41341f2ec 100644 --- a/services/admin/app/supplier_compliance_routes.py +++ b/services/admin/app/supplier_compliance_routes.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from shared.data_gap_tracking import INTERNAL_TARGET, fetch_supplier_summaries from shared.supplier_data_quality import ( DEFAULT_WINDOW_DAYS, @@ -26,8 +26,10 @@ MIN_RECORDS, fetch_lot_rows, fetch_record_rows, + fetch_sending_hours, fetch_supplier_quality, flag_records, + late_records, unlinked_lots, window_start as quality_window_start, ) @@ -54,6 +56,8 @@ router = APIRouter(prefix="/supplier", tags=["supplier-onboarding"]) QUALITY_METRIC_ORDER = (METRIC_MISSING_KDE, METRIC_UNLINKED_LOT, METRIC_LATE_CTE, METRIC_EPCIS_FAILED) +# supplier_facilities.agreed_sending_hours is a Postgres integer. +MAX_AGREED_SENDING_HOURS = 2**31 - 1 @router.get("/compliance-score", response_model=SupplierComplianceScoreResponse) @@ -197,9 +201,20 @@ class SupplierQualityMetric(BaseModel): class SupplierDataQuality(BaseModel): supplier_key: str + agreed_sending_hours: int | None = None metrics: list[SupplierQualityMetric] +class AgreedSendingHoursRequest(BaseModel): + # Required key; null clears it. Strict: no strings, floats or booleans. + agreed_sending_hours: int | None = Field(..., strict=True, gt=0, le=MAX_AGREED_SENDING_HOURS) + + +class AgreedSendingHoursResponse(BaseModel): + facility_id: str + agreed_sending_hours: int | None + + class SupplierDataQualityResponse(BaseModel): suppliers: list[SupplierDataQuality] window_days: int @@ -260,13 +275,15 @@ async def get_supplier_data_quality( facility_ids = _owned_facility_ids(db, tenant_id, current_user.id) now = datetime.now(timezone.utc) + hours = fetch_sending_hours(db, str(tenant_id), str(current_user.id), facility_ids) quality = fetch_supplier_quality( - db, str(tenant_id), str(current_user.id), facility_ids, quality_window_start(now, window_days) + db, str(tenant_id), str(current_user.id), facility_ids, quality_window_start(now, window_days), hours ) return SupplierDataQualityResponse( suppliers=[ SupplierDataQuality( supplier_key=key, + agreed_sending_hours=hours.get(key), metrics=[SupplierQualityMetric(**metrics[name]) for name in QUALITY_METRIC_ORDER], ) for key, metrics in quality.items() @@ -281,7 +298,7 @@ async def get_supplier_data_quality( @router.get("/data-quality/{facility_id}/records", response_model=SupplierQualityRecordsResponse) async def get_supplier_data_quality_records( facility_id: str, - metric: Literal["missing_kde", "unlinked_lot"], + metric: Literal["missing_kde", "unlinked_lot", "late_cte"], window_days: int = Query(default=DEFAULT_WINDOW_DAYS, ge=1, le=MAX_WINDOW_DAYS), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), @@ -296,17 +313,22 @@ async def get_supplier_data_quality_records( scoped_facility_id = uuid_module.UUID(facility_id) except ValueError as exc: raise HTTPException(status_code=400, detail="Invalid facility id") from exc - _get_supplier_facility_or_404( + facility = _get_supplier_facility_or_404( db, tenant_id=tenant_id, supplier_user_id=current_user.id, facility_id=scoped_facility_id, ) + if metric == METRIC_LATE_CTE and facility.agreed_sending_hours is None: + raise HTTPException(status_code=409, detail="No agreed sending time is set for this facility") key = str(scoped_facility_id) since = quality_window_start(datetime.now(timezone.utc), window_days) - if metric == METRIC_MISSING_KDE: - flagged = flag_records(fetch_record_rows(db, str(tenant_id), str(current_user.id), [key], since)) + if metric in (METRIC_MISSING_KDE, METRIC_LATE_CTE): + rows = fetch_record_rows(db, str(tenant_id), str(current_user.id), [key], since) + flagged = ( + flag_records(rows) if metric == METRIC_MISSING_KDE else late_records(rows, facility.agreed_sending_hours) + ) items = [ SupplierQualityRecordItem( record_id=str(row["record_id"]), @@ -314,7 +336,7 @@ async def get_supplier_data_quality_records( cte_type=row["cte_type"], event_time=_iso_or_none(row["event_time"]), received_at=_iso_or_none(row["created_at"]), - missing_kdes=row["missing_kdes"], + missing_kdes=row.get("missing_kdes", []), ) for row in flagged ] @@ -334,6 +356,35 @@ async def get_supplier_data_quality_records( ) +@router.put("/facilities/{facility_id}/agreed-sending-hours", response_model=AgreedSendingHoursResponse) +async def set_agreed_sending_hours( + facility_id: str, + request: AgreedSendingHoursRequest, + current_user: UserModel = Depends(get_current_user), + db: Session = Depends(get_session), +) -> AgreedSendingHoursResponse: + """Set or clear (null) the agreed sending time for one owned facility.""" + tenant_id = TenantContext.get_tenant_context(db) + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant context required") + try: + scoped_facility_id = uuid_module.UUID(facility_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid facility id") from exc + facility = _get_supplier_facility_or_404( + db, + tenant_id=tenant_id, + supplier_user_id=current_user.id, + facility_id=scoped_facility_id, + ) + + facility.agreed_sending_hours = request.agreed_sending_hours + db.commit() + return AgreedSendingHoursResponse( + facility_id=str(scoped_facility_id), agreed_sending_hours=request.agreed_sending_hours + ) + + @router.get("/export/fda-records/preview", response_model=SupplierFDAExportPreviewResponse) async def preview_fda_records_export( facility_id: str | None = None, diff --git a/services/admin/tests/test_supplier_data_quality_routes.py b/services/admin/tests/test_supplier_data_quality_routes.py index 7eca1bd5b..29796fe8e 100644 --- a/services/admin/tests/test_supplier_data_quality_routes.py +++ b/services/admin/tests/test_supplier_data_quality_routes.py @@ -6,13 +6,20 @@ from __future__ import annotations +from datetime import datetime, timedelta, timezone from types import SimpleNamespace -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from shared.supplier_data_quality import DEFAULT_WINDOW_DAYS, LATE_CTE_LABEL, MAX_WINDOW_DAYS, MIN_RECORDS +from shared.supplier_data_quality import ( + DEFAULT_WINDOW_DAYS, + LATE_CTE_LABEL, + LATE_CTE_NO_HOURS_REASON, + MAX_WINDOW_DAYS, + MIN_RECORDS, +) TENANT = uuid4() OTHER_TENANT = uuid4() @@ -22,16 +29,21 @@ FOREIGN_FACILITY = uuid4() OTHER_TENANT_FACILITY = uuid4() -# Shipping records with no KDEs on the owned facility, and one lot with no -# upstream link. Ids and lot codes are generated per run. +# Any positive whole number of hours; derived rather than picked. +AGREED_HOURS = MIN_RECORDS +NOW = datetime.now(timezone.utc) + +# Shipping records with no KDEs on the owned facility, each received just past +# AGREED_HOURS after its event time, and one lot with no upstream link. Ids +# and lot codes are generated per run. RECORD_ROWS = [ { "record_id": str(uuid4()), "facility_id": str(OWN_FACILITY), "cte_type": "shipping", "kde_data": {}, - "event_time": None, - "created_at": None, + "event_time": NOW - timedelta(hours=AGREED_HOURS) - timedelta.resolution, + "created_at": NOW, "lot_code": str(uuid4()), } for _ in range(MIN_RECORDS // 10) @@ -49,15 +61,23 @@ class FakeSession: - def __init__(self) -> None: + def __init__(self, own_hours: int | None = None) -> None: + self.commits = 0 self.facilities = { - OWN_FACILITY: SimpleNamespace(id=OWN_FACILITY, tenant_id=TENANT, supplier_user_id=USER), - FOREIGN_FACILITY: SimpleNamespace(id=FOREIGN_FACILITY, tenant_id=TENANT, supplier_user_id=OTHER_USER), + OWN_FACILITY: SimpleNamespace( + id=OWN_FACILITY, tenant_id=TENANT, supplier_user_id=USER, agreed_sending_hours=own_hours + ), + FOREIGN_FACILITY: SimpleNamespace( + id=FOREIGN_FACILITY, tenant_id=TENANT, supplier_user_id=OTHER_USER, agreed_sending_hours=None + ), OTHER_TENANT_FACILITY: SimpleNamespace( - id=OTHER_TENANT_FACILITY, tenant_id=OTHER_TENANT, supplier_user_id=USER + id=OTHER_TENANT_FACILITY, tenant_id=OTHER_TENANT, supplier_user_id=USER, agreed_sending_hours=None ), } + def commit(self) -> None: + self.commits += 1 + def get(self, _model, key): return self.facilities.get(key) @@ -72,9 +92,11 @@ def calls(): return [] -def _client(monkeypatch, calls, tenant=TENANT): +def _client(monkeypatch, calls, tenant=TENANT, session=None): import app.supplier_compliance_routes as routes + session = session if session is not None else FakeSession() + def fake_records(_db, tenant_id, user_id, facility_ids, since): calls.append(("records", tenant_id, user_id, list(facility_ids))) return [dict(row) for row in RECORD_ROWS] @@ -83,6 +105,12 @@ def fake_lots(_db, tenant_id, user_id, facility_ids, since): calls.append(("lots", tenant_id, user_id, list(facility_ids))) return [dict(row) for row in LOT_ROWS] + def fake_hours(db, tenant_id, user_id, facility_ids): + calls.append(("hours", tenant_id, user_id, list(facility_ids))) + return {key: db.facilities[UUID(key)].agreed_sending_hours for key in facility_ids} + + monkeypatch.setattr(routes, "fetch_sending_hours", fake_hours) + monkeypatch.setattr("shared.supplier_data_quality.fetch_sending_hours", fake_hours) monkeypatch.setattr(routes, "fetch_record_rows", fake_records) monkeypatch.setattr(routes, "fetch_lot_rows", fake_lots) monkeypatch.setattr("shared.supplier_data_quality.fetch_record_rows", fake_records) @@ -91,7 +119,6 @@ def fake_lots(_db, tenant_id, user_id, facility_ids, since): app = FastAPI() app.include_router(routes.router, prefix="/v1") - session = FakeSession() app.dependency_overrides[routes.get_session] = lambda: session app.dependency_overrides[routes.get_current_user] = lambda: SimpleNamespace(id=USER) return TestClient(app) @@ -113,7 +140,9 @@ def test_summary_returns_four_separate_metrics_for_owned_facilities(monkeypatch, len(RECORD_ROWS), len(RECORD_ROWS), ) - assert supplier["metrics"][2]["status"] == "unavailable" + assert supplier["agreed_sending_hours"] is None + late = supplier["metrics"][2] + assert (late["status"], late["numerator"], late["reason"]) == ("not_enough_data", None, LATE_CTE_NO_HOURS_REASON) assert supplier["metrics"][3]["status"] == "unavailable" assert all(call[1:3] == (str(TENANT), str(USER)) for call in calls) assert all(call[3] == [str(OWN_FACILITY)] for call in calls) @@ -162,8 +191,49 @@ def test_drilldown_refuses_facility_not_owned_in_tenant(monkeypatch, calls, faci assert calls == [] +def test_summary_counts_late_records_against_agreed_hours(monkeypatch, calls): + resp = _client(monkeypatch, calls, session=FakeSession(own_hours=AGREED_HOURS)).get("/v1/supplier/data-quality") + [supplier] = resp.json()["suppliers"] + assert supplier["agreed_sending_hours"] == AGREED_HOURS + late = supplier["metrics"][2] + # Fewer than MIN_RECORDS timed records: counts shown, no share. + assert (late["status"], late["numerator"], late["denominator"], late["share"]) == ( + "not_enough_data", + len(RECORD_ROWS), + len(RECORD_ROWS), + None, + ) + assert [call for call in calls if call[0] == "hours"] == [("hours", str(TENANT), str(USER), [str(OWN_FACILITY)])] + + +def test_drilldown_lists_late_records(monkeypatch, calls): + session = FakeSession(own_hours=AGREED_HOURS) + resp = _client(monkeypatch, calls, session=session).get( + f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=late_cte" + ) + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == len(RECORD_ROWS) + assert [item["record_id"] for item in body["items"]] == [row["record_id"] for row in RECORD_ROWS] + assert all(item["received_at"] and item["event_time"] for item in body["items"]) + assert calls == [("records", str(TENANT), str(USER), [str(OWN_FACILITY)])] + + +def test_drilldown_late_cte_without_agreed_hours_is_refused(monkeypatch, calls): + resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=late_cte") + assert resp.status_code == 409 + assert calls == [] + + +@pytest.mark.parametrize("facility", [FOREIGN_FACILITY, OTHER_TENANT_FACILITY]) +def test_drilldown_late_cte_refuses_facility_not_owned(monkeypatch, calls, facility): + resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality/{facility}/records?metric=late_cte") + assert resp.status_code == 404 + assert calls == [] + + def test_drilldown_rejects_unavailable_metrics(monkeypatch, calls): - for metric in ("late_cte", "epcis_failed", "anything"): + for metric in ("epcis_failed", "anything"): resp = _client(monkeypatch, calls).get(f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric={metric}") assert resp.status_code == 422 assert calls == [] @@ -176,3 +246,70 @@ def test_drilldown_rejects_bad_facility_id_and_limit(monkeypatch, calls): client.get(f"/v1/supplier/data-quality/{OWN_FACILITY}/records?metric=missing_kde&limit=201").status_code == 422 ) assert calls == [] + + +# --- agreed sending hours ------------------------------------------------------ + + +def _hours_url(facility) -> str: + return f"/v1/supplier/facilities/{facility}/agreed-sending-hours" + + +def test_owner_sets_agreed_sending_hours(monkeypatch, calls): + session = FakeSession() + resp = _client(monkeypatch, calls, session=session).put( + _hours_url(OWN_FACILITY), json={"agreed_sending_hours": AGREED_HOURS} + ) + assert resp.status_code == 200 + assert resp.json() == {"facility_id": str(OWN_FACILITY), "agreed_sending_hours": AGREED_HOURS} + assert session.facilities[OWN_FACILITY].agreed_sending_hours == AGREED_HOURS + assert session.commits == len([resp]) + + +def test_owner_clears_agreed_sending_hours_with_null(monkeypatch, calls): + session = FakeSession(own_hours=AGREED_HOURS) + resp = _client(monkeypatch, calls, session=session).put( + _hours_url(OWN_FACILITY), json={"agreed_sending_hours": None} + ) + assert resp.status_code == 200 + assert resp.json()["agreed_sending_hours"] is None + assert session.facilities[OWN_FACILITY].agreed_sending_hours is None + + +@pytest.mark.parametrize("facility", [FOREIGN_FACILITY, OTHER_TENANT_FACILITY]) +def test_agreed_sending_hours_refuses_facility_not_owned_in_tenant(monkeypatch, calls, facility): + session = FakeSession() + resp = _client(monkeypatch, calls, session=session).put( + _hours_url(facility), json={"agreed_sending_hours": AGREED_HOURS} + ) + assert resp.status_code == 404 + assert session.facilities[facility].agreed_sending_hours is None + assert session.commits == 0 + + +@pytest.mark.parametrize( + "body", + [ + {"agreed_sending_hours": 0}, + {"agreed_sending_hours": -AGREED_HOURS}, + {"agreed_sending_hours": str(AGREED_HOURS)}, + {"agreed_sending_hours": AGREED_HOURS + 0.5}, + {"agreed_sending_hours": float(AGREED_HOURS)}, + {"agreed_sending_hours": True}, + # One past the Postgres integer maximum. + {"agreed_sending_hours": 2**31}, + {}, + ], +) +def test_agreed_sending_hours_must_be_positive_int_or_null(monkeypatch, calls, body): + session = FakeSession() + resp = _client(monkeypatch, calls, session=session).put(_hours_url(OWN_FACILITY), json=body) + assert resp.status_code == 422 + assert session.facilities[OWN_FACILITY].agreed_sending_hours is None + assert session.commits == 0 + + +def test_agreed_sending_hours_rejects_bad_id_and_missing_tenant(monkeypatch, calls): + body = {"agreed_sending_hours": AGREED_HOURS} + assert _client(monkeypatch, calls).put(_hours_url("not-a-uuid"), json=body).status_code == 400 + assert _client(monkeypatch, calls, tenant=None).put(_hours_url(OWN_FACILITY), json=body).status_code == 400 diff --git a/services/shared/supplier_data_quality.py b/services/shared/supplier_data_quality.py index 50e05930c..db91316f8 100644 --- a/services/shared/supplier_data_quality.py +++ b/services/shared/supplier_data_quality.py @@ -16,7 +16,11 @@ linkage KDE (the same fields the rules engine checks in ``shared.rules.seeds``), or when it starts at this facility (harvesting or first land based receiving). -3. ``late_cte``: unavailable. No agreed sending time is stored per supplier. +3. ``late_cte``: share of CTE records received in the window that arrived + more than ``supplier_facilities.agreed_sending_hours`` after their + ``event_time`` (received is ``created_at``). The denominator is the records + in the window with both times. Our measure against the agreed sending time, + not an FDA rule. With no agreed hours set, the status is not enough data. 4. ``epcis_failed``: unavailable. EPCIS documents are not stored as files with a validation result, and nothing ties an EPCIS upload to a facility. @@ -39,12 +43,10 @@ METRIC_UNLINKED_LOT = "unlinked_lot" METRIC_LATE_CTE = "late_cte" METRIC_EPCIS_FAILED = "epcis_failed" -DRILLDOWN_METRICS = (METRIC_MISSING_KDE, METRIC_UNLINKED_LOT) +DRILLDOWN_METRICS = (METRIC_MISSING_KDE, METRIC_UNLINKED_LOT, METRIC_LATE_CTE) LATE_CTE_LABEL = "Our measure, based on the agreed sending time with this supplier; not a regulatory requirement" -LATE_CTE_UNAVAILABLE_REASON = ( - "No agreed sending time is recorded for this supplier, so late records cannot be measured." -) +LATE_CTE_NO_HOURS_REASON = "No agreed sending time is set for this supplier, so late records cannot be measured." EPCIS_UNAVAILABLE_REASON = ( "EPCIS uploads are not stored as files with a validation result linked to a facility, " "so failing files cannot be counted." @@ -228,6 +230,30 @@ def share_metric(key: str, numerator: int, denominator: int, min_records: int = } +def timed_records(rows: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]: + """Records with both an event time and a received time: the late denominator.""" + return [row for row in rows if row.get("event_time") is not None and row.get("created_at") is not None] + + +def late_records(rows: Sequence[Mapping[str, Any]], agreed_sending_hours: int) -> list[Mapping[str, Any]]: + """Records received (``created_at``) more than ``agreed_sending_hours`` after ``event_time``.""" + allowed = timedelta(hours=agreed_sending_hours) + return [row for row in timed_records(rows) if row["created_at"] - row["event_time"] > allowed] + + +def late_metric(rows: Sequence[Mapping[str, Any]], agreed_sending_hours: Optional[int]) -> dict[str, Any]: + if agreed_sending_hours is None: + return { + "key": METRIC_LATE_CTE, + "status": "not_enough_data", + "numerator": None, + "denominator": None, + "share": None, + "reason": LATE_CTE_NO_HOURS_REASON, + } + return share_metric(METRIC_LATE_CTE, len(late_records(rows, agreed_sending_hours)), len(timed_records(rows))) + + def unavailable_metric(key: str, reason: str) -> dict[str, Any]: return { "key": key, @@ -284,13 +310,14 @@ def unlinked_lots(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: def summarize_facility( record_rows: Sequence[Mapping[str, Any]], lot_rows: Sequence[Mapping[str, Any]], + agreed_sending_hours: Optional[int] = None, ) -> dict[str, dict[str, Any]]: """The four metrics for one facility, keyed by metric.""" lot_count = len(group_lots(lot_rows)) return { METRIC_MISSING_KDE: share_metric(METRIC_MISSING_KDE, len(flag_records(record_rows)), len(record_rows)), METRIC_UNLINKED_LOT: share_metric(METRIC_UNLINKED_LOT, len(unlinked_lots(lot_rows)), lot_count), - METRIC_LATE_CTE: unavailable_metric(METRIC_LATE_CTE, LATE_CTE_UNAVAILABLE_REASON), + METRIC_LATE_CTE: late_metric(record_rows, agreed_sending_hours), METRIC_EPCIS_FAILED: unavailable_metric(METRIC_EPCIS_FAILED, EPCIS_UNAVAILABLE_REASON), } @@ -357,6 +384,18 @@ def summarize_facility( ) +_SENDING_HOURS_SQL = text( + """ + SELECT f.id AS facility_id, + f.agreed_sending_hours + FROM supplier_facilities f + WHERE f.tenant_id = CAST(:tid AS uuid) + AND f.supplier_user_id = CAST(:uid AS uuid) + AND f.id = ANY(CAST(:fids AS uuid[])) + """ +) + + def _params(tenant_id: str, supplier_user_id: str, facility_ids: Sequence[str], since: datetime) -> dict[str, Any]: return {"tid": tenant_id, "uid": supplier_user_id, "fids": list(facility_ids), "since": since} @@ -379,6 +418,18 @@ def fetch_lot_rows( return [dict(row) for row in result.mappings().all()] +def fetch_sending_hours( + db_session: Any, tenant_id: str, supplier_user_id: str, facility_ids: Sequence[str] +) -> dict[str, Optional[int]]: + """Agreed sending hours per facility id; None where none is set.""" + if not facility_ids: + return {} + result = db_session.execute( + _SENDING_HOURS_SQL, {"tid": tenant_id, "uid": supplier_user_id, "fids": list(facility_ids)} + ) + return {str(row["facility_id"]): row["agreed_sending_hours"] for row in result.mappings().all()} + + def _by_facility(rows: Sequence[Mapping[str, Any]], facility_ids: Sequence[str]) -> dict[str, list[Mapping[str, Any]]]: grouped: dict[str, list[Mapping[str, Any]]] = {key: [] for key in facility_ids} for row in rows: @@ -392,10 +443,19 @@ def fetch_supplier_quality( supplier_user_id: str, facility_ids: Sequence[str], since: datetime, + sending_hours: Optional[Mapping[str, Optional[int]]] = None, ) -> dict[str, dict[str, dict[str, Any]]]: - """Per facility metrics for ``facility_ids`` (every id is present).""" + """Per facility metrics for ``facility_ids`` (every id is present). + + ``sending_hours`` is fetched when the caller has not already done so. + """ records = _by_facility( fetch_record_rows(db_session, tenant_id, supplier_user_id, facility_ids, since), facility_ids ) lots = _by_facility(fetch_lot_rows(db_session, tenant_id, supplier_user_id, facility_ids, since), facility_ids) - return {key: summarize_facility(records[key], lots[key]) for key in facility_ids} + hours = ( + sending_hours + if sending_hours is not None + else fetch_sending_hours(db_session, tenant_id, supplier_user_id, facility_ids) + ) + return {key: summarize_facility(records[key], lots[key], hours.get(key)) for key in facility_ids} diff --git a/services/shared/tests/test_supplier_data_quality.py b/services/shared/tests/test_supplier_data_quality.py index 4d5c2e5d3..0197ae499 100644 --- a/services/shared/tests/test_supplier_data_quality.py +++ b/services/shared/tests/test_supplier_data_quality.py @@ -14,20 +14,26 @@ from shared.supplier_data_quality import ( COLUMN_BACKED_KDES, DEFAULT_WINDOW_DAYS, + DRILLDOWN_METRICS, + LATE_CTE_NO_HOURS_REASON, METRIC_EPCIS_FAILED, METRIC_LATE_CTE, METRIC_MISSING_KDE, METRIC_UNLINKED_LOT, MIN_RECORDS, WEBHOOK_REQUIRED_KDES, + fetch_sending_hours, fetch_supplier_quality, flag_records, group_lots, + late_metric, + late_records, lot_is_linked, missing_kdes, required_record_kdes, share_metric, summarize_facility, + timed_records, unavailable_metric, unlinked_lots, window_start, @@ -52,14 +58,25 @@ LOT_CODES: dict[str, str] = {} -def _record(cte_type: str, kde_data: dict, lot_code: str | None = None) -> dict: +# Any positive whole number of hours; derived rather than picked. +AGREED_HOURS = MIN_RECORDS +NOW = datetime.now(timezone.utc) + + +def _record( + cte_type: str, + kde_data: dict, + lot_code: str | None = None, + event_time: datetime | None = None, + created_at: datetime | None = None, +) -> dict: return { "record_id": uuid.uuid4(), "facility_id": FACILITY_ID, "cte_type": cte_type, "kde_data": kde_data, - "event_time": None, - "created_at": None, + "event_time": event_time, + "created_at": created_at, "lot_code": lot_code if lot_code is not None else str(uuid.uuid4()), } @@ -237,6 +254,73 @@ def test_window_start(): # --- facility summary -------------------------------------------------------- +# --- late CTE ------------------------------------------------------------------ + + +def _received_after(delay: timedelta) -> dict: + """A shipping record received ``delay`` after its event time.""" + return _record("shipping", COMPLETE_SHIPPING, event_time=NOW - delay, created_at=NOW) + + +ON_TIME = timedelta(hours=AGREED_HOURS) +LATE = ON_TIME + timedelta.resolution + + +def test_record_received_exactly_at_agreed_hours_is_on_time(): + assert late_records([_received_after(ON_TIME)], AGREED_HOURS) == [] + + +def test_record_received_after_agreed_hours_is_late(): + late = _received_after(LATE) + assert late_records([_received_after(ON_TIME), late], AGREED_HOURS) == [late] + + +def test_records_without_both_times_are_outside_the_denominator(): + untimed = [ + _record("shipping", {}, event_time=NOW), + _record("shipping", {}, created_at=NOW), + _record("shipping", {}), + ] + timed = [_received_after(LATE)] + assert timed_records(untimed + timed) == timed + assert late_records(untimed, AGREED_HOURS) == [] + + +def test_late_metric_without_agreed_hours_is_not_enough_data_with_reason(): + rows = [_received_after(LATE) for _ in range(MIN_RECORDS)] + assert late_metric(rows, None) == { + "key": METRIC_LATE_CTE, + "status": "not_enough_data", + "numerator": None, + "denominator": None, + "share": None, + "reason": LATE_CTE_NO_HOURS_REASON, + } + + +def test_late_metric_below_min_timed_records_is_not_enough_data(): + timed = [_received_after(LATE) for _ in range(MIN_RECORDS - 1)] + # Untimed rows never lift the count over MIN_RECORDS. + untimed = [_record("shipping", {}) for _ in range(MIN_RECORDS)] + metric = late_metric(timed + untimed, AGREED_HOURS) + assert metric["status"] == "not_enough_data" + assert metric["share"] is None + assert (metric["numerator"], metric["denominator"]) == (len(timed), len(timed)) + + +def test_late_metric_at_min_records_computes_share(): + late = [_received_after(LATE) for _ in range(MIN_RECORDS // 2)] + on_time = [_received_after(ON_TIME) for _ in range(MIN_RECORDS - len(late))] + metric = late_metric(late + on_time + [_record("shipping", {})], AGREED_HOURS) + assert metric["status"] == "ok" + assert (metric["numerator"], metric["denominator"]) == (len(late), len(late + on_time)) + assert metric["share"] == len(late) / len(late + on_time) + + +def test_late_cte_is_a_drilldown_metric(): + assert METRIC_LATE_CTE in DRILLDOWN_METRICS + + def test_summarize_facility_keeps_four_separate_metrics(): complete = [_record("shipping", COMPLETE_SHIPPING) for _ in range(MIN_RECORDS // 2)] incomplete = [_record("shipping", {}) for _ in range(MIN_RECORDS - len(complete))] @@ -251,13 +335,21 @@ def test_summarize_facility_keeps_four_separate_metrics(): len(lots), len(lots), ) - assert summary[METRIC_LATE_CTE]["status"] == "unavailable" + assert summary[METRIC_LATE_CTE] == late_metric(records, None) assert summary[METRIC_EPCIS_FAILED]["status"] == "unavailable" +def test_summarize_facility_measures_late_records_with_agreed_hours(): + late = [_received_after(LATE) for _ in range(MIN_RECORDS)] + summary = summarize_facility(late, [], AGREED_HOURS) + assert summary[METRIC_LATE_CTE]["status"] == "ok" + assert summary[METRIC_LATE_CTE]["share"] == len(late) / len(late) + + def test_fetch_supplier_quality_without_facilities_runs_no_query(): class NoQuery: def execute(self, *args, **kwargs): # pragma: no cover (must not run) raise AssertionError("no query expected") assert fetch_supplier_quality(NoQuery(), str(uuid.uuid4()), str(uuid.uuid4()), [], datetime.now(timezone.utc)) == {} + assert fetch_sending_hours(NoQuery(), str(uuid.uuid4()), str(uuid.uuid4()), []) == {} diff --git a/supabase/migrations/20261024070000_supplier_agreed_sending_hours.sql b/supabase/migrations/20261024070000_supplier_agreed_sending_hours.sql new file mode 100644 index 000000000..526fd8f2d --- /dev/null +++ b/supabase/migrations/20261024070000_supplier_agreed_sending_hours.sql @@ -0,0 +1,22 @@ +-- Agreed sending time per supplier facility. +-- +-- agreed_sending_hours is how many hours after a CTE's event time the +-- supplier has agreed to send the record. The supplier data quality view +-- counts a record as late when it is received more than this many hours after +-- its event time. This is our own measure, not an FDA rule. NULL means no +-- agreement is recorded, and the late measure shows not enough data. +-- +-- No backfill. Additive and idempotent: the inline CHECK is only created +-- together with the column, so a re-run is a no-op. + +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); + +-- Downgrade (run by hand only; alembic v136 b005ddbf1dba does the same): +-- +-- alter table public.supplier_facilities +-- drop constraint if exists supplier_facilities_agreed_sending_hours_check; +-- alter table public.supplier_facilities +-- drop column if exists agreed_sending_hours; diff --git a/tests/security/test_route_security_manifest.py b/tests/security/test_route_security_manifest.py index 8d734b306..69ab87850 100644 --- a/tests/security/test_route_security_manifest.py +++ b/tests/security/test_route_security_manifest.py @@ -73,7 +73,9 @@ def test_checked_in_manifest_matches_live_route_and_dependency_graph(manifest): # +1 tenant-scoped (#3572): admin:GET:/v1/supplier/gap-time-to-fix. # +2 tenant-scoped: admin:GET:/v1/supplier/data-quality and # admin:GET:/v1/supplier/data-quality/{facility_id}/records, owner-only, tenant-filtered. - "enabled_operation_count": 257, + # +1 tenant-scoped: admin:PUT:/v1/supplier/facilities/{facility_id}/agreed-sending-hours, + # owner-only, tenant-filtered. + "enabled_operation_count": 258, # 178/161 includes graph:GET:/api/v1/provisions/by-request: it is added # to the graph v1_router AFTER router.include_router(v1_router), so the # eager route copies of fastapi < 0.137 (under which the previous @@ -97,7 +99,7 @@ def test_checked_in_manifest_matches_live_route_and_dependency_graph(manifest): "enabled_by_classification": { "global-sysadmin": 16, "public": 30, - "tenant-scoped": 204, + "tenant-scoped": 205, "workload-only": 7, }, } From 8cc9a3bea156406418db696d2b64ba1f7d52b6a8 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:18:26 -0700 Subject: [PATCH 2/9] test(security): count the agreed sending hours PUT as a dual session CSRF mutation Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- tests/security/test_route_security_manifest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/security/test_route_security_manifest.py b/tests/security/test_route_security_manifest.py index 69ab87850..d7622fe8d 100644 --- a/tests/security/test_route_security_manifest.py +++ b/tests/security/test_route_security_manifest.py @@ -192,8 +192,8 @@ def test_dual_session_routes_record_cookie_tenancy_and_csrf_adapter(manifest): dual = [item for item in manifest["enabled_operations"] if item["test_adapter"] == "tenant_dual_session_csrf"] mutations = [item for item in dual if item["method"] in {"POST", "PUT", "PATCH", "DELETE"}] - assert len(dual) == 54 - assert len(mutations) == 23 + assert len(dual) == 55 + assert len(mutations) == 24 assert all(item["authentication"] == "bearer-token-or-opaque-session-cookie" for item in dual) assert all(item["storage_boundary"] == "admin-postgres:jwt-tenant-or-session-membership-tenant" for item in dual) From 627cd9d954d30320cfc6496649eec15ddaf70306 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:59:15 -0700 Subject: [PATCH 3/9] fix(copy): real-data follow-up to #3471 (v3) (#3544) * fix(dashboard): drop fabricated demo data from recall report page Remove the hardcoded demo investigation stats and affected-lot rows (invented suppliers, lot codes, consumer counts). Demo mode now renders the same No data yet empty state with a link to import records. * feat(copy): label every EPCIS intake and export mention as (Preview) EPCIS is preview on both paths, so feature-name mentions across app and marketing copy now carry the (Preview) marker. The integrations eyebrow now reads CSV upload, JSON events API, EPCIS (Preview). The FDA sortable export stays unlabeled. Stored keys and option values are unchanged. * fix(copy): keep typed EPCIS format value unlabeled in customer-readiness The format field is a string union used for logic; the (Preview) marker stays on the display name only. * feat(pricing): show honest unavailable state when billing is unreachable Drop the hardcoded tier prices and the silent fallback. Prices now come only from the billing service; when it cannot be reached the page renders a Pricing is temporarily unavailable state with signup and contact links instead of invented figures. Hardcoded dollar amounts in the pricing copy and metadata are removed as well. EPCIS 2.0 export carries the (Preview) label; the FDA sortable export stays unlabeled. * fix(app): remove sample and sandbox banners and the simulated ingest walkthrough Dashboard shell and DemoBanner now say No data yet with a link to connect real data on the Ingest page. The ingest page no longer fakes a job for the first example URL; it submits for real. The Sample: Daily FDA package archive entry is removed from the archive jobs list. Test assertions updated for the EPCIS (Preview) status labels. * fix(data-import): remove synthetic sample dataset simulation Delete SAMPLE_EVENTS, the handleLoadSample flow with its state and types, and the Load Sample Dataset / Run Simulation card. A regression test asserts the control is not rendered. Real CSV upload and the API guide are unchanged. * test(copy): assert csv-sftp display text never renders SFTP Display labels were already clean (CSV Import); this locks the invariant for the csv-sftp registry entry name, customer copy, and the csv_sftp_import delivery-mode label. Stored keys and ids are unchanged. * fix(receiving): use a comma in generated Receiving Dock location names New receiving submissions name the location 'Receiving Dock, ' instead of using a dash. No existing records are touched. * fix(inflow-lab): hide Inflow Lab links from dashboard, docs, ingest, integrations and product * fix(tools): remove Inflow Lab simulator, walkthrough sample payloads and recall drill demo data Drops tools/inflow-lab components, lib/api-samples ingest walkthrough JSON, synthetic tools/ask and tools/export copy, and invented proof/recall-drill numbers. Recall report shows the #3536 no_data empty state. * ci: trigger required gates Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --------- Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../public/samples/sample_recall_drill.csv | 11 - .../__tests__/components/demo-banner.test.tsx | 8 +- .../components/evidence-chain.test.tsx | 167 - .../src/__tests__/lib/inflow-error.test.ts | 14 - .../lib/inflow-lab-exception-grouping.test.ts | 152 - .../pages/dashboard-navigation.test.tsx | 10 +- .../src/__tests__/pages/data-import.test.tsx | 6 + .../__tests__/pages/docs-contract.test.tsx | 42 +- .../pages/experimental-gating.test.tsx | 24 +- .../src/__tests__/pages/inflow-lab.test.tsx | 761 ----- .../src/__tests__/pages/ingest-epcis.test.tsx | 4 +- .../__tests__/pages/pre-revenue-copy.test.ts | 9 +- .../pages/proof-recall-drill.test.tsx | 49 - .../__tests__/pages/recall-report.test.tsx | 84 +- frontend/src/app/buyer-programs/page.tsx | 2 +- frontend/src/app/compliance/page.tsx | 2 +- .../src/app/dashboard/compliance/page.tsx | 30 +- .../src/app/dashboard/export-jobs/page.tsx | 12 +- .../src/app/dashboard/inflow-lab/page.tsx | 22 +- .../src/app/dashboard/integrations/page.tsx | 134 +- frontend/src/app/dashboard/layout.tsx | 6 +- frontend/src/app/dashboard/page.tsx | 20 +- .../src/app/dashboard/recall-drills/page.tsx | 2 +- .../src/app/dashboard/recall-report/page.tsx | 198 +- frontend/src/app/dashboard/receiving/page.tsx | 2 +- frontend/src/app/developers/page.tsx | 4 +- .../src/app/docs/api/ApiReferenceClient.tsx | 28 +- frontend/src/app/docs/changelog/page.tsx | 2 +- .../connectors/inflow-lab/opengraph-image.tsx | 10 - .../app/docs/connectors/inflow-lab/page.tsx | 175 +- frontend/src/app/docs/fsma-204/page.tsx | 4 +- frontend/src/app/docs/page.tsx | 3 +- frontend/src/app/docs/quickstart/page.tsx | 12 +- frontend/src/app/docs/walkthrough/page.tsx | 47 +- frontend/src/app/fsma/integration/page.tsx | 2 +- frontend/src/app/ingest/page.tsx | 43 +- frontend/src/app/ingest/sandbox-handoff.ts | 4 +- frontend/src/app/integrations/page.tsx | 90 +- frontend/src/app/pricing/page.tsx | 99 +- frontend/src/app/product/page.tsx | 10 - frontend/src/app/proof/page.tsx | 10 +- frontend/src/app/proof/recall-drill/page.tsx | 515 --- frontend/src/app/sitemap.ts | 6 +- frontend/src/app/tools/ask/page.tsx | 204 +- .../components/DataImportClient.tsx | 151 - frontend/src/app/tools/export/page.tsx | 122 +- .../inflow-lab-dashboard-preview/page.tsx | 23 +- .../components/DashboardInflowLab.tsx | 2311 -------------- .../inflow-lab/components/EvidenceChain.tsx | 322 -- .../inflow-lab/components/InflowLabClient.tsx | 2774 ----------------- .../components/InflowLabTutorial.tsx | 224 -- .../inflow-lab/lib/exception-grouping.ts | 121 - .../app/tools/inflow-lab/opengraph-image.tsx | 10 - frontend/src/app/tools/inflow-lab/page.tsx | 28 +- frontend/src/app/trust/architecture/page.tsx | 4 +- frontend/src/app/trust/page.tsx | 6 +- frontend/src/app/trust/retention/page.tsx | 4 +- frontend/src/app/walkthrough/page.tsx | 2 +- frontend/src/app/why-regengine/page.tsx | 12 +- .../components/control-plane/demo-banner.tsx | 8 +- .../src/components/dashboard/breadcrumb.tsx | 1 - .../dashboard/metrics-overview-widget.tsx | 4 +- .../components/layout/marketing-footer.tsx | 6 +- .../components/pricing/PricingPageClient.tsx | 6 +- frontend/src/components/ui/tutorial.tsx | 4 +- .../lib/api-samples/ingest-inflow-lab.json | 22 - .../src/lib/api-samples/ingest-receiving.json | 21 - .../src/lib/api-samples/ingest-samples.ts | 73 +- .../ingest-walkthrough-receiving.json | 22 - .../ingest-walkthrough-shipping.json | 22 - .../ingest-walkthrough-transformation.json | 28 - frontend/src/lib/customer-readiness.test.ts | 8 + frontend/src/lib/customer-readiness.ts | 18 +- frontend/src/lib/doc-pages.ts | 1 - frontend/src/lib/inflow-error.ts | 6 - .../src/lib/marketing-analytics-routes.ts | 3 +- frontend/tests/visual/routes.json | 2 +- 77 files changed, 404 insertions(+), 9004 deletions(-) delete mode 100644 frontend/public/samples/sample_recall_drill.csv delete mode 100644 frontend/src/__tests__/components/evidence-chain.test.tsx delete mode 100644 frontend/src/__tests__/lib/inflow-error.test.ts delete mode 100644 frontend/src/__tests__/lib/inflow-lab-exception-grouping.test.ts delete mode 100644 frontend/src/__tests__/pages/inflow-lab.test.tsx delete mode 100644 frontend/src/__tests__/pages/proof-recall-drill.test.tsx delete mode 100644 frontend/src/app/docs/connectors/inflow-lab/opengraph-image.tsx delete mode 100644 frontend/src/app/proof/recall-drill/page.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/components/DashboardInflowLab.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/components/EvidenceChain.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/components/InflowLabTutorial.tsx delete mode 100644 frontend/src/app/tools/inflow-lab/lib/exception-grouping.ts delete mode 100644 frontend/src/app/tools/inflow-lab/opengraph-image.tsx delete mode 100644 frontend/src/lib/api-samples/ingest-inflow-lab.json delete mode 100644 frontend/src/lib/api-samples/ingest-receiving.json delete mode 100644 frontend/src/lib/api-samples/ingest-walkthrough-receiving.json delete mode 100644 frontend/src/lib/api-samples/ingest-walkthrough-shipping.json delete mode 100644 frontend/src/lib/api-samples/ingest-walkthrough-transformation.json delete mode 100644 frontend/src/lib/inflow-error.ts 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__/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..9b8136c4c 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -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()}>