diff --git a/app/src/api/flagshipReportStore.ts b/app/src/api/flagshipReportStore.ts index f0dfb7f43..661c85882 100644 --- a/app/src/api/flagshipReportStore.ts +++ b/app/src/api/flagshipReportStore.ts @@ -8,10 +8,13 @@ import { readOwnedItems, } from '@/libs/flagship/ownedItems'; import { - ReportValidationSnapshot, - validationFromWire, - validationToWire, -} from '@/libs/flagship/reportValidation'; + parseReportRecord, + parseReportRow, + readableReports, + stampReportRecord, + unwrapReport, +} from '@/libs/flagship/reportSchema'; +import { ReportValidationSnapshot, validationToWire } from '@/libs/flagship/reportValidation'; import { RunReportProvision } from '@/libs/flagship/runReport'; import { clampStoreText } from '@/libs/flagship/storeText'; @@ -81,25 +84,9 @@ function newestFirst(a: FlagshipReportRecord, b: FlagshipReportRecord): number { return b.createdAt.localeCompare(a.createdAt); } -function fromMetadata(data: any): FlagshipReportRecord { - return { - id: data.id, - countryId: data.country_id, - apiReportId: data.api_report_id, - title: data.title, - sourceNote: data.source_note, - provisions: (data.provisions ?? []).map((p: any) => ({ - path: p.path, - breadcrumb: p.breadcrumb, - unit: p.unit ?? null, - baselineValue: p.baseline_value, - value: p.value, - })), - year: data.year, - createdAt: data.created_at, - reformId: data.reform_id ?? null, - validation: validationFromWire(data.validation), - }; +/** A central row as a record (see reportSchema); throws ReportSchemaError when unreadable. */ +function fromMetadata(data: unknown): FlagshipReportRecord { + return unwrapReport(parseReportRow(data)); } export class ApiFlagshipReportStore implements FlagshipReportStore { @@ -158,7 +145,9 @@ export class ApiFlagshipReportStore implements FlagshipReportStore { throwIfUnavailable(response); throw new Error('Failed to load reports'); } - records.push(...((await response.json()) as any[]).map(fromMetadata)); + // A row that can't be read is left out of the list, not fatal to it. + const rows: unknown = await response.json(); + records.push(...readableReports((Array.isArray(rows) ? rows : []).map(parseReportRow))); } return records.sort(newestFirst); } @@ -215,7 +204,11 @@ const LOCAL_KEY = 'pe-flagship-reports'; * central store dropped user ids, which still carry a userId field. */ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { - private read(): FlagshipReportRecord[] { + /** + * The entries as stored, of any schema version. Writes keep the ones + * this version can't read, so a record a newer version saved survives. + */ + private readStored(): unknown[] { try { const parsed = JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]'); return Array.isArray(parsed) ? parsed : []; @@ -224,21 +217,43 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { } } - private write(records: FlagshipReportRecord[]): void { + private write(entries: unknown[]): void { try { - localStorage.setItem(LOCAL_KEY, JSON.stringify(records)); + localStorage.setItem(LOCAL_KEY, JSON.stringify(entries)); } catch { // Reports simply won't persist locally. } } + /** The readable records, in the current shape; an unreadable entry is left out. */ + private read(): FlagshipReportRecord[] { + return readableReports(this.readStored().map(parseReportRecord)); + } + + /** The first entry with this field value, parsed; throws ReportSchemaError when unreadable. */ + private findStored( + field: 'id' | 'apiReportId', + value: string + ): { entries: unknown[]; index: number; record: FlagshipReportRecord } | null { + const entries = this.readStored(); + const index = entries.findIndex( + (entry) => !!entry && typeof entry === 'object' && (entry as any)[field] === value + ); + if (index === -1) { + return null; + } + return { entries, index, record: unwrapReport(parseReportRecord(entries[index])) }; + } + async create(report: NewFlagshipReport): Promise { - const record: FlagshipReportRecord = { - ...report, - id: `lfr-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, - createdAt: new Date().toISOString(), - }; - this.write([record, ...this.read()]); + const record = unwrapReport( + parseReportRecord({ + ...report, + id: `lfr-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, + createdAt: new Date().toISOString(), + }) + ); + this.write([stampReportRecord(record), ...this.readStored()]); return record; } @@ -249,25 +264,24 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { } async findById(id: string): Promise { - return this.read().find((record) => record.id === id) ?? null; + return this.findStored('id', id)?.record ?? null; } async findByApiReportId(apiReportId: string): Promise { - return this.read().find((record) => record.apiReportId === apiReportId) ?? null; + return this.findStored('apiReportId', apiReportId)?.record ?? null; } async saveValidation( id: string, snapshot: ReportValidationSnapshot ): Promise { - const records = this.read(); - const index = records.findIndex((record) => record.id === id); - if (index === -1) { + const found = this.findStored('id', id); + if (!found) { throw new Error('Report not found'); } - const updated = { ...records[index], validation: snapshot }; - records[index] = updated; - this.write(records); + const updated = { ...found.record, validation: snapshot }; + found.entries[found.index] = stampReportRecord(updated); + this.write(found.entries); return updated; } } diff --git a/app/src/components/flagship/report/ReportDetailsError.tsx b/app/src/components/flagship/report/ReportDetailsError.tsx new file mode 100644 index 000000000..9b0cbf321 --- /dev/null +++ b/app/src/components/flagship/report/ReportDetailsError.tsx @@ -0,0 +1,113 @@ +import { IconAlertTriangle } from '@tabler/icons-react'; +import { Button } from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { colors, spacing, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import type { ReportSchemaError } from '@/libs/flagship/reportSchema'; + +/** + * A report whose stored details (title, source note, provisions) can't + * be read: saved by a newer version of the app, or damaged. The results + * can still open without them, rebuilt from the report's policy. + */ +export default function ReportDetailsError({ + error, + onOpenWithout, +}: { + error: ReportSchemaError; + /** Opens the report without the stored details. */ + onOpenWithout: () => void; +}) { + const nav = useAppNavigate(); + const countryId = useCurrentCountry(); + const newer = error.code === 'newer-version'; + + return ( +
+
+ + + +
+

+ This report's saved details can't be read +

+

+ {newer + ? 'A newer version of PolicyEngine saved them. Reload to update.' + : 'They are damaged in this browser.'} +

+
+
+ +
+ {newer && } + + +
+ +
+ + Technical details + +
+          {error.message}
+        
+
+
+ ); +} diff --git a/app/src/hooks/useUnreadableReportMeta.ts b/app/src/hooks/useUnreadableReportMeta.ts new file mode 100644 index 000000000..d49971766 --- /dev/null +++ b/app/src/hooks/useUnreadableReportMeta.ts @@ -0,0 +1,35 @@ +import { useMemo, useState } from 'react'; +import type { ReportSchemaError } from '@/libs/flagship/reportSchema'; +import { readReportMetaResult, removeReportMeta } from '@/libs/flagship/runReport'; + +export interface UnreadableReportMeta { + /** Why the stored provenance can't be read; null when it can, none is stored, or it is set aside. */ + error: ReportSchemaError | null; + /** Opens the report without it: the page rebuilds the provenance from the policy. */ + setAside: () => void; +} + +/** + * The provenance this browser stored when it ran the report, when the + * report schema can't read it. `readReportMeta` answers null for it, so + * once set aside the page carries on as for a report with none stored. + * + * Setting aside damaged provenance removes it, since no version of the + * app can read it, so the report opens without asking next time. + * Provenance a newer version saved stays stored for that version. + */ +export function useUnreadableReportMeta(userReportId: string): UnreadableReportMeta { + const [setAsideId, setSetAsideId] = useState(null); + // Read once per report: the page reads the provenance on every render. + const error = useMemo(() => { + const result = readReportMetaResult(userReportId); + return result && !result.ok ? result.error : null; + }, [userReportId]); + const setAside = () => { + if (error && error.code !== 'newer-version') { + removeReportMeta(userReportId); + } + setSetAsideId(userReportId); + }; + return { error: setAsideId === userReportId ? null : error, setAside }; +} diff --git a/app/src/libs/flagship/reportSchema.ts b/app/src/libs/flagship/reportSchema.ts new file mode 100644 index 000000000..55e50ff77 --- /dev/null +++ b/app/src/libs/flagship/reportSchema.ts @@ -0,0 +1,491 @@ +/** + * The stored shape of a v3 report, in one place. + * + * A report run stores two things: + * - its provenance (`FlagshipReportMeta`): the header's title, source + * note and provisions, kept in this browser's localStorage under the + * API report id; and + * - its record (`FlagshipReportRecord`): the pointer to the API report + * plus the same provenance, kept by the report store — the + * calculator-app routes (Postgres), or this browser's localStorage + * when they are unconfigured. + * + * Fields were added over time without a version, so every read goes + * through a parser here. It upgrades an older shape to the current one, + * fills safe defaults for fields older writes lacked, and answers a + * typed error for anything else, instead of a bad field throwing deep + * in the UI. Every write stamps `schemaVersion`. + * + * Versions: + * 1 — unversioned: everything written before this module. + * Meta: title, sourceNote, provisions, createdAt; later provisions + * gained `intervals` and the `growth` settings, and the meta + * `budgetWindow` and `baseline`. + * Record: id, userId, countryId, apiReportId, title, sourceNote, + * provisions, year, createdAt; later `reformId` and `validation`, + * and then `userId` was dropped. + * 2 — the same fields, stamped, with defaults filled and no `userId`. + * + * To add a field: add it to the type and give it a rule below (the rule + * maps do not compile until every field has one). A missing field takes + * the rule's default; when old records need more than a default, bump + * REPORT_SCHEMA_VERSION and add the upgrade step. A field this version + * does not know is dropped on read, so a field is only safe to write + * once its rule is here. + * + * Central rows have no version column: their columns are versioned by + * the database migrations, and the routes always serve the current + * columns. The rows' JSON fields (provisions, validation) follow the + * same rules as the browser's records, on the way in and on the way out. + */ +import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { CountryId, countryIds } from '@/libs/countries'; +import type { CalibrationRing } from '@/libs/flagship/calibrationMatching'; +import { + validationFromWire, + type ReportValidationMatch, + type ReportValidationSnapshot, +} from '@/libs/flagship/reportValidation'; +import type { FlagshipReportMeta, RunReportProvision } from '@/libs/flagship/runReport'; +import type { ValueInterval } from '@/types/subIngredients/valueInterval'; + +export type { FlagshipReportMeta, FlagshipReportRecord, RunReportProvision }; + +/** The version every write stamps. */ +export const REPORT_SCHEMA_VERSION = 2; + +/** The version of anything stored without a `schemaVersion`. */ +export const UNVERSIONED_REPORT_SCHEMA_VERSION = 1; + +/** + * What a report compared the reform with, when not current law: a saved + * reform run as the baseline policy. Named here as well as on the meta + * type, so the schema keeps it whether or not that type names it yet. + */ +export interface ReportMetaBaseline { + /** The saved reform, when the run came from one. */ + reformId: string | null; + /** The baseline policy on the API. */ + policyId: string; + /** Its name, for the report's labels. */ + label: string; +} + +/** The provenance with every field the schema reads. */ +export type ReportMetaFields = FlagshipReportMeta & { baseline?: ReportMetaBaseline }; + +export type StoredReportMeta = ReportMetaFields & { schemaVersion: number }; +export type StoredReportRecord = FlagshipReportRecord & { schemaVersion: number }; + +export type ReportSchemaErrorCode = + /** Not a JSON object at all. */ + | 'not-an-object' + /** A field is present with the wrong type, or a required one is missing. */ + | 'invalid-field' + /** Stamped by a newer version of the app than this one. */ + | 'newer-version'; + +/** Why a stored report can't be read. */ +export class ReportSchemaError extends Error { + readonly code: ReportSchemaErrorCode; + /** The offending field's path, e.g. "provisions[0].path". */ + readonly field?: string; + + constructor(code: ReportSchemaErrorCode, message: string, field?: string) { + super(message); + this.name = 'ReportSchemaError'; + this.code = code; + this.field = field; + } +} + +export type ReportParseResult = { ok: true; value: T } | { ok: false; error: ReportSchemaError }; + +/** The parsed value; throws its ReportSchemaError when it could not be read. */ +export function unwrapReport(result: ReportParseResult): T { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** The values that parsed, in order; the rest are left out. */ +export function readableReports(results: Array>): T[] { + return results.flatMap((result) => (result.ok ? [result.value] : [])); +} + +/* Field rules. A rule returns the field's value in the current shape, or + throws FieldError; `undefined` leaves an optional field out. */ + +class FieldError extends Error { + constructor( + readonly field: string, + message: string + ) { + super(message); + } +} + +type Rule = (value: unknown, field: string) => T; + +/** One rule per field, optional fields included. */ +type FieldRules = { [K in keyof T]-?: Rule }; + +function fail(field: string, expected: string): never { + throw new FieldError(field, `${field} must be ${expected}`); +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const isAbsent = (value: unknown): value is null | undefined => + value === undefined || value === null; + +const requiredText: Rule = (value, field) => + typeof value === 'string' && value.length > 0 ? value : fail(field, 'a non-empty string'); + +/** A string; absent, the fallback. */ +function text(fallback: string): Rule { + return (value, field) => + isAbsent(value) ? fallback : typeof value === 'string' ? value : fail(field, 'a string'); +} + +const nullableText: Rule = (value, field) => + isAbsent(value) ? null : typeof value === 'string' ? value : fail(field, 'a string or null'); + +const integer: Rule = (value, field) => + Number.isInteger(value) ? (value as number) : fail(field, 'an integer'); + +const finiteNumber: Rule = (value, field) => + typeof value === 'number' && Number.isFinite(value) ? value : fail(field, 'a number'); + +const country: Rule = (value, field) => + countryIds.includes(value as CountryId) + ? (value as CountryId) + : fail(field, `one of ${countryIds.join(', ')}`); + +const ring: Rule = (value, field) => + value === 'primary' || value === 'mechanism' || value === 'downstream' + ? value + : fail(field, 'primary, mechanism or downstream'); + +/** Parameter values are any JSON; kept exactly as stored. */ +const anyValue: Rule = (value) => value; + +function optional(rule: Rule): Rule { + return (value, field) => (isAbsent(value) ? undefined : rule(value, field)); +} + +function nullable(rule: Rule): Rule { + return (value, field) => (isAbsent(value) ? null : rule(value, field)); +} + +function arrayOf(rule: Rule): Rule { + return (value, field) => + Array.isArray(value) + ? value.map((item, index) => rule(item, `${field}[${index}]`)) + : fail(field, 'an array'); +} + +/** An object with exactly these fields; anything else on it is dropped. */ +function shape(rules: FieldRules): Rule { + return (value, field) => { + if (!isJsonObject(value)) { + return fail(field || 'the record', 'an object'); + } + const parsed: Record = {}; + for (const [key, rule] of Object.entries(rules) as Array<[string, Rule]>) { + const fieldValue = rule(value[key], field ? `${field}.${key}` : key); + if (fieldValue !== undefined) { + parsed[key] = fieldValue; + } + } + return parsed as T; + }; +} + +/** A rule whose failure means "leave it out" rather than "unreadable". */ +function orNull(rule: Rule): Rule { + return (value, field) => { + try { + return rule(value, field); + } catch (error) { + if (error instanceof FieldError) { + return null; + } + throw error; + } + }; +} + +const intervalRule = shape({ + parameter: optional(text('')), + startDate: requiredText, + endDate: requiredText, + value: anyValue, +}); + +const growthRound: Rule> = (value, field) => + value === 'nearest' || value === 'down' || value === 'up' + ? value + : fail(field, 'nearest, down or up'); + +const provisionFields = shape({ + path: requiredText, + breadcrumb: text(''), + unit: nullableText, + baselineValue: anyValue, + value: anyValue, + intervals: optional(arrayOf(intervalRule)), + // How a value set from one year on grows after it: Build reads these + // back when the report is edited there. + growth: optional(text('')), + growthStart: optional(finiteNumber), + growthBase: optional(finiteNumber), + growthRoundTo: optional(finiteNumber), + growthRound: optional(growthRound), +}); + +/** A provision; one stored without a breadcrumb is labelled by its path. */ +const provisionRule: Rule = (value, field) => { + const provision = provisionFields(value, field); + return provision.breadcrumb ? provision : { ...provision, breadcrumb: provision.path }; +}; + +const provisionsRule = arrayOf(provisionRule); + +const budgetWindowRule = shape>({ + startYear: integer, + windowSize: (value, field) => + Number.isInteger(value) && (value as number) >= 1 + ? (value as number) + : fail(field, 'a whole number of years'), +}); + +const baselineRule = shape({ + reformId: nullableText, + policyId: requiredText, + label: text(''), +}); + +const validationMatchRule = shape({ + variable: text(''), + depth: finiteNumber, + ring, + targetCount: finiteNumber, + meanAbsRelativeError: finiteNumber, + worst: shape({ + name: text(''), + source: text(''), + geography: text(''), + relativeError: nullable(finiteNumber), + }), +}); + +const validationRule = shape({ + matchedAt: requiredText, + mapModelVersion: text(''), + calibration: nullable( + shape>({ + releaseId: nullableText, + geography: text(''), + reachedCount: finiteNumber, + matches: arrayOf(validationMatchRule), + }) + ), + scorecard: nullable( + shape>({ + programs: arrayOf(text('')), + }) + ), +}); + +const metaRule = shape({ + title: text(''), + sourceNote: text(''), + provisions: provisionsRule, + createdAt: text(''), + budgetWindow: optional(budgetWindowRule), + // Absent, the report compares with current law. + baseline: optional(baselineRule), +}); + +const recordRule = shape({ + id: requiredText, + countryId: country, + apiReportId: requiredText, + title: nullableText, + sourceNote: nullableText, + provisions: provisionsRule, + year: requiredText, + createdAt: text(''), + reformId: nullableText, + // The pin is a cache of what validation matched: a damaged one is + // dropped, and the report pins again, rather than the record failing. + validation: orNull(validationRule), +}); + +/* Upgrades, keyed by the version they upgrade from. */ + +type Upgrade = (stored: Record) => Record; + +const META_UPGRADES: Record = { + // Unversioned meta has today's fields; the rules fill what it lacks. + 1: (stored) => stored, +}; + +const RECORD_UPGRADES: Record = { + // Records saved before ownership moved to edit tokens carry the shared user id. + 1: ({ userId: _userId, ...stored }) => stored, +}; + +function parseVersioned( + stored: unknown, + upgrades: Record, + rule: Rule +): ReportParseResult { + if (!isJsonObject(stored)) { + return { + ok: false, + error: new ReportSchemaError('not-an-object', 'The stored report is not an object'), + }; + } + const version = stored.schemaVersion ?? UNVERSIONED_REPORT_SCHEMA_VERSION; + if (!Number.isInteger(version) || (version as number) < UNVERSIONED_REPORT_SCHEMA_VERSION) { + return { + ok: false, + error: new ReportSchemaError( + 'invalid-field', + 'schemaVersion must be a positive integer', + 'schemaVersion' + ), + }; + } + if ((version as number) > REPORT_SCHEMA_VERSION) { + return { + ok: false, + error: new ReportSchemaError( + 'newer-version', + `Saved with report schema ${version}; this version reads up to ${REPORT_SCHEMA_VERSION}`, + 'schemaVersion' + ), + }; + } + let current = stored; + for (let from = version as number; from < REPORT_SCHEMA_VERSION; from++) { + current = upgrades[from](current); + } + try { + return { ok: true, value: rule(current, '') }; + } catch (error) { + if (error instanceof FieldError) { + return { + ok: false, + error: new ReportSchemaError('invalid-field', error.message, error.field), + }; + } + throw error; + } +} + +/** Reads a report's stored provenance, of any version, in the current shape. */ +export function parseReportMeta(stored: unknown): ReportParseResult { + return parseVersioned(stored, META_UPGRADES, metaRule); +} + +/** Reads a stored report record, of any version, in the current shape. */ +export function parseReportRecord(stored: unknown): ReportParseResult { + return parseVersioned(stored, RECORD_UPGRADES, recordRule); +} + +/** Provisions as the central store speaks them (snake_case), in the record's names. */ +function provisionsFromWire(provisions: unknown): unknown { + if (!Array.isArray(provisions)) { + return provisions; + } + return provisions.map((provision) => + isJsonObject(provision) + ? { + path: provision.path, + breadcrumb: provision.breadcrumb, + unit: provision.unit, + baselineValue: provision.baseline_value, + value: provision.value, + } + : provision + ); +} + +/** A validation pin from the wire; one too damaged to convert reads as none. */ +function validationFromWireOrNull(wire: unknown): ReportValidationSnapshot | null { + try { + return validationFromWire(wire); + } catch { + return null; + } +} + +/** Reads a central store row (the routes' snake_case wire form) as a record. */ +export function parseReportRow(row: unknown): ReportParseResult { + if (!isJsonObject(row)) { + return parseReportRecord(row); + } + return parseReportRecord({ + // The routes serve the current columns (see above). + schemaVersion: REPORT_SCHEMA_VERSION, + id: row.id, + countryId: row.country_id, + apiReportId: row.api_report_id, + title: row.title, + sourceNote: row.source_note, + provisions: provisionsFromWire(row.provisions), + year: row.year, + createdAt: row.created_at, + reformId: row.reform_id, + validation: validationFromWireOrNull(row.validation), + }); +} + +/** The rule's error message for this value, or null when it fits. */ +function wireError(rule: Rule, value: unknown, field: string): string | null { + try { + rule(value, field); + return null; + } catch (error) { + if (error instanceof FieldError) { + return error.message; + } + throw error; + } +} + +/** + * Why a create body's provisions (snake_case) break the report schema, + * or null when they fit: the same rules the client reads them back with. + */ +export function reportProvisionsWireError(provisions: unknown): string | null { + return wireError(provisionsRule, provisionsFromWire(provisions), 'provisions'); +} + +/** + * Why a validation pin (snake_case) breaks the report schema, or null + * when it fits: the same rules the client reads it back with. + */ +export function reportValidationWireError(validation: unknown): string | null { + const snapshot = validationFromWireOrNull(validation); + if (!snapshot) { + return 'validation must be a snapshot with matched_at'; + } + return wireError(validationRule, snapshot, 'validation'); +} + +/** Provenance as it is written: stamped with the current version. */ +export function stampReportMeta(meta: FlagshipReportMeta): StoredReportMeta { + return { ...meta, schemaVersion: REPORT_SCHEMA_VERSION }; +} + +/** A record as it is written: stamped with the current version. */ +export function stampReportRecord(record: FlagshipReportRecord): StoredReportRecord { + return { ...record, schemaVersion: REPORT_SCHEMA_VERSION }; +} diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index 9fa87a298..c5ec879b6 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -6,6 +6,7 @@ import { fetchSocietyWideCalculation } from '@/api/societyWideCalculation'; import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; import { CountryId } from '@/libs/countries'; import { ValueInterval } from '@/types/subIngredients/valueInterval'; +import { parseReportMeta, stampReportMeta, type ReportParseResult } from './reportSchema'; /** * The run bridge: turns a set of provisions (from a draft reform or a @@ -23,6 +24,12 @@ export interface RunReportProvision { value: any; /** Dated changes, when the provision varies over time (see DraftProvision). */ intervals?: ValueInterval[]; + /** How a value set from one year on grows, from when, and how it rounds (see DraftProvision). */ + growth?: string; + growthStart?: number; + growthBase?: number; + growthRoundTo?: number; + growthRound?: 'nearest' | 'down' | 'up'; } /** Provenance shown in the flagship report header, stashed per report. */ @@ -38,7 +45,8 @@ export interface FlagshipReportMeta { const META_KEY = 'pe-flagship-report-meta'; -function readAllMeta(): Record { +/** Every report's provenance as stored, of any schema version (see reportSchema). */ +function readAllMeta(): Record { try { return JSON.parse(localStorage.getItem(META_KEY) ?? '{}'); } catch { @@ -46,7 +54,8 @@ function readAllMeta(): Record { } } -export function saveReportMeta(userReportId: string, meta: FlagshipReportMeta): void { +export function saveReportMeta(userReportId: string, unstamped: FlagshipReportMeta): void { + const meta = stampReportMeta(unstamped); try { localStorage.setItem(META_KEY, JSON.stringify({ ...readAllMeta(), [userReportId]: meta })); } catch { @@ -54,8 +63,44 @@ export function saveReportMeta(userReportId: string, meta: FlagshipReportMeta): } } +/** The stash, when it holds this report's provenance; a stash that is not a map holds none. */ +function stashWith(userReportId: string): Record | null { + const all: unknown = readAllMeta(); + if (!all || typeof all !== 'object' || Array.isArray(all) || !Object.hasOwn(all, userReportId)) { + return null; + } + return all as Record; +} + +/** + * The report's stored provenance, parsed: null when none is stored, + * otherwise the current shape or why it can't be read. + */ +export function readReportMetaResult( + userReportId: string +): ReportParseResult | null { + const all = stashWith(userReportId); + return all ? parseReportMeta(all[userReportId]) : null; +} + +/** The report's provenance in the current shape; null when none is stored or it can't be read. */ export function readReportMeta(userReportId: string): FlagshipReportMeta | null { - return readAllMeta()[userReportId] ?? null; + const result = readReportMetaResult(userReportId); + return result?.ok ? result.value : null; +} + +/** Drops the report's stored provenance; the other reports' stays as stored. */ +export function removeReportMeta(userReportId: string): void { + const all = stashWith(userReportId); + if (!all) { + return; + } + const { [userReportId]: _removed, ...rest } = all; + try { + localStorage.setItem(META_KEY, JSON.stringify(rest)); + } catch { + // It stays stored; the report still opens without it. + } } export interface RunFlagshipReportArgs { diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 93fe14623..9e0caa016 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -5,6 +5,7 @@ import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/society import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; import { populationLabel } from '@/components/flagship/reform/PopulationPicker'; import ReportCalculationError from '@/components/flagship/report/ReportCalculationError'; +import ReportDetailsError from '@/components/flagship/report/ReportDetailsError'; import ReportView from '@/components/flagship/report/ReportView'; import RunProgress from '@/components/flagship/report/RunProgress'; import { ReportUnresolvable } from '@/components/flagship/ReportComputing'; @@ -16,6 +17,7 @@ import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { isApiReportId, useFlagshipReport } from '@/hooks/useFlagshipReport'; import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot'; import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; +import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; import { clearDraftAfterRun, loadProvisionsIntoDraft } from '@/libs/draftReform'; import { provenanceFromPolicy } from '@/libs/flagship/reportProvenance'; import { readReportMeta } from '@/libs/flagship/runReport'; @@ -53,6 +55,8 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep (parameters ? provenanceFromPolicy(reformPolicy, parameters, report?.label) : null); const calcStatus = useCalculationStatus(report?.id || '', 'report'); + // Stored provenance that can't be read: readReportMeta answers none for it. + const unreadableMeta = useUnreadableReportMeta(userReportId); // The run handed its draft over to this report; Build starts fresh. useEffect(() => { @@ -127,6 +131,18 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep
{main}
); + // Unreadable stored provenance says so, until the report is opened without it. + if (unreadableMeta.error) { + return layout( + + + {title} + + + + ); + } + if (!output || isLoading || (!meta && !!reformPolicy)) { if (unresolvable) { return layout( diff --git a/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts b/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts new file mode 100644 index 000000000..46d7012e8 --- /dev/null +++ b/app/src/tests/fixtures/libs/flagship/reportSchemaMocks.ts @@ -0,0 +1,361 @@ +/** + * Report records and provenance in every shape the app has stored, from + * git history (`git log -p` on runReport.ts, flagshipReportStore.ts and + * calculator-app/src/db/serialize.ts), with what each reads as today. + */ +import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import type { ReportMetaFields } from '@/libs/flagship/reportSchema'; +import { validationToWire } from '@/libs/flagship/reportValidation'; +import type { FlagshipReportMeta, RunReportProvision } from '@/libs/flagship/runReport'; +import { + API_REPORT_ID, + CENTRAL_REFORM_ID, + CENTRAL_REPORT_ID, +} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; +import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; + +export const REPORT_META_KEY = 'pe-flagship-report-meta'; +export const REPORTS_KEY = 'pe-flagship-reports'; + +/** The version this app stamps; a later one is "newer". */ +export const CURRENT_SCHEMA_VERSION = 2; +export const NEWER_SCHEMA_VERSION = 3; + +export const LOCAL_REPORT_ID = 'lfr-mfz1abcdef'; +export const OTHER_LOCAL_REPORT_ID = 'lfr-mfz2ghijkl'; +export const CREATED_AT = '2026-09-03T00:00:00.000Z'; + +export const CTC_PROVISION: RunReportProvision = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Amount', + unit: 'currency-USD', + baselineValue: 2200, + value: 2500, +}; + +export const DATED_CTC_PROVISION: RunReportProvision = { + ...CTC_PROVISION, + intervals: [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 2500 }, + { startDate: '2027-01-01', endDate: '2100-12-31', value: 3000 }, + ], +}; + +/** A value set from 2026 that grows with CPI-U after it, as Build's one-value editor saves it. */ +export const GROWING_PROVISION: RunReportProvision = { + path: 'gov.irs.deductions.standard.amount.SINGLE', + breadcrumb: 'IRS → Deductions → Standard deduction → Single', + unit: 'currency-USD', + baselineValue: 16100, + value: 18000, + intervals: [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 18000 }, + { startDate: '2027-01-01', endDate: '2027-12-31', value: 18450 }, + ], + growth: 'gov.bls.cpi.cpi_u', + growthStart: 2027, + growthBase: 2026, + growthRoundTo: 50, + growthRound: 'down', +}; + +/* Provenance meta, stored under REPORT_META_KEY by report id. */ + +/** 3673cf94 "Build the run bridge": the first shape. */ +export const metaFromRunBridge = { + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [CTC_PROVISION], + createdAt: CREATED_AT, +}; + +/** ebb805b5 "Build: make the reform the page": provisions with dated changes. */ +export const metaWithIntervals = { ...metaFromRunBridge, provisions: [DATED_CTC_PROVISION] }; + +/** 42c22f3a "Build: let a value grow with inflation, …": a provision's growth. */ +const { + growthStart: _growthStart, + growthBase: _growthBase, + growthRoundTo: _growthRoundTo, + growthRound: _growthRound, + ...provisionWithGrowth +} = GROWING_PROVISION; +export const metaWithGrowth = { + ...metaWithIntervals, + provisions: [DATED_CTC_PROVISION, provisionWithGrowth], +}; + +/** e02a17ef "Build: … set when and how they grow and round": its timing and rounding. */ +export const metaWithGrowthTiming = { + ...metaWithIntervals, + provisions: [DATED_CTC_PROVISION, GROWING_PROVISION], +}; + +/** aaf850a6 "Build: choose the budget window before the run". */ +export const metaWithBudgetWindow = { + ...metaWithGrowthTiming, + budgetWindow: { startYear: 2026, windowSize: 10 }, +}; + +/** What a report compared the reform with, when not current law (PR #1265). */ +export const CUSTOM_BASELINE = { reformId: 'rf-plan-b', policyId: '88', label: 'Plan B' }; + +/** e0cee74a "Build: choose what the reform is compared with" (feat/v3-custom-baseline). */ +export const metaWithBaseline = { ...metaWithBudgetWindow, baseline: CUSTOM_BASELINE }; + +/** A sparse unversioned stash: what the defaults are for. */ +export const sparseMeta = { + provisions: [{ path: CTC_PROVISION.path, value: 2500 }], +}; + +export const sparseMetaExpected: FlagshipReportMeta = { + title: '', + sourceNote: '', + provisions: [ + // No baseline was stored, so none is made up. + { + path: CTC_PROVISION.path, + breadcrumb: CTC_PROVISION.path, + unit: null, + baselineValue: undefined, + value: 2500, + }, + ], + createdAt: '', +}; + +/** The current shape, as runFlagshipReport writes it. */ +export const currentMeta: FlagshipReportMeta = { + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [DATED_CTC_PROVISION, GROWING_PROVISION], + createdAt: CREATED_AT, + budgetWindow: { startYear: 2026, windowSize: 10 }, +}; + +export const historicMeta: Array<[string, unknown, ReportMetaFields]> = [ + ['the run bridge', metaFromRunBridge, metaFromRunBridge], + ['provisions with dated changes', metaWithIntervals, metaWithIntervals], + ['a provision that grows', metaWithGrowth, metaWithGrowth], + ['when and how it grows and rounds', metaWithGrowthTiming, metaWithGrowthTiming], + ['a budget window', metaWithBudgetWindow, metaWithBudgetWindow], + ['a custom baseline', metaWithBaseline, metaWithBaseline], + [ + 'a custom baseline without a saved reform', + { ...metaWithBaseline, baseline: { policyId: '88', label: 'Plan B' } }, + { ...metaWithBaseline, baseline: { reformId: null, policyId: '88', label: 'Plan B' } }, + ], +]; + +/* Report records, stored under REPORTS_KEY by the localStorage fallback. */ + +/** 6c23805e "Beat 1 of storage unification": the first record, with the shared user id. */ +export const recordFromStorageBeat = { + id: LOCAL_REPORT_ID, + userId: 'anonymous', + countryId: 'us', + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [CTC_PROVISION], + year: '2026', + createdAt: CREATED_AT, +}; + +/** b2a09635 "Adjust panel: … reform reconciliation": the saved reform it ran from. */ +export const recordWithReformLink = { ...recordFromStorageBeat, reformId: CENTRAL_REFORM_ID }; + +/** f8294f90 "Pin what each report's validation was matched against". */ +export const recordWithValidation = { + ...recordWithReformLink, + validation: mockReportValidationSnapshot, +}; + +/** ec399555 "Flagship: store reforms and reports without a user id". */ +const { userId: _userId, ...recordWithoutUserIdFields } = recordWithValidation; +export const recordWithoutUserId = recordWithoutUserIdFields; + +const recordBase: FlagshipReportRecord = { + id: LOCAL_REPORT_ID, + countryId: 'us', + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [CTC_PROVISION], + year: '2026', + createdAt: CREATED_AT, + reformId: null, + validation: null, +}; + +export const currentRecord: FlagshipReportRecord = { + ...recordBase, + reformId: CENTRAL_REFORM_ID, + validation: mockReportValidationSnapshot, +}; + +export const historicRecords: Array<[string, unknown, FlagshipReportRecord]> = [ + ['the first record, with the shared user id', recordFromStorageBeat, recordBase], + ['a reform link', recordWithReformLink, { ...recordBase, reformId: CENTRAL_REFORM_ID }], + ['a validation pin', recordWithValidation, currentRecord], + ['no user id', recordWithoutUserId, currentRecord], +]; + +/* Central rows, as the calculator-app routes serve them (snake_case). */ + +const CTC_PROVISION_WIRE = { + path: CTC_PROVISION.path, + breadcrumb: CTC_PROVISION.breadcrumb, + unit: CTC_PROVISION.unit, + baseline_value: CTC_PROVISION.baselineValue, + value: CTC_PROVISION.value, +}; + +/** 6c23805e: user_id, and no validation column yet. */ +export const rowFromStorageBeat = { + id: CENTRAL_REPORT_ID, + user_id: 'anonymous', + country_id: 'us', + api_report_id: API_REPORT_ID, + title: 'CTC to $2,500', + source_note: 'Hand-built draft', + provisions: [CTC_PROVISION_WIRE], + reform_id: null, + year: '2026', + created_at: CREATED_AT, +}; + +/** f8294f90: the validation column. */ +export const rowWithValidation = { + ...rowFromStorageBeat, + reform_id: CENTRAL_REFORM_ID, + validation: validationToWire(mockReportValidationSnapshot), +}; + +/** ec399555: no user_id; today's row. */ +const { user_id: _userIdColumn, ...rowWithoutUserIdFields } = rowWithValidation; +export const rowWithoutUserId = rowWithoutUserIdFields; + +const rowRecordBase: FlagshipReportRecord = { ...recordBase, id: CENTRAL_REPORT_ID }; + +export const historicRows: Array<[string, unknown, FlagshipReportRecord]> = [ + ['the first row, without a validation column', rowFromStorageBeat, rowRecordBase], + [ + 'a validation column', + rowWithValidation, + { ...rowRecordBase, reformId: CENTRAL_REFORM_ID, validation: mockReportValidationSnapshot }, + ], + [ + 'no user_id', + rowWithoutUserId, + { ...rowRecordBase, reformId: CENTRAL_REFORM_ID, validation: mockReportValidationSnapshot }, + ], +]; + +/* Unreadable: [name, stored, error code, offending field]. */ + +export const malformedMeta: Array<[string, unknown, string, string | undefined]> = [ + ['a string', 'CTC to $2,500', 'not-an-object', undefined], + ['an array', [metaFromRunBridge], 'not-an-object', undefined], + ['null', null, 'not-an-object', undefined], + ['no provisions', { title: 'CTC' }, 'invalid-field', 'provisions'], + ['provisions as an object', { provisions: { 0: CTC_PROVISION } }, 'invalid-field', 'provisions'], + [ + 'a provision without a path', + { provisions: [{ ...CTC_PROVISION, path: '' }] }, + 'invalid-field', + 'provisions[0].path', + ], + ['a provision that is not an object', { provisions: [42] }, 'invalid-field', 'provisions[0]'], + [ + 'a numeric unit', + { provisions: [{ ...CTC_PROVISION, unit: 7 }] }, + 'invalid-field', + 'provisions[0].unit', + ], + [ + 'a dated change without a start', + { + provisions: [{ ...CTC_PROVISION, intervals: [{ endDate: '2026-12-31', value: 2500 }] }], + }, + 'invalid-field', + 'provisions[0].intervals[0].startDate', + ], + [ + 'a numeric growth', + { provisions: [{ ...GROWING_PROVISION, growth: 0.03 }] }, + 'invalid-field', + 'provisions[0].growth', + ], + [ + 'a growth start as text', + { provisions: [{ ...GROWING_PROVISION, growthStart: '2027' }] }, + 'invalid-field', + 'provisions[0].growthStart', + ], + [ + 'an unknown rounding', + { provisions: [{ ...GROWING_PROVISION, growthRound: 'sideways' }] }, + 'invalid-field', + 'provisions[0].growthRound', + ], + ['a numeric title', { ...metaFromRunBridge, title: 42 }, 'invalid-field', 'title'], + [ + 'a budget window of no years', + { ...metaFromRunBridge, budgetWindow: { startYear: 2026, windowSize: 0 } }, + 'invalid-field', + 'budgetWindow.windowSize', + ], + [ + 'a baseline without its policy', + { ...metaFromRunBridge, baseline: { reformId: null, label: 'Plan B' } }, + 'invalid-field', + 'baseline.policyId', + ], + [ + 'a baseline that is a name', + { ...metaFromRunBridge, baseline: 'Plan B' }, + 'invalid-field', + 'baseline', + ], + [ + 'a text schema version', + { ...metaFromRunBridge, schemaVersion: '2' }, + 'invalid-field', + 'schemaVersion', + ], + [ + 'a newer schema version', + { ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }, + 'newer-version', + 'schemaVersion', + ], +]; + +export const malformedRecords: Array<[string, unknown, string, string | undefined]> = [ + ['no id', { ...recordWithoutUserId, id: undefined }, 'invalid-field', 'id'], + ['an unknown country', { ...recordWithoutUserId, countryId: 'fr' }, 'invalid-field', 'countryId'], + ['no API report id', { ...recordWithoutUserId, apiReportId: '' }, 'invalid-field', 'apiReportId'], + ['a numeric year', { ...recordWithoutUserId, year: 2026 }, 'invalid-field', 'year'], + ['a numeric reform id', { ...recordWithoutUserId, reformId: 7 }, 'invalid-field', 'reformId'], + [ + 'a newer schema version', + { ...recordWithoutUserId, schemaVersion: NEWER_SCHEMA_VERSION }, + 'newer-version', + 'schemaVersion', + ], +]; + +/** A record whose validation pin is damaged: the record still reads, without the pin. */ +export const recordWithDamagedPin = { + ...recordWithoutUserId, + validation: { matchedAt: 7, calibration: 'release' }, +}; + +/** A record a newer version of the app saved, with a field this one doesn't know. */ +export const recordFromNewerVersion = { + ...recordWithoutUserId, + id: OTHER_LOCAL_REPORT_ID, + schemaVersion: NEWER_SCHEMA_VERSION, + futureField: { kept: true }, +}; diff --git a/app/src/tests/integration/api/reportRoutes.test.ts b/app/src/tests/integration/api/reportRoutes.test.ts index 2ed598eec..64bdcd46e 100644 --- a/app/src/tests/integration/api/reportRoutes.test.ts +++ b/app/src/tests/integration/api/reportRoutes.test.ts @@ -136,6 +136,12 @@ describe('POST /api/reports', () => { ['invalid country', { ...VALID_PAYLOAD, country_id: 'fr' }], ['missing api_report_id', { ...VALID_PAYLOAD, api_report_id: undefined }], ['provisions not an array', { ...VALID_PAYLOAD, provisions: 'oops' }], + ['a provision that is not an object', { ...VALID_PAYLOAD, provisions: ['oops'] }], + ['a provision without a path', { ...VALID_PAYLOAD, provisions: [{ breadcrumb: 'CTC' }] }], + [ + 'a provision with a numeric unit', + { ...VALID_PAYLOAD, provisions: [{ ...VALID_PAYLOAD.provisions[0], unit: 7 }] }, + ], ['missing year', { ...VALID_PAYLOAD, year: undefined }], ['a local fallback reform_id', { ...VALID_PAYLOAD, reform_id: 'rf-mfz1abcd' }], ['a non-string reform_id', { ...VALID_PAYLOAD, reform_id: 42 }], @@ -144,6 +150,27 @@ describe('POST /api/reports', () => { expect(response.status).toBe(400); }); + it('given a provision without a path then the error names the field', async () => { + const response = await createReport( + postRequest({ ...VALID_PAYLOAD, provisions: [{ breadcrumb: 'CTC' }] }) + ); + + expect(await response.json()).toEqual({ + error: 'provisions[0].path must be a non-empty string', + }); + }); + + it('given provisions without units or baselines then the record is stored', async () => { + const { path, breadcrumb, value } = VALID_PAYLOAD.provisions[0]; + + const created = await createAndParse({ + ...VALID_PAYLOAD, + provisions: [{ path, breadcrumb, value }], + }); + + expect(created.provisions).toEqual([{ path, breadcrumb, value }]); + }); + it('given a central reform_id then it is stored on the record', async () => { const created = await createAndParse({ ...VALID_PAYLOAD, reform_id: CENTRAL_REFORM_ID }); @@ -275,6 +302,23 @@ describe('GET/PATCH /api/reports/:id', () => { expect((await reread.json()).validation).toBeNull(); }); + it('given a pin the report schema can not read then PATCH returns 400 and nothing is pinned', async () => { + const created = await createAndParse(); + const damaged = { ...VALIDATION_BODY.validation, scorecard: { programs: 'ctc_refund' } }; + + const response = await patchReport( + patchRequest({ validation: damaged }, created.edit_token), + itemContext(created.id) + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'validation.scorecard.programs must be an array', + }); + const reread = await getReport(new Request('http://test'), itemContext(created.id)); + expect((await reread.json()).validation).toBeNull(); + }); + it('given an unknown record then PATCH returns 404', async () => { const response = await patchReport( patchRequest(VALIDATION_BODY, 'a'.repeat(64)), diff --git a/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts b/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts new file mode 100644 index 000000000..24f30cf9c --- /dev/null +++ b/app/src/tests/unit/api/flagshipReportStoreSchema.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ApiFlagshipReportStore, LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; +import { ReportSchemaError } from '@/libs/flagship/reportSchema'; +import { mockJsonResponse, mockNewReport } from '@/tests/fixtures/api/flagshipStoreMocks'; +import { + CENTRAL_REPORT_ID, + OWNED_REPORTS_KEY, + ownedReport, + seedOwnedItems, +} from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; +import { + CURRENT_SCHEMA_VERSION, + currentRecord, + historicRecords, + LOCAL_REPORT_ID, + recordFromNewerVersion, + recordWithoutUserId, + REPORTS_KEY, + rowWithoutUserId, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; +import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; + +const UNREADABLE_ID = 'lfr-unreadable'; +const unreadableEntry = { ...recordWithoutUserId, id: UNREADABLE_ID, provisions: 'oops' }; + +function storedEntries(): any[] { + return JSON.parse(localStorage.getItem(REPORTS_KEY) ?? '[]'); +} + +function seedEntries(entries: unknown[]): void { + localStorage.setItem(REPORTS_KEY, JSON.stringify(entries)); +} + +describe('LocalStorageFlagshipReportStore and the report schema', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given a new report then the stored entry is stamped with the current version', async () => { + await new LocalStorageFlagshipReportStore().create(mockNewReport); + + expect(storedEntries()[0].schemaVersion).toBe(CURRENT_SCHEMA_VERSION); + }); + + test('given records in every earlier shape then each is listed in the current shape', async () => { + seedEntries( + historicRecords.map(([_name, stored], index) => ({ + ...(stored as object), + id: `lfr-${index}`, + })) + ); + + const mine = await new LocalStorageFlagshipReportStore().findMine('us'); + + expect(mine).toEqual( + historicRecords.map(([_name, _stored, expected], index) => ({ + ...expected, + id: `lfr-${index}`, + })) + ); + }); + + test('given an unreadable entry then the list leaves it out', async () => { + seedEntries([unreadableEntry, recordWithoutUserId]); + + const mine = await new LocalStorageFlagshipReportStore().findMine(); + + expect(mine).toEqual([currentRecord]); + }); + + test('given an unreadable entry then finding it throws a ReportSchemaError', async () => { + seedEntries([unreadableEntry]); + + await expect(new LocalStorageFlagshipReportStore().findById(UNREADABLE_ID)).rejects.toThrow( + ReportSchemaError + ); + }); + + test('given an unversioned record then pinning a validation writes it in the current shape', async () => { + seedEntries([recordWithoutUserId]); + + await new LocalStorageFlagshipReportStore().saveValidation( + LOCAL_REPORT_ID, + mockReportValidationSnapshot + ); + + expect(storedEntries()[0]).toEqual({ + ...currentRecord, + validation: mockReportValidationSnapshot, + schemaVersion: CURRENT_SCHEMA_VERSION, + }); + }); + + test('given entries this version can not read then writes keep them as stored', async () => { + seedEntries([recordFromNewerVersion, unreadableEntry]); + + await new LocalStorageFlagshipReportStore().create(mockNewReport); + + expect(storedEntries().slice(1)).toEqual([recordFromNewerVersion, unreadableEntry]); + }); + + test('given a record a newer version saved then pinning it fails and leaves it as stored', async () => { + seedEntries([recordFromNewerVersion]); + + await expect( + new LocalStorageFlagshipReportStore().saveValidation( + recordFromNewerVersion.id, + mockReportValidationSnapshot + ) + ).rejects.toThrow(ReportSchemaError); + expect(storedEntries()).toEqual([recordFromNewerVersion]); + }); +}); + +describe('ApiFlagshipReportStore and the report schema', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + localStorage.clear(); + seedOwnedItems(OWNED_REPORTS_KEY, [ownedReport]); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given a central row then it reads in the current shape', async () => { + fetchMock.mockResolvedValue(mockJsonResponse(rowWithoutUserId)); + + const record = await new ApiFlagshipReportStore().findById(CENTRAL_REPORT_ID); + + expect(record).toEqual({ ...currentRecord, id: CENTRAL_REPORT_ID }); + }); + + test('given an unreadable row then the list leaves it out', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse([rowWithoutUserId, { ...rowWithoutUserId, country_id: 'fr' }]) + ); + + const mine = await new ApiFlagshipReportStore().findMine(); + + expect(mine.map((record) => record.id)).toEqual([CENTRAL_REPORT_ID]); + }); + + test('given an unreadable row then reading it throws a ReportSchemaError', async () => { + fetchMock.mockResolvedValue(mockJsonResponse({ ...rowWithoutUserId, provisions: 'oops' })); + + await expect(new ApiFlagshipReportStore().findById(CENTRAL_REPORT_ID)).rejects.toThrow( + ReportSchemaError + ); + }); +}); diff --git a/app/src/tests/unit/components/flagship/ReportEditInBuild.test.tsx b/app/src/tests/unit/components/flagship/ReportEditInBuild.test.tsx new file mode 100644 index 000000000..52bebb09a --- /dev/null +++ b/app/src/tests/unit/components/flagship/ReportEditInBuild.test.tsx @@ -0,0 +1,70 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import ReportAdjustPanel from '@/components/flagship/ReportAdjustPanel'; +import { clearDraftReform, getDraftReform } from '@/libs/draftReform'; +import { readReportMeta, saveReportMeta } from '@/libs/flagship/runReport'; +import { currentMeta, GROWING_PROVISION } from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; + +// A report's stored provenance, read back through the report schema, +// going back to Build from the Adjust panel (ReportAdjustPanel.test.tsx +// covers the panel with provisions handed to it). + +const REPORT_ID = '4512'; + +vi.mock('@/hooks/useRunFlagshipReport', () => ({ + useRunFlagshipReport: () => ({ run: vi.fn(), isRunning: false, error: null }), +})); + +const mockPush = vi.fn(); + +vi.mock('@/contexts/NavigationContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAppNavigate: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn() }) }; +}); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useParams: () => ({ countryId: 'us' }) }; +}); + +describe('ReportAdjustPanel with stored provenance', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + clearDraftReform(); + }); + + test('given a stored provision that grows then Edit in Build keeps its growth', async () => { + const user = userEvent.setup(); + saveReportMeta(REPORT_ID, currentMeta); + const meta = readReportMeta(REPORT_ID)!; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + ); + + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); + await user.click(screen.getByRole('button', { name: /edit in build/i })); + + expect(mockPush).toHaveBeenCalledWith('/us/build'); + const growing = getDraftReform()?.provisions.find( + (provision) => provision.path === GROWING_PROVISION.path + ); + expect(growing).toMatchObject({ + growth: GROWING_PROVISION.growth, + growthStart: GROWING_PROVISION.growthStart, + growthBase: GROWING_PROVISION.growthBase, + growthRoundTo: GROWING_PROVISION.growthRoundTo, + growthRound: GROWING_PROVISION.growthRound, + intervals: GROWING_PROVISION.intervals, + }); + }); +}); diff --git a/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx b/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx new file mode 100644 index 000000000..cfabeed0b --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/ReportDetailsError.test.tsx @@ -0,0 +1,40 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import ReportDetailsError from '@/components/flagship/report/ReportDetailsError'; +import { ReportSchemaError } from '@/libs/flagship/reportSchema'; + +const DAMAGED = new ReportSchemaError('invalid-field', 'provisions must be an array', 'provisions'); +const NEWER = new ReportSchemaError( + 'newer-version', + 'Saved with report schema 3; this version reads up to 2', + 'schemaVersion' +); + +describe('ReportDetailsError', () => { + test('given damaged details then it says so, with the reason kept for debugging', () => { + render(); + + expect( + screen.getByRole('heading', { name: "This report's saved details can't be read" }) + ).toBeInTheDocument(); + expect(screen.getByText('They are damaged in this browser.')).toBeInTheDocument(); + expect(screen.getByText('provisions must be an array')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Reload' })).not.toBeInTheDocument(); + }); + + test('given details a newer version saved then reloading is offered', () => { + render(); + + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument(); + }); + + test('given the user opens without the details then the callback runs', async () => { + const user = userEvent.setup(); + const onOpenWithout = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open without saved details' })); + + expect(onOpenWithout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts b/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts index 2190c1b07..503c59ae2 100644 --- a/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts +++ b/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts @@ -74,7 +74,8 @@ describe('useReportValidationSnapshot', () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(result.current.snapshot).toBeNull(); const stored = await new LocalStorageFlagshipReportStore().findById(record.id); - expect(stored?.validation).toBeUndefined(); + // No pin reads as null, the report schema's default. + expect(stored?.validation).toBeNull(); }); test('given no store record for the report then nothing is pinned', async () => { diff --git a/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts b/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts new file mode 100644 index 000000000..04c757bcb --- /dev/null +++ b/app/src/tests/unit/hooks/useUnreadableReportMeta.test.ts @@ -0,0 +1,102 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; +import { ReportSchemaError } from '@/libs/flagship/reportSchema'; +import { readReportMeta } from '@/libs/flagship/runReport'; +import { + metaFromRunBridge, + NEWER_SCHEMA_VERSION, + REPORT_META_KEY, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; + +const REPORT_ID = '4512'; +const OTHER_REPORT_ID = '4513'; +const DAMAGED_META = { provisions: 'oops' }; +const NEWER_META = { ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }; + +function seedMeta(byReportId: Record): void { + localStorage.setItem(REPORT_META_KEY, JSON.stringify(byReportId)); +} + +function storedMeta(): Record { + return JSON.parse(localStorage.getItem(REPORT_META_KEY) ?? '{}'); +} + +describe('useUnreadableReportMeta', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given readable provenance then there is no error', () => { + seedMeta({ [REPORT_ID]: metaFromRunBridge }); + + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + expect(result.current.error).toBeNull(); + }); + + test('given no stored provenance then there is no error', () => { + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + expect(result.current.error).toBeNull(); + }); + + test('given unreadable provenance then the error says why, and the plain read is none', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META }); + + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + expect(result.current.error).toBeInstanceOf(ReportSchemaError); + expect(result.current.error?.field).toBe('provisions'); + expect(readReportMeta(REPORT_ID)).toBeNull(); + }); + + test('given damaged provenance set aside then it is removed, and the next visit does not ask', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META, [OTHER_REPORT_ID]: metaFromRunBridge }); + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + act(() => result.current.setAside()); + + expect(result.current.error).toBeNull(); + expect(storedMeta()).toEqual({ [OTHER_REPORT_ID]: metaFromRunBridge }); + expect(renderHook(() => useUnreadableReportMeta(REPORT_ID)).result.current.error).toBeNull(); + }); + + test('given provenance a newer version saved set aside then it stays stored for that version', () => { + seedMeta({ [REPORT_ID]: NEWER_META }); + const { result } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + + act(() => result.current.setAside()); + + expect(result.current.error).toBeNull(); + expect(storedMeta()[REPORT_ID]).toEqual(NEWER_META); + expect(renderHook(() => useUnreadableReportMeta(REPORT_ID)).result.current.error?.code).toBe( + 'newer-version' + ); + }); + + test('given re-renders then the stored provenance is read once', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META }); + const getItem = vi.spyOn(localStorage, 'getItem'); + const { rerender } = renderHook(() => useUnreadableReportMeta(REPORT_ID)); + const reads = getItem.mock.calls.length; + + rerender(); + rerender(); + + expect(getItem.mock.calls.length).toBe(reads); + getItem.mockRestore(); + }); + + test('given a set-aside report then another report still answers its own error', () => { + seedMeta({ [REPORT_ID]: DAMAGED_META, [OTHER_REPORT_ID]: DAMAGED_META }); + const { result, rerender } = renderHook(({ id }) => useUnreadableReportMeta(id), { + initialProps: { id: REPORT_ID }, + }); + + act(() => result.current.setAside()); + rerender({ id: OTHER_REPORT_ID }); + + expect(result.current.error).toBeInstanceOf(ReportSchemaError); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts b/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts new file mode 100644 index 000000000..bf37a55d8 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/reportMetaStorage.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { getDraftReform, loadProvisionsIntoDraft } from '@/libs/draftReform'; +import { + readReportMeta, + readReportMetaResult, + removeReportMeta, + saveReportMeta, +} from '@/libs/flagship/runReport'; +import { + CURRENT_SCHEMA_VERSION, + currentMeta, + CUSTOM_BASELINE, + GROWING_PROVISION, + metaFromRunBridge, + metaWithBudgetWindow, + NEWER_SCHEMA_VERSION, + REPORT_META_KEY, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; + +const REPORT_ID = '4512'; +const OTHER_REPORT_ID = '4513'; + +function seedMeta(byReportId: Record): void { + localStorage.setItem(REPORT_META_KEY, JSON.stringify(byReportId)); +} + +function storedMeta(): Record { + return JSON.parse(localStorage.getItem(REPORT_META_KEY) ?? '{}'); +} + +describe('report provenance in localStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given saved provenance then it is stamped with the current version', () => { + saveReportMeta(REPORT_ID, currentMeta); + + expect(storedMeta()[REPORT_ID]).toEqual({ + ...currentMeta, + schemaVersion: CURRENT_SCHEMA_VERSION, + }); + }); + + test('given saved provenance then it reads back unchanged', () => { + saveReportMeta(REPORT_ID, currentMeta); + + expect(readReportMeta(REPORT_ID)).toEqual(currentMeta); + }); + + test('given provenance an older version stashed then it reads in the current shape', () => { + seedMeta({ [REPORT_ID]: metaWithBudgetWindow }); + + expect(readReportMeta(REPORT_ID)).toEqual(metaWithBudgetWindow); + }); + + test('given no provenance for the report then there is no result', () => { + seedMeta({ [OTHER_REPORT_ID]: metaFromRunBridge }); + + expect(readReportMetaResult(REPORT_ID)).toBeNull(); + expect(readReportMetaResult('constructor')).toBeNull(); + }); + + test('given unreadable provenance then the result says why and the plain read is null', () => { + seedMeta({ [REPORT_ID]: { ...metaFromRunBridge, provisions: 'oops' } }); + + const result = readReportMetaResult(REPORT_ID); + + expect(result?.ok ? null : result?.error.field).toBe('provisions'); + expect(readReportMeta(REPORT_ID)).toBeNull(); + }); + + test('given provenance a newer version saved then saving another keeps it as stored', () => { + const newer = { ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }; + seedMeta({ [OTHER_REPORT_ID]: newer }); + + saveReportMeta(REPORT_ID, currentMeta); + + expect(storedMeta()[OTHER_REPORT_ID]).toEqual(newer); + }); + + test.each([ + ['a string', '4512'], + ['a list', [metaFromRunBridge]], + ['null', null], + ])('given a stash that is %s then no report reads provenance from it', (_name, stash) => { + localStorage.setItem(REPORT_META_KEY, JSON.stringify(stash)); + + expect(readReportMetaResult('0')).toBeNull(); + expect(readReportMeta('0')).toBeNull(); + }); + + test('given a saved provision that grows then Build gets it back with its growth', () => { + saveReportMeta(REPORT_ID, currentMeta); + + loadProvisionsIntoDraft('us', readReportMeta(REPORT_ID)!.provisions); + + expect(getDraftReform()?.provisions).toEqual(currentMeta.provisions); + expect(getDraftReform()?.provisions[1]).toMatchObject({ + growth: GROWING_PROVISION.growth, + growthStart: GROWING_PROVISION.growthStart, + growthBase: GROWING_PROVISION.growthBase, + growthRoundTo: GROWING_PROVISION.growthRoundTo, + growthRound: GROWING_PROVISION.growthRound, + }); + }); + + test('given provenance removed then only that report loses it', () => { + const damaged = { ...metaFromRunBridge, provisions: 'oops' }; + seedMeta({ [REPORT_ID]: damaged, [OTHER_REPORT_ID]: metaFromRunBridge }); + + removeReportMeta(REPORT_ID); + + expect(storedMeta()).toEqual({ [OTHER_REPORT_ID]: metaFromRunBridge }); + }); + + test('given no provenance for the report then removing it leaves the stash alone', () => { + seedMeta({ [OTHER_REPORT_ID]: metaFromRunBridge }); + + removeReportMeta(REPORT_ID); + + expect(storedMeta()).toEqual({ [OTHER_REPORT_ID]: metaFromRunBridge }); + }); + + test('given meta compared with a saved reform then the baseline reads back', () => { + const compared = { ...currentMeta, baseline: CUSTOM_BASELINE }; + + saveReportMeta(REPORT_ID, compared); + + expect(readReportMeta(REPORT_ID)).toEqual(compared); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/reportSchema.test.ts b/app/src/tests/unit/libs/flagship/reportSchema.test.ts new file mode 100644 index 000000000..6874c5e2f --- /dev/null +++ b/app/src/tests/unit/libs/flagship/reportSchema.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from 'vitest'; +import { + parseReportMeta, + parseReportRecord, + parseReportRow, + readableReports, + REPORT_SCHEMA_VERSION, + reportProvisionsWireError, + ReportSchemaError, + reportValidationWireError, + stampReportMeta, + stampReportRecord, + unwrapReport, +} from '@/libs/flagship/reportSchema'; +import { validationToWire } from '@/libs/flagship/reportValidation'; +import { CENTRAL_REPORT_ID } from '@/tests/fixtures/libs/flagship/ownedItemsMocks'; +import { + CTC_PROVISION, + CURRENT_SCHEMA_VERSION, + currentMeta, + currentRecord, + GROWING_PROVISION, + historicMeta, + historicRecords, + historicRows, + malformedMeta, + malformedRecords, + recordFromNewerVersion, + recordWithDamagedPin, + recordWithoutUserId, + rowWithoutUserId, + sparseMeta, + sparseMetaExpected, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; +import { + MATCHED_AT, + mockReportValidationSnapshot, +} from '@/tests/fixtures/libs/flagship/reportValidationMocks'; + +/** What a stored value reads back as after a trip through localStorage. */ +function throughStorage(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)); +} + +describe('REPORT_SCHEMA_VERSION', () => { + test('given the current schema then writes stamp version 2', () => { + expect(REPORT_SCHEMA_VERSION).toBe(CURRENT_SCHEMA_VERSION); + }); +}); + +describe('parseReportMeta', () => { + test.each(historicMeta)( + 'given unversioned meta from %s then it reads as today', + (_n, stored, expected) => { + const result = parseReportMeta(throughStorage(stored)); + + expect(result).toEqual({ ok: true, value: expected }); + } + ); + + test('given sparse unversioned meta then the defaults fill what it lacks', () => { + expect(parseReportMeta(sparseMeta)).toEqual({ ok: true, value: sparseMetaExpected }); + }); + + test('given meta written today then it round trips unchanged', () => { + const stored = throughStorage(stampReportMeta(currentMeta)); + + expect(parseReportMeta(stored)).toEqual({ ok: true, value: currentMeta }); + }); + + test('given a provision that grows then its growth, timing and rounding read back', () => { + const result = parseReportMeta( + throughStorage({ ...currentMeta, provisions: [GROWING_PROVISION] }) + ); + + expect(result.ok && result.value.provisions).toEqual([GROWING_PROVISION]); + }); + + test('given meta with fields no version wrote then they are dropped', () => { + const result = parseReportMeta({ ...currentMeta, stray: 'value' }); + + expect(result.ok && result.value).not.toHaveProperty('stray'); + }); + + test.each(malformedMeta)( + 'given meta with %s then it reads as a typed error', + (_name, stored, code, field) => { + const result = parseReportMeta(stored); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(ReportSchemaError); + expect(result.error.code).toBe(code); + expect(result.error.field).toBe(field); + } + } + ); + + test.each([undefined, 0, true, {}, { provisions: [null] }, { provisions: [{ path: 1 }] }])( + 'given %j then parsing answers instead of throwing', + (stored) => { + expect(() => parseReportMeta(stored)).not.toThrow(); + } + ); +}); + +describe('parseReportRecord', () => { + test.each(historicRecords)( + 'given a record from %s then it reads as today', + (_name, stored, expected) => { + expect(parseReportRecord(throughStorage(stored))).toEqual({ ok: true, value: expected }); + } + ); + + test('given a record with the shared user id then the id is gone', () => { + const result = parseReportRecord(historicRecords[0][1]); + + expect(result.ok && result.value).not.toHaveProperty('userId'); + }); + + test('given a record written today then it round trips unchanged', () => { + const stored = throughStorage(stampReportRecord(currentRecord)); + + expect(parseReportRecord(stored)).toEqual({ ok: true, value: currentRecord }); + }); + + test('given a record whose provision grows then its growth reads back', () => { + const stored = throughStorage( + stampReportRecord({ ...currentRecord, provisions: [GROWING_PROVISION] }) + ); + + expect(parseReportRecord(stored)).toEqual({ + ok: true, + value: { ...currentRecord, provisions: [GROWING_PROVISION] }, + }); + }); + + test('given a damaged validation pin then the record reads without it', () => { + const result = parseReportRecord(recordWithDamagedPin); + + expect(result).toEqual({ ok: true, value: { ...currentRecord, validation: null } }); + }); + + test.each(malformedRecords)( + 'given a record with %s then it reads as a typed error', + (_name, stored, code, field) => { + const result = parseReportRecord(stored); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(code); + expect(result.error.field).toBe(field); + } + } + ); + + test('given a record a newer version saved then it is not read as this version', () => { + const result = parseReportRecord(recordFromNewerVersion); + + expect(result.ok ? null : result.error.code).toBe('newer-version'); + }); +}); + +describe('parseReportRow', () => { + test.each(historicRows)( + 'given a central row with %s then it reads as a record', + (_n, row, expected) => { + expect(parseReportRow(row)).toEqual({ ok: true, value: expected }); + } + ); + + test('given a row with provisions that are not a list then it reads as a typed error', () => { + const result = parseReportRow({ ...rowWithoutUserId, provisions: 'oops' }); + + expect(result.ok ? null : result.error.field).toBe('provisions'); + }); + + test('given a row whose pin is too damaged to convert then it reads without the pin', () => { + const row = { + ...rowWithoutUserId, + validation: { matched_at: MATCHED_AT, calibration: { matches: 'x' } }, + }; + + expect(parseReportRow(row)).toEqual({ + ok: true, + value: { ...currentRecord, id: CENTRAL_REPORT_ID, validation: null }, + }); + }); + + test('given a row that is not an object then it reads as a typed error', () => { + const result = parseReportRow('row'); + + expect(result.ok ? null : result.error.code).toBe('not-an-object'); + }); +}); + +describe('reportProvisionsWireError', () => { + test('given provisions as the client sends them then there is no error', () => { + const wire = [ + { + path: CTC_PROVISION.path, + breadcrumb: CTC_PROVISION.breadcrumb, + unit: CTC_PROVISION.unit, + baseline_value: CTC_PROVISION.baselineValue, + value: CTC_PROVISION.value, + }, + ]; + + expect(reportProvisionsWireError(wire)).toBeNull(); + }); + + test('given a provision without a path then the error names it', () => { + expect(reportProvisionsWireError([{ breadcrumb: 'CTC', value: 1 }])).toBe( + 'provisions[0].path must be a non-empty string' + ); + }); + + test('given a provision that is not an object then the error names it', () => { + expect(reportProvisionsWireError(['CTC'])).toBe('provisions[0] must be an object'); + }); +}); + +describe('reportValidationWireError', () => { + test('given a pin as the client sends it then there is no error', () => { + expect(reportValidationWireError(validationToWire(mockReportValidationSnapshot))).toBeNull(); + }); + + test.each([ + ['no pin', undefined, 'validation must be a snapshot with matched_at'], + [ + 'scorecard programs that are not a list', + { ...validationToWire(mockReportValidationSnapshot), scorecard: { programs: 'ctc' } }, + 'validation.scorecard.programs must be an array', + ], + [ + 'a match depth that is not a number', + { + ...validationToWire(mockReportValidationSnapshot), + calibration: { geography: 'US', reached_count: 1, matches: [{ depth: 'deep' }] }, + }, + 'validation.calibration.matches[0].depth must be a number', + ], + ])('given %s then the error names it', (_name, wire, message) => { + expect(reportValidationWireError(wire)).toBe(message); + }); +}); + +describe('unwrapReport and readableReports', () => { + test('given an unreadable record then unwrapping throws its ReportSchemaError', () => { + expect(() => unwrapReport(parseReportRecord({ ...recordWithoutUserId, id: 7 }))).toThrow( + ReportSchemaError + ); + }); + + test('given readable and unreadable records then only the readable are kept, in order', () => { + const results = [recordWithoutUserId, 'junk', recordFromNewerVersion].map(parseReportRecord); + + expect(readableReports(results)).toEqual([currentRecord]); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx new file mode 100644 index 000000000..de78cfa57 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportUnreadableMeta.test.tsx @@ -0,0 +1,144 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { + metaFromRunBridge, + NEWER_SCHEMA_VERSION, + REPORT_META_KEY, +} from '@/tests/fixtures/libs/flagship/reportSchemaMocks'; + +// The report page reading its stored provenance through the report +// schema, from real localStorage (Report.test.tsx stubs the read). + +const REPORT_ID = 'r1'; +const UNREADABLE_HEADING = "This report's saved details can't be read"; +const WAITING_TEXT = 'Calculating the nationwide results'; + +vi.mock('@/hooks/useFlagshipReport', () => ({ + isApiReportId: () => true, + useFlagshipReport: () => ({ + report: { id: 'r1', countryId: 'us', year: '2026', label: 'Test Reform' }, + simulations: [ + { id: 's1', populationId: 'us', policyId: '2' }, + { id: 's2', populationId: 'us', policyId: '9' }, + ], + policies: [], + isLoading: false, + }), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => ({ + status: 'pending', + isComplete: false, + isError: false, + progress: 20, + result: null, + }), +})); + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: () => undefined, +})); + +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); + +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal()), + useCalibrationMatches: () => null, +})); + +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal()), + useModelTrackRecord: () => null, +})); + +vi.mock('@/utils/reportSimulations', () => ({ + allSimulationsLoaded: () => true, +})); + +function seedMeta(stored: unknown): void { + localStorage.setItem(REPORT_META_KEY, JSON.stringify({ [REPORT_ID]: stored })); +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +describe('FlagshipReportPage with stored provenance it cannot read', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('given damaged provenance then the page says so instead of loading', () => { + seedMeta({ ...metaFromRunBridge, provisions: 'oops' }); + + renderPage(); + + expect(screen.getByRole('heading', { name: UNREADABLE_HEADING })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Test Reform' })).toBeInTheDocument(); + expect(screen.queryByText(WAITING_TEXT)).not.toBeInTheDocument(); + }); + + test('given damaged provenance opened without it then the report carries on', async () => { + const user = userEvent.setup(); + seedMeta({ ...metaFromRunBridge, provisions: 'oops' }); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Open without saved details' })); + + expect(screen.queryByRole('heading', { name: UNREADABLE_HEADING })).not.toBeInTheDocument(); + expect(screen.getByText(WAITING_TEXT)).toBeInTheDocument(); + }); + + test('given damaged provenance opened without it then the next visit does not ask', async () => { + const user = userEvent.setup(); + seedMeta({ ...metaFromRunBridge, provisions: 'oops' }); + const { unmount } = renderPage(); + await user.click(screen.getByRole('button', { name: 'Open without saved details' })); + unmount(); + + renderPage(); + + expect(screen.queryByRole('heading', { name: UNREADABLE_HEADING })).not.toBeInTheDocument(); + expect(screen.getByText(WAITING_TEXT)).toBeInTheDocument(); + }); + + test('given provenance a newer version saved opened without it then the next visit asks again', async () => { + const user = userEvent.setup(); + seedMeta({ ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }); + const { unmount } = renderPage(); + await user.click(screen.getByRole('button', { name: 'Open without saved details' })); + unmount(); + + renderPage(); + + expect(screen.getByRole('heading', { name: UNREADABLE_HEADING })).toBeInTheDocument(); + }); + + test('given provenance a newer version saved then reloading is offered', () => { + seedMeta({ ...metaFromRunBridge, schemaVersion: NEWER_SCHEMA_VERSION }); + + renderPage(); + + expect(screen.getByRole('heading', { name: UNREADABLE_HEADING })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument(); + }); + + test('given provenance from before the schema then the report opens as usual', () => { + seedMeta(metaFromRunBridge); + + renderPage(); + + expect(screen.queryByRole('heading', { name: UNREADABLE_HEADING })).not.toBeInTheDocument(); + expect(screen.getByText(WAITING_TEXT)).toBeInTheDocument(); + }); +}); diff --git a/calculator-app/src/app/api/reports/[reportId]/route.ts b/calculator-app/src/app/api/reports/[reportId]/route.ts index 9b4cb36dd..523b6b08c 100644 --- a/calculator-app/src/app/api/reports/[reportId]/route.ts +++ b/calculator-app/src/app/api/reports/[reportId]/route.ts @@ -3,6 +3,7 @@ import { isFlagshipApiEnabled, } from "@/libs/flagship/apiGate"; import { eq } from "drizzle-orm"; +import { reportValidationWireError } from "@/libs/flagship/reportSchema"; import { getDb, isDbConfigured } from "../../../../db"; import { readJsonBody } from "../../../../db/limits"; import { guardSchema } from "../../../../db/errors"; @@ -95,6 +96,11 @@ async function handlePATCH( if (!isValidationPayload(payload?.validation)) { return json({ error: "validation snapshot is required" }, 400); } + // The snapshot as the client's report schema reads it back. + const validationError = reportValidationWireError(payload.validation); + if (validationError) { + return json({ error: validationError }, 400); + } const [row] = await getDb() .update(reports) diff --git a/calculator-app/src/app/api/reports/route.ts b/calculator-app/src/app/api/reports/route.ts index bda40cde5..da804e012 100644 --- a/calculator-app/src/app/api/reports/route.ts +++ b/calculator-app/src/app/api/reports/route.ts @@ -4,6 +4,7 @@ import { } from "@/libs/flagship/apiGate"; import { desc, inArray } from "drizzle-orm"; import { countryIds } from "@/libs/countries"; +import { reportProvisionsWireError } from "@/libs/flagship/reportSchema"; import { getDb, isDbConfigured } from "../../../db"; import { invalidText, @@ -59,6 +60,11 @@ function validateCreationPayload(payload: any): string | null { if (!Array.isArray(payload.provisions)) { return "provisions must be an array"; } + // Each provision as the client's report schema reads it back. + const provisionsError = reportProvisionsWireError(payload.provisions); + if (provisionsError) { + return provisionsError; + } if (typeof payload.year !== "string" || !payload.year) { return "year is required"; }