diff --git a/package.json b/package.json index 2bf1f5e7c..15ee30b4f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint", "test:qbo-expense-sync": "tsx --test tests/qbo-expense-sync.test.ts tests/qbo-expense-sync-route.test.ts tests/qbo-expense-sync-ui.test.tsx tests/qbo-purchase-changes.test.ts tests/qbo-expense-guard.test.ts tests/qbo-purchase-classification.test.ts", "test:qbo-receipt-push": "tsx --test tests/qbo-receipt-push.test.ts", - "test:bank-ledger": "tsx --test tests/bank-ledger.test.ts tests/bank-ledger-ingest-route.test.ts tests/bank-ledger-reconcile-route.test.ts tests/bank-ledger-status-route.test.ts tests/apply-bank-ledger.test.ts tests/migration-history-blind-spots.test.ts tests/parse-wtb-statement.test.ts tests/parse-wtb-daily-csv.test.ts tests/post-qbo-register.test.ts tests/receipt-match.test.ts tests/vendor-alias.test.ts tests/receipt-policy.test.ts tests/bank-image.test.ts tests/post-bank-images.test.ts tests/extract-check-payers.test.ts tests/check-payer-match.test.ts tests/check-evidence.test.ts tests/deposit-attribution.test.ts", + "test:bank-ledger": "tsx --test tests/bank-ledger.test.ts tests/bank-ledger-ingest-route.test.ts tests/bank-ledger-reconcile-route.test.ts tests/bank-ledger-status-route.test.ts tests/apply-bank-ledger.test.ts tests/apply-check-payer-extraction.test.ts tests/migration-history-blind-spots.test.ts tests/parse-wtb-statement.test.ts tests/parse-wtb-daily-csv.test.ts tests/post-qbo-register.test.ts tests/receipt-match.test.ts tests/vendor-alias.test.ts tests/receipt-policy.test.ts tests/bank-image.test.ts tests/post-bank-images.test.ts tests/extract-check-payers.test.ts tests/check-payer-match.test.ts tests/check-evidence.test.ts tests/deposit-attribution.test.ts", "test:automation-key-resolver": "tsx --test tests/automation-key-resolver.test.ts", "test:automation-register": "tsx --test tests/automation-register-filters.test.ts tests/match-receipt-journey.test.ts tests/automation-events-grouping.test.ts tests/automation-format.test.ts", "test:ai-review-reasonableness": "tsx --test tests/ai-review-reasonableness.test.ts", diff --git a/tests/apply-check-payer-extraction.test.ts b/tests/apply-check-payer-extraction.test.ts new file mode 100644 index 000000000..d68bae5d1 --- /dev/null +++ b/tests/apply-check-payer-extraction.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + BANK_IMAGE_CONSTRAINT_QUERY, + BANK_IMAGE_FOUNDATION_PREFLIGHT_QUERY, + constraintDefinitionMatches, + missingFoundationTables, + runPayerExtractionMigration, + statements, + targetMatches, + wrongColumnDefinitions, +} from "../scripts/apply-check-payer-extraction.mjs"; + +test("same-named constraint on another relation does not satisfy BankImage constraint lookup", () => { + assert.match(BANK_IMAGE_CONSTRAINT_QUERY, /conname = 'BankImage_extraction_pair'/); + assert.match(BANK_IMAGE_CONSTRAINT_QUERY, /conrelid = 'public\."BankImage"'::regclass/); + + const addConstraintStatement = statements.at(-1) ?? ""; + assert.match(addConstraintStatement, /conname = 'BankImage_extraction_pair'/); + assert.match(addConstraintStatement, /conrelid = 'public\."BankImage"'::regclass/); +}); + +test("migration preflight is read-only and requires both BankImage foundation tables", () => { + const preflight = BANK_IMAGE_FOUNDATION_PREFLIGHT_QUERY; + assert.match(preflight, /information_schema\.tables/); + assert.match(preflight, /table_type = 'BASE TABLE'/); + assert.match(preflight, /'BankImage'/); + assert.match(preflight, /'BankImageMatch'/); + assert.doesNotMatch(preflight, /\bALTER\b|\bCREATE\b|\bDROP\b|\bUPDATE\b|\bDELETE\b|\bINSERT\b/i); + assert.deepEqual(missingFoundationTables([{ table_name: "BankImage" }]), ["BankImageMatch"]); + assert.deepEqual( + missingFoundationTables([{ table_name: "BankImage" }, { table_name: "BankImageMatch" }]), + [], + ); +}); + +test("target host must match exactly, never as a prefix", () => { + assert.equal(targetMatches({ db: "ledger", host: "10.0.0.1" }, "ledger", "10.0.0.1"), true); + assert.equal(targetMatches({ db: "ledger", host: "10.0.0.12" }, "ledger", "10.0.0.1"), false); +}); + +test("constraint verification rejects a same-name check with the wrong invariant", () => { + assert.equal( + constraintDefinitionMatches('CHECK ((("extractedAt" IS NULL) = ("extractionModel" IS NULL)))'), + true, + ); + assert.equal(constraintDefinitionMatches("CHECK (true)"), false); +}); + +test("post-DDL verification requires TIMESTAMPTZ(6), not a lower-precision lookalike", () => { + const baseColumns = [ + { column_name: "payerName", data_type: "text", datetime_precision: null }, + { column_name: "memoText", data_type: "text", datetime_precision: null }, + { column_name: "extractedAt", data_type: "timestamp with time zone", datetime_precision: 6 }, + { column_name: "extractionModel", data_type: "text", datetime_precision: null }, + ]; + assert.deepEqual(wrongColumnDefinitions(baseColumns), []); + assert.deepEqual( + wrongColumnDefinitions(baseColumns.map(column => + column.column_name === "extractedAt" ? { ...column, datetime_precision: 0 } : column, + )), + ["extractedAt"], + ); +}); + +test("failed foundation preflight rejects before any DDL can run", async () => { + let executedStatements = 0; + const prisma = { + async $queryRawUnsafe(sql: string) { + if (sql.includes("current_database()")) return [{ db: "ledger", host: "10.0.0.1" }]; + if (sql === BANK_IMAGE_FOUNDATION_PREFLIGHT_QUERY) return [{ table_name: "BankImage" }]; + throw new Error(`unexpected query: ${sql}`); + }, + async $executeRawUnsafe() { + executedStatements += 1; + }, + }; + + await assert.rejects( + runPayerExtractionMigration({ prisma, expectDb: "ledger", expectHost: "10.0.0.1", write: () => {} }), + /PREFLIGHT FAILED: missing foundation table\(s\): BankImageMatch/, + ); + assert.equal(executedStatements, 0, "a missing foundation must stop before the first DDL statement"); +});