From 726e200e4f7fab6977dfc7b9f7d6a772962901b8 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Fri, 14 Aug 2026 21:55:17 +0200 Subject: [PATCH 1/2] test: prove parser and postgres v1 parity ACC-29 Compare all anonymous-fixture cents, salary/due days, and current snapshot selection between the sheet parser and the PostgreSQL reader. --- docs/architektur/finanz-domaene.md | 2 +- docs/architektur/tests-und-qualitaet.md | 2 +- .../0013-postgresql-als-finanzquelle.md | 5 +- docs/produkt/entwicklungsstand.md | 4 +- docs/produkt/roadmap.md | 1 - .../financeRepository.postgres.test.ts | 135 +++++++++++++++++- 6 files changed, 140 insertions(+), 9 deletions(-) diff --git a/docs/architektur/finanz-domaene.md b/docs/architektur/finanz-domaene.md index cfc3305..8acd638 100644 --- a/docs/architektur/finanz-domaene.md +++ b/docs/architektur/finanz-domaene.md @@ -43,7 +43,7 @@ Die Tabellen `finance_meta`, `accounts`, `account_snapshots`, `pockets`, `pocket Das Repository liest bewusst jeden gespeicherten Snapshot einschließlich alter und zukünftiger Werte. Es wählt keinen „aktuellen“ Stand und berechnet keine Summe. Erst die unveränderten Selektoren verwenden `snapshot.asOf <= data.asOf`. Damit bleibt die fachliche Auswahlgrenze identisch zum Sheets-Pfad. Die Datenbank erzwingt strukturelle Owner-/Fremdschlüsselintegrität; das Repository ergänzt Laufzeitvertrag und die Parserregel, dass aktive Accounts, Pockets und Debts einen passenden Snapshot benötigen. -Vollständiger Tabellenvertrag: [Datenbankreferenz](../referenz/datenbank.md). Implementierung und echter Datenbanktest: [financeRepository.ts](../../api/_lib/financeRepository.ts), [financeRepository.postgres.test.ts](../../tests/postgres/financeRepository.postgres.test.ts). +Vollständiger Tabellenvertrag: [Datenbankreferenz](../referenz/datenbank.md). Implementierung und echter Datenbanktest: [financeRepository.ts](../../api/_lib/financeRepository.ts), [financeRepository.postgres.test.ts](../../tests/postgres/financeRepository.postgres.test.ts). Dieselbe Suite hält Cents, `salaryDay`/`dueDay` und die Selektor-Auswahl `selectLatest*Snapshot` der anonymen Fixture zwischen Parser und Reader fest. ## Integer-Cents und Snapshots diff --git a/docs/architektur/tests-und-qualitaet.md b/docs/architektur/tests-und-qualitaet.md index bb1a07c..f111a54 100644 --- a/docs/architektur/tests-und-qualitaet.md +++ b/docs/architektur/tests-und-qualitaet.md @@ -15,7 +15,7 @@ Keine einzelne Prüfung beweist das Produkt. Reine Unit-Tests decken fachliche R | Befehl | Inhalt | Netzwerk/Secrets | | --- | --- | --- | | `npm test` | Node-Tests für ESM und den Lizenzgenerator sowie Vitest-Dateien unter `src/` und `build/` | keine externen Dienste | -| `npm run test:postgres` | Migrationen 001/002, Finance-Constraints, Owner-Isolation, Reader und Selektorgrenze gegen echtes PostgreSQL | dedizierte `POSTGRES_TEST_URL`, nur synthetische Daten | +| `npm run test:postgres` | Migrationen 001/002, Finance-Constraints, Owner-Isolation, Reader, Selektorgrenze und ACC-29-Parität (Cents, Fälligkeiten, Snapshot-Auswahl) gegen echtes PostgreSQL | dedizierte `POSTGRES_TEST_URL`, nur synthetische Daten | | `npm run lint` | ESLint über das Repository | nein | | `npm run licenses:check` | installierten Produktionsgraph fail-closed gegen Policy und eingecheckte Drittanbieterhinweise prüfen | nein | | `npm run build` | TypeScript-Projektbuild und Vite/PWA-Produktionsbuild | nein | diff --git a/docs/entscheidungen/0013-postgresql-als-finanzquelle.md b/docs/entscheidungen/0013-postgresql-als-finanzquelle.md index 5c7ccae..f5105e4 100644 --- a/docs/entscheidungen/0013-postgresql-als-finanzquelle.md +++ b/docs/entscheidungen/0013-postgresql-als-finanzquelle.md @@ -47,7 +47,7 @@ Diese ADR legt das verbindliche Zielbild fest, beschreibt aber nicht den bereits Die Umsetzung erfolgt in klaren Schritten: 1. ACC-71 erstellt Schema und Repository-Lesepfad, ohne die fachliche Grenze zu ändern. -2. ACC-29 weist mit derselben anonymen Fixture identische Cents, Fälligkeiten und Snapshot-Auswahl für Parser- und PostgreSQL-Pfad nach. +2. ACC-29 weist mit derselben anonymen Fixture identische Cents, Fälligkeiten und Snapshot-Auswahl für Parser- und PostgreSQL-Pfad nach. Dieser Nachweis ist umgesetzt. 3. ACC-66 importiert den privaten Bestand vollständig, prüft die Parität und schaltet die einzige produktive Quelle auf PostgreSQL um. 4. ACC-72 baut anschließend den eng begrenzten In-App-Editor. @@ -77,4 +77,5 @@ Schema, Migration, Repository und Schreibgrenzen müssen sorgfältig umgesetzt u - Domänenvertrag und bestehender Produktionspfad: [FinanceDataV1](../../src/finance/types.ts), [Sheets-Parser](../../src/finance/parser.ts), [Finance-Service](../../api/_lib/financeService.ts) - Umgesetzter ACC-71-Stand: [Migration 002](../../migrations/002_finance_data_v1.sql), [PostgreSQL-Reader](../../api/_lib/financeRepository.ts), [Integrationstest](../../tests/postgres/financeRepository.postgres.test.ts) -- Weitere Nachweise und Cutover: ACC-29, ACC-66 und ACC-72 in Linear +- Umgesetzter ACC-29-Nachweis: dieselbe Suite vergleicht Cents, `salaryDay`/`dueDay` und `selectLatest*Snapshot` von Parser- und PostgreSQL-Pfad an der anonymen Fixture +- Weitere Nachweise und Cutover: ACC-66 und ACC-72 in Linear diff --git a/docs/produkt/entwicklungsstand.md b/docs/produkt/entwicklungsstand.md index a9a1972..9a11287 100644 --- a/docs/produkt/entwicklungsstand.md +++ b/docs/produkt/entwicklungsstand.md @@ -13,14 +13,14 @@ - Kanonische URLs für alle vier Hauptansichten mit Deep Links, Browser-/PWA-History, sicherer OAuth-Rückkehr und gezielter PWA-Kaltstart-Wiederherstellung. - Google OAuth mit State, Nonce und PKCE; Picker mit `drive.file`; serverseitige Drive-/Sheets-Zugriffe; verschlüsselte Refresh-Tokens in PostgreSQL. - Finance Data Schema v1 mit zehn Maschinen-Tabs, Laufzeitvalidierung, Integer-Cents, Fremdschlüsseln, Snapshot-Auswahl, `salary_day` und `due_day`. -- Ownergebundenes PostgreSQL-v1-Schema mit zusammengesetzten Fremdschlüsseln, gemeinsamem Lazy-Pool und internem `READ ONLY`-/`REPEATABLE READ`-Reader zurück zum unveränderten `FinanceDataV1`. Der produktive `/api/finance`-Pfad bleibt bis ACC-66 auf Sheets. +- Ownergebundenes PostgreSQL-v1-Schema mit zusammengesetzten Fremdschlüsseln, gemeinsamem Lazy-Pool und internem `READ ONLY`-/`REPEATABLE READ`-Reader zurück zum unveränderten `FinanceDataV1`. Parser- und PostgreSQL-Lesepfad liefern für die anonyme Fixture dieselben Cents, Fälligkeitstage und Snapshot-Auswahl. Der produktive `/api/finance`-Pfad bleibt bis ACC-66 auf Sheets. - Last-known-good-Cache in IndexedDB, Offline-App-Shell, getesteter leerer Offline-Start und Netzrückkehr, manuelle und ereignisgesteuerte Datenaktualisierung sowie Race-Schutz. - Kontrollierter PWA-Versionswechsel mit verständlichem „Jetzt neu laden“/„Später“-Hinweis, stabilem Installationsmanifest und automatisierten Android-orientierten Icon-/Systemfarben-Verträgen. - Appearance mit Systemmodus, Hell/Dunkel, Browser-Akzent, neun Presets, lokaler Bildanalyse im Worker und lokaler WebP-Vorschau. - Lokaler Privacy-Modus einschließlich Tabsynchronisierung und Maskierung von sichtbaren sowie zugänglichen Geldtexten. - Optionaler App-Vorschau-Schutz und lokaler sechsstelliger PIN-Lock mit Android-orientiertem, thematisiertem Lockscreen, Expressive-PIN-Formen, Fehlversuchs-Wartezeit und fail-closed Recovery. - Wiederverwendbare MD3-Komponenten, Responsive/Reflow, Reduced Motion, Forced Colors, Fokusmanagement und lokale Google-Sans-Flex-Schrift. -- GitHub-CI für Lint, Unit-Tests, echte PostgreSQL-Integrationstests, Build und Smoke; aktuell 237 normale Vitest-Tests, zehn dedizierte PostgreSQL-Fälle und elf Node-Tests sowie PWA-, Offline-, Golden- und Axe-Prüfungen. +- GitHub-CI für Lint, Unit-Tests, echte PostgreSQL-Integrationstests, Build und Smoke; aktuell 237 normale Vitest-Tests, 13 dedizierte PostgreSQL-Fälle und elf Node-Tests sowie PWA-, Offline-, Golden- und Axe-Prüfungen. ## Historische Meilensteine diff --git a/docs/produkt/roadmap.md b/docs/produkt/roadmap.md index 67e7032..2b5d0fc 100644 --- a/docs/produkt/roadmap.md +++ b/docs/produkt/roadmap.md @@ -20,7 +20,6 @@ Die Roadmap ist eine Absichtserklärung, kein Funktionsversprechen. „Now“ be - Architektur für den Quellenwechsel verbindlich festlegen und die sheetgebundenen ADRs ersetzen. - Das heutige `FinanceDataV1` mit internem `owner_id` in PostgreSQL abbilden und ownergebunden wieder als denselben Vertrag lesen. -- Sheet-Parser und PostgreSQL-Lesepfad mit derselben anonymen Fixture auf identische Cents, Fälligkeiten und Snapshot-Auswahl prüfen. - Den bestehenden privaten Datenstand einmalig importieren und danach eindeutig auf PostgreSQL als einzige produktive Quelle umschalten. - Einen eng begrenzten In-App-Editor für Stände, Beträge, Fälligkeiten und Aktivstatus bauen. diff --git a/tests/postgres/financeRepository.postgres.test.ts b/tests/postgres/financeRepository.postgres.test.ts index 2d042ff..a5a685a 100644 --- a/tests/postgres/financeRepository.postgres.test.ts +++ b/tests/postgres/financeRepository.postgres.test.ts @@ -8,7 +8,11 @@ import { } from '../../api/_lib/financeRepository'; import { financeDataV1Schema } from '../../src/finance/runtime'; import { parseSheetsBatchResponse } from '../../src/finance/parser'; -import { selectLatestAccountSnapshot } from '../../src/finance/selectors'; +import { + selectLatestAccountSnapshot, + selectLatestDebtSnapshot, + selectLatestPocketSnapshot, +} from '../../src/finance/selectors'; import { anonymousSheetsResponse } from '../../src/mocks/anonymousWorkbook'; import type { FinanceDataV1 } from '../../src/finance/types'; @@ -270,6 +274,70 @@ describe.sequential('Finance PostgreSQL migration and repository', () => { expect(serialized).not.toContain('Geheimer Kontoname'); expect(serialized).not.toContain(String(fixture.monthlyIncomeCents)); }); + + it('delivers identical cents from the parser and the postgres reader for the anonymous fixture', async () => { + const ownerId = await createOwner('parity-cents-owner'); + await insertFinanceData(ownerId, fixture); + + const fromParser = fixture; + const fromPostgres = await repository.readForGoogleSub('parity-cents-owner'); + expect(fromPostgres).not.toBeNull(); + + const parserCents = moneyCentsByStableKey(fromParser); + const postgresCents = moneyCentsByStableKey(fromPostgres!); + + expect(Object.keys(parserCents)).toHaveLength(ANONYMOUS_FIXTURE_MONEY_FIELD_COUNT); + expect(parserCents['meta.monthlyIncomeCents']).toBe(259_132); + expect(postgresCents).toEqual(parserCents); + }); + + it('selects the same current snapshots from parser and postgres data for the anonymous fixture', async () => { + const ownerId = await createOwner('parity-snapshot-owner'); + await insertFinanceData(ownerId, fixture); + await sql` + INSERT INTO account_snapshots (owner_id, account_id, as_of, balance_cents) + VALUES + (${ownerId}, 'daily-account', '2025-01-01', 1), + (${ownerId}, 'daily-account', '2027-01-01', 999999) + `; + await sql` + INSERT INTO pocket_snapshots (owner_id, pocket_id, as_of, balance_cents) + VALUES + (${ownerId}, 'home-reserve', '2025-01-01', 1), + (${ownerId}, 'home-reserve', '2027-01-01', 999999) + `; + await sql` + INSERT INTO debt_snapshots ( + owner_id, debt_id, as_of, payoff_balance_cents, + remaining_payment_count, remaining_scheduled_total_cents + ) VALUES + (${ownerId}, 'primary-loan', '2025-01-01', 1, 1, 1), + (${ownerId}, 'primary-loan', '2027-01-01', 999999, 1, 1) + `; + + const fromPostgres = await repository.readForGoogleSub('parity-snapshot-owner'); + expect(fromPostgres).not.toBeNull(); + expect(fromPostgres!.accountSnapshots.length).toBeGreaterThan(fixture.accountSnapshots.length); + expect(fromPostgres!.pocketSnapshots.length).toBeGreaterThan(fixture.pocketSnapshots.length); + expect(fromPostgres!.debtSnapshots.length).toBeGreaterThan(fixture.debtSnapshots.length); + + expect(selectedSnapshotsById(fromPostgres!)).toEqual(selectedSnapshotsById(fixture)); + }); + + it('delivers identical salary and due days from the parser and the postgres reader', async () => { + const ownerId = await createOwner('parity-due-day-owner'); + await insertFinanceData(ownerId, fixture); + + const fromPostgres = await repository.readForGoogleSub('parity-due-day-owner'); + expect(fromPostgres).not.toBeNull(); + + const parserDays = dueDaysByStableKey(fixture); + const postgresDays = dueDaysByStableKey(fromPostgres!); + + expect(parserDays['meta.salaryDay']).toBe(25); + expect(Object.values(parserDays).some((day) => day === null)).toBe(true); + expect(postgresDays).toEqual(parserDays); + }); }); async function createOwner(googleSub: string): Promise { @@ -382,9 +450,72 @@ async function insertFinanceData(ownerId: string, data: FinanceDataV1): Promise< } } +const ANONYMOUS_FIXTURE_MONEY_FIELD_COUNT = 53; + +function moneyCentsByStableKey(data: FinanceDataV1): Record { + const entries: Array<[string, number]> = [ + ['meta.monthlyIncomeCents', data.monthlyIncomeCents], + ...data.accountSnapshots.map((row) => [ + `accountSnapshots.${row.accountId}.${row.asOf}`, + row.balanceCents, + ] as [string, number]), + ...data.pocketSnapshots.map((row) => [ + `pocketSnapshots.${row.pocketId}.${row.asOf}`, + row.balanceCents, + ] as [string, number]), + ...data.budgetItems.map((row) => [ + `budgetItems.${row.id}.monthlyAmountCents`, + row.monthlyAmountCents, + ] as [string, number]), + ...data.debts.map((row) => [ + `debts.${row.id}.monthlyPaymentCents`, + row.monthlyPaymentCents, + ] as [string, number]), + ...data.debtSnapshots.flatMap((row) => [ + [`debtSnapshots.${row.debtId}.${row.asOf}.payoffBalanceCents`, row.payoffBalanceCents], + [`debtSnapshots.${row.debtId}.${row.asOf}.remainingScheduledTotalCents`, row.remainingScheduledTotalCents], + ] as Array<[string, number]>), + ...data.debtMilestones.map((row) => [ + `debtMilestones.${row.debtId}.${row.date}`, + row.balanceCents, + ] as [string, number]), + ...data.reliefMilestones.map((row) => [ + `reliefMilestones.${row.date}.${row.event}.${row.eventDetail ?? ''}`, + row.monthlyReliefCents, + ] as [string, number]), + ]; + return Object.fromEntries(entries.sort(([left], [right]) => compareText(left, right))); +} + +function dueDaysByStableKey(data: FinanceDataV1): Record { + const entries: Array<[string, number | null]> = [ + ['meta.salaryDay', data.salaryDay], + ...data.budgetItems.map((row) => [`budgetItems.${row.id}.dueDay`, row.dueDay] as [string, number | null]), + ...data.debts.map((row) => [`debts.${row.id}.dueDay`, row.dueDay] as [string, number | null]), + ]; + return Object.fromEntries(entries.sort(([left], [right]) => compareText(left, right))); +} + +function selectedSnapshotsById(data: FinanceDataV1) { + return { + accounts: Object.fromEntries( + data.accounts.map((account) => [account.id, selectLatestAccountSnapshot(data, account.id) ?? null]), + ), + pockets: Object.fromEntries( + data.pockets.map((pocket) => [pocket.id, selectLatestPocketSnapshot(data, pocket.id) ?? null]), + ), + debts: Object.fromEntries( + data.debts.map((debt) => [debt.id, selectLatestDebtSnapshot(data, debt.id) ?? null]), + ), + }; +} + +function compareText(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} + function inRepositoryOrder(data: FinanceDataV1): FinanceDataV1 { const result = structuredClone(data); - const compareText = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0; const compareEntities = (left: { displayOrder: number; id: string }, right: { displayOrder: number; id: string }) => left.displayOrder - right.displayOrder || compareText(left.id, right.id); result.accounts.sort(compareEntities); From e5b3b98b04686b651e87f22f02f73d2481e4e585 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Fri, 14 Aug 2026 22:07:59 +0200 Subject: [PATCH 2/2] test: tighten ACC-29 parity assertions Narrow the postgres reader result without non-null assertions and compare money fields as a list so duplicate relief milestones stay visible. --- .../financeRepository.postgres.test.ts | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/postgres/financeRepository.postgres.test.ts b/tests/postgres/financeRepository.postgres.test.ts index a5a685a..0c9504f 100644 --- a/tests/postgres/financeRepository.postgres.test.ts +++ b/tests/postgres/financeRepository.postgres.test.ts @@ -280,14 +280,13 @@ describe.sequential('Finance PostgreSQL migration and repository', () => { await insertFinanceData(ownerId, fixture); const fromParser = fixture; - const fromPostgres = await repository.readForGoogleSub('parity-cents-owner'); - expect(fromPostgres).not.toBeNull(); + const fromPostgres = requireStoredFinanceData(await repository.readForGoogleSub('parity-cents-owner')); const parserCents = moneyCentsByStableKey(fromParser); - const postgresCents = moneyCentsByStableKey(fromPostgres!); + const postgresCents = moneyCentsByStableKey(fromPostgres); - expect(Object.keys(parserCents)).toHaveLength(ANONYMOUS_FIXTURE_MONEY_FIELD_COUNT); - expect(parserCents['meta.monthlyIncomeCents']).toBe(259_132); + expect(parserCents).toHaveLength(ANONYMOUS_FIXTURE_MONEY_FIELD_COUNT); + expect(parserCents).toContainEqual(['meta.monthlyIncomeCents', 259_132]); expect(postgresCents).toEqual(parserCents); }); @@ -315,24 +314,22 @@ describe.sequential('Finance PostgreSQL migration and repository', () => { (${ownerId}, 'primary-loan', '2027-01-01', 999999, 1, 1) `; - const fromPostgres = await repository.readForGoogleSub('parity-snapshot-owner'); - expect(fromPostgres).not.toBeNull(); - expect(fromPostgres!.accountSnapshots.length).toBeGreaterThan(fixture.accountSnapshots.length); - expect(fromPostgres!.pocketSnapshots.length).toBeGreaterThan(fixture.pocketSnapshots.length); - expect(fromPostgres!.debtSnapshots.length).toBeGreaterThan(fixture.debtSnapshots.length); + const fromPostgres = requireStoredFinanceData(await repository.readForGoogleSub('parity-snapshot-owner')); + expect(fromPostgres.accountSnapshots.length).toBeGreaterThan(fixture.accountSnapshots.length); + expect(fromPostgres.pocketSnapshots.length).toBeGreaterThan(fixture.pocketSnapshots.length); + expect(fromPostgres.debtSnapshots.length).toBeGreaterThan(fixture.debtSnapshots.length); - expect(selectedSnapshotsById(fromPostgres!)).toEqual(selectedSnapshotsById(fixture)); + expect(selectedSnapshotsById(fromPostgres)).toEqual(selectedSnapshotsById(fixture)); }); it('delivers identical salary and due days from the parser and the postgres reader', async () => { const ownerId = await createOwner('parity-due-day-owner'); await insertFinanceData(ownerId, fixture); - const fromPostgres = await repository.readForGoogleSub('parity-due-day-owner'); - expect(fromPostgres).not.toBeNull(); + const fromPostgres = requireStoredFinanceData(await repository.readForGoogleSub('parity-due-day-owner')); const parserDays = dueDaysByStableKey(fixture); - const postgresDays = dueDaysByStableKey(fromPostgres!); + const postgresDays = dueDaysByStableKey(fromPostgres); expect(parserDays['meta.salaryDay']).toBe(25); expect(Object.values(parserDays).some((day) => day === null)).toBe(true); @@ -452,7 +449,12 @@ async function insertFinanceData(ownerId: string, data: FinanceDataV1): Promise< const ANONYMOUS_FIXTURE_MONEY_FIELD_COUNT = 53; -function moneyCentsByStableKey(data: FinanceDataV1): Record { +function requireStoredFinanceData(data: FinanceDataV1 | null): FinanceDataV1 { + if (!data) throw new Error('Expected stored FinanceDataV1 from the postgres reader.'); + return data; +} + +function moneyCentsByStableKey(data: FinanceDataV1): Array<[string, number]> { const entries: Array<[string, number]> = [ ['meta.monthlyIncomeCents', data.monthlyIncomeCents], ...data.accountSnapshots.map((row) => [ @@ -484,7 +486,8 @@ function moneyCentsByStableKey(data: FinanceDataV1): Record { row.monthlyReliefCents, ] as [string, number]), ]; - return Object.fromEntries(entries.sort(([left], [right]) => compareText(left, right))); + return entries.sort(([leftKey, leftValue], [rightKey, rightValue]) => + compareText(leftKey, rightKey) || leftValue - rightValue); } function dueDaysByStableKey(data: FinanceDataV1): Record {