{
+ const box = await target.boundingBox();
+ if (!box) throw new Error('html5-drag: drop target has no bounding box');
+ const point = { clientX: box.x + box.width / 2, clientY: box.y + offsetY };
+
+ await dragStart(source);
+ await dragOver(target, point);
+ await drop(target, point);
+ await dragEnd(source);
+ // The drop fires a fetcher submit; give the router a tick to enter its
+ // pending state before the caller starts asserting on the outcome.
+ await page.waitForTimeout(100);
+}
diff --git a/tests/e2e/locale-activation.spec.ts b/tests/e2e/locale-activation.spec.ts
new file mode 100644
index 000000000..13fadd4e3
--- /dev/null
+++ b/tests/e2e/locale-activation.spec.ts
@@ -0,0 +1,99 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * #269 — proof that the i18n framework is ACTIVATED, not merely present.
+ *
+ * `/login` is the right surface for this: it was fully externalized in Phase C,
+ * so a failure here is a resolution failure rather than a missing translation.
+ * It is also pre-auth, which means no `users.locale` and no tenant default are
+ * in play — exactly the rungs this file is NOT trying to test. What is under
+ * test is the seam in `workers/app.ts`, which rewrites the incoming Cookie
+ * header before paraglide reads it.
+ *
+ * Every case asserts the LANGUAGE OF THE RENDER, not just the `lang` attribute.
+ * `lang` alone would stay green if the resolver worked and the message
+ * catalogue never loaded — which is the precise failure this whole rollout
+ * exists to rule out.
+ */
+
+const SPANISH_ON_LOGIN = /Iniciar sesión|Contraseña|Correo/;
+const ENGLISH_ON_LOGIN = /Sign in|Password|Email/;
+
+test.describe('locale activation', () => {
+ test('a Spanish browser with no cookie gets Spanish', async ({ browser }) => {
+ const ctx = await browser.newContext({ locale: 'es-419' });
+ const page = await ctx.newPage();
+ await page.goto('/login');
+
+ await expect(page.locator('html')).toHaveAttribute('lang', 'es-419');
+ await expect(page.locator('body')).toContainText(SPANISH_ON_LOGIN);
+ await ctx.close();
+ });
+
+ test('an English browser is byte-for-byte unaffected', async ({ browser }) => {
+ // The other half of the contract, and the one that would break every
+ // existing user: someone who has set no preference must see exactly
+ // what they saw before activation.
+ const ctx = await browser.newContext({ locale: 'en-US' });
+ const page = await ctx.newPage();
+ await page.goto('/login');
+
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ await expect(page.locator('body')).toContainText(ENGLISH_ON_LOGIN);
+ await ctx.close();
+ });
+
+ test('a language we do not speak falls through to English, not to itself', async ({ browser }) => {
+ // The resolver must IGNORE an unsupported tag rather than stop on it.
+ // Stopping would render `lang="fr-FR"` over English text, which tells
+ // a screen reader to pronounce English with French phonetics.
+ const ctx = await browser.newContext({ locale: 'fr-FR' });
+ const page = await ctx.newPage();
+ await page.goto('/login');
+
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ await expect(page.locator('body')).toContainText(ENGLISH_ON_LOGIN);
+ await ctx.close();
+ });
+
+ test('an explicit cookie beats the browser, in both directions', async ({ browser }) => {
+ // The switcher's contract, at the level where it can actually break.
+ // The plan for #269 ranked the cookie BELOW Accept-Language; under that
+ // ordering the first half of this test fails, because a Spanish-
+ // browsered person who picks English is handed Spanish straight back.
+ const spanishBrowser = await browser.newContext({ locale: 'es-419' });
+ await spanishBrowser.addCookies([
+ { name: 'PARAGLIDE_LOCALE', value: 'en', url: 'http://127.0.0.1:8789' },
+ ]);
+ const englishPage = await spanishBrowser.newPage();
+ await englishPage.goto('/login');
+ await expect(englishPage.locator('html')).toHaveAttribute('lang', 'en');
+ await expect(englishPage.locator('body')).toContainText(ENGLISH_ON_LOGIN);
+ await spanishBrowser.close();
+
+ const englishBrowser = await browser.newContext({ locale: 'en-US' });
+ await englishBrowser.addCookies([
+ { name: 'PARAGLIDE_LOCALE', value: 'es-419', url: 'http://127.0.0.1:8789' },
+ ]);
+ const spanishPage = await englishBrowser.newPage();
+ await spanishPage.goto('/login');
+ await expect(spanishPage.locator('html')).toHaveAttribute('lang', 'es-419');
+ await expect(spanishPage.locator('body')).toContainText(SPANISH_ON_LOGIN);
+ await englishBrowser.close();
+ });
+
+ test('a stored tag the catalogue has no locale for still resolves', async ({ browser }) => {
+ // 'en-US' is what the settings pickers write. Paraglide has no such
+ // locale, so an unnormalised cookie would fall back to baseLocale by
+ // accident — right answer, wrong reason, and 'es-MX' would then be
+ // wrong for real. Both are asserted here so the pair cannot drift.
+ const ctx = await browser.newContext({ locale: 'en-US' });
+ await ctx.addCookies([
+ { name: 'PARAGLIDE_LOCALE', value: 'es-MX', url: 'http://127.0.0.1:8789' },
+ ]);
+ const page = await ctx.newPage();
+ await page.goto('/login');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'es-419');
+ await ctx.close();
+ });
+});
diff --git a/tests/e2e/people-role-profiles.spec.ts b/tests/e2e/people-role-profiles.spec.ts
index 9a1dcfc33..b446fc52b 100644
--- a/tests/e2e/people-role-profiles.spec.ts
+++ b/tests/e2e/people-role-profiles.spec.ts
@@ -152,7 +152,17 @@ test.describe.serial('People / Role Profiles (Plan 1B)', () => {
// Primary before this test ever opens the modal.
await expect(page.getByTestId('people-group-client')).toBeVisible();
await expect(page.getByText('Editor Seed Client')).toBeVisible();
- await expect(page.getByText('Primary', { exact: true })).toBeVisible();
+ // Asserted as a relationship, not as page-wide existence: the per-report card
+ // puts a second "Primary" badge on this same page, so a bare exact-text match
+ // resolves to two nodes. The pill sits in the same as the person's link —
+ // note `people-group-client` is on the group HEADING, whose rows are siblings,
+ // so scoping to that testid finds nothing.
+ await expect(
+ page
+ .getByRole('link', { name: 'Editor Seed Client' })
+ .locator('xpath=..')
+ .getByText('Primary', { exact: true }),
+ ).toBeVisible();
// IA-36 ⑬ — this used to 409 ("an inspection already has a primary
// client"). It no longer does. "Exactly one primary client" is now upheld
@@ -171,8 +181,14 @@ test.describe.serial('People / Role Profiles (Plan 1B)', () => {
await expect(page.getByRole('link', { name: SEARCH_CONTACT_CLIENT.name })).toBeVisible();
// The seat moved rather than duplicating: exactly one Primary badge, and
- // it is not on the incumbent any more.
- await expect(page.getByText('Primary', { exact: true })).toHaveCount(1);
+ // it is not on the incumbent any more. Counted inside the People card only —
+ // the per-report card carries its own "Primary" badge for the primary
+ // deliverable, which has nothing to do with who holds the client seat.
+ // `people-group-client` marks the group HEADING; its grandparent is the
+ // container holding every people group, which is the smallest node that
+ // provably contains all the badges and none of the report card's.
+ const peopleGroups = page.getByTestId('people-group-client').locator('xpath=../..');
+ await expect(peopleGroups.getByText('Primary', { exact: true })).toHaveCount(1);
// The incumbent stayed on the inspection, demoted to the company's
// co-client role — losing the seat must not mean losing access.
await expect(page.getByText('Editor Seed Client')).toBeVisible();
diff --git a/tests/e2e/workspace-pages-responsive.spec.ts b/tests/e2e/workspace-pages-responsive.spec.ts
index 38d4a83d0..9253739e1 100644
--- a/tests/e2e/workspace-pages-responsive.spec.ts
+++ b/tests/e2e/workspace-pages-responsive.spec.ts
@@ -119,6 +119,30 @@ async function overflowCulprits(page: Page): Promise {
});
}
+/**
+ * ⚠️ OPEN, UNDIAGNOSED — `contacts @ ipad-portrait` fails deterministically when
+ * this project runs on its own (`--workers=1`), and passes in the full suite at
+ * `workers: 3`. Verified 2026-08-05 across eight runs.
+ *
+ * The failure is always the FIRST test in the matrix, never any of the other 49
+ * that issue the identical `beforeEach` navigation: `page.goto('/login')` never
+ * completes, the test timeout expires, and Playwright tears the page down —
+ * reported as `net::ERR_ABORTED; maybe frame was detached`, which looks like a
+ * navigation bug and is really the teardown.
+ *
+ * Ruled out by experiment, each with its own run — none of these is the cause:
+ * - slowness / cold start: a 90s budget times out the same way
+ * - `waitUntil: 'load'` hanging on a subresource: `domcontentloaded` identical
+ * - the worker not being up: /status answers before the hook runs
+ * - retrying the goto: the test timeout kills the hook, so the catch is dead
+ * - warming the API in beforeAll (`request.get`), and warming the browser in
+ * beforeAll with a separate page — neither changes the outcome
+ *
+ * It is a harness fault, not a product one: the page it cannot reach is served
+ * to the 49 navigations that follow it. NOT quarantined with `fixme`, because
+ * skipping it only promotes the next test into the same position — which would
+ * hide the fault rather than remove it.
+ */
test.describe('Workspace pages — responsive smoke', () => {
// Seed enough contacts to force a VERTICAL scrollbar. That matters: a
// vertical scrollbar takes ~15px off clientWidth, and a layout with no
@@ -149,12 +173,28 @@ test.describe('Workspace pages — responsive smoke', () => {
headers: auth,
});
}
+
+ // Readiness, asked rather than waited out: /status is a plain JSON
+ // handler with no SSR and no assets, so a 200 means the worker is
+ // serving. Poll it instead of sleeping a fixed interval — a fixed wait
+ // is either too short on a cold machine or wasted on a warm one.
+ const deadline = Date.now() + 60_000;
+ for (;;) {
+ const res = await request.get(`${BASE_URL}/status`).catch(() => null);
+ if (res?.ok()) break;
+ if (Date.now() > deadline) throw new Error('worker never became ready at /status');
+ }
+
});
test.beforeEach(async ({ page }) => {
const seed = readEditorSeed();
test.skip(!seed, 'editor-seed fixture unavailable');
- await page.goto('/login');
+ // `domcontentloaded` to match every navigation in the test bodies below,
+ // which all pass it explicitly. This hook used the default `load`, which
+ // was inconsistent — though it is NOT the cause of the open failure
+ // recorded above the describe.
+ await page.goto('/login', { waitUntil: 'domcontentloaded' });
await page.fill('input[name=email]', seed!.email);
await page.fill('input[name=password]', seed!.password);
await page.click('button[type=submit]');
diff --git a/tests/helpers/inline-ddl.ts b/tests/helpers/inline-ddl.ts
index 61e599138..d026e7228 100644
--- a/tests/helpers/inline-ddl.ts
+++ b/tests/helpers/inline-ddl.ts
@@ -21,7 +21,7 @@
* one sync assertion.
*/
export const TENANT_CONFIGS_TEST_DDL =
- 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, company_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, video_mode TEXT, sms_mode TEXT, sender_display_name TEXT, point_of_contact TEXT, billing_url TEXT, review_url TEXT, company_phone TEXT, integration_config TEXT, secrets TEXT, secrets_enc TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, default_profile_id TEXT, attention_thresholds TEXT, inspection_prefs TEXT, is_estimates_shown INTEGER, is_repair_list_enabled INTEGER, is_customer_repair_export_enabled INTEGER, is_unpaid_blocked INTEGER, is_unsigned_agreement_blocked INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, is_concierge_review_required INTEGER, is_inspector_choice_allowed INTEGER, is_pdf_pipeline_enabled INTEGER, auto_sign_on_publish_default INTEGER, is_team_mode_default TEXT, is_apprentice_review_required INTEGER, is_guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, reinspection_statuses TEXT, is_collab_editing_enabled INTEGER NOT NULL DEFAULT 1, company_address TEXT, is_pdf_footer_shown INTEGER, is_pdf_page_numbers_shown INTEGER, is_pdf_license_shown INTEGER, sms_byo_provider TEXT, email_byo_provider TEXT, is_managed_eligible INTEGER NOT NULL DEFAULT 0, managed_provider TEXT NOT NULL DEFAULT \'twilio\', is_reserve_schedule_enabled INTEGER, reserve_term_years INTEGER, inflation_rate_bps INTEGER, default_timezone TEXT NOT NULL DEFAULT \'UTC\', booking_slot_mode TEXT NOT NULL DEFAULT \'fixed\', booking_slot_interval_min INTEGER NOT NULL DEFAULT 30, holiday_region TEXT, holiday_public_policy TEXT NOT NULL DEFAULT \'open\', holiday_internal_policy TEXT NOT NULL DEFAULT \'advisory\', default_locale TEXT NOT NULL DEFAULT \'en-US\', currency TEXT NOT NULL DEFAULT \'USD\', is_archive_revoking_access INTEGER NOT NULL DEFAULT 0, legal_mode TEXT NOT NULL DEFAULT \'hosted\', custom_privacy_url TEXT, custom_terms_url TEXT, privacy_body TEXT, terms_body TEXT, updated_at INTEGER);';
+ 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, company_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, video_mode TEXT, sms_mode TEXT, sender_display_name TEXT, point_of_contact TEXT, billing_url TEXT, review_url TEXT, company_phone TEXT, integration_config TEXT, secrets TEXT, secrets_enc TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, default_profile_id TEXT, attention_thresholds TEXT, inspection_prefs TEXT, is_estimates_shown INTEGER, is_repair_list_enabled INTEGER, is_customer_repair_export_enabled INTEGER, is_unpaid_blocked INTEGER, is_unsigned_agreement_blocked INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, is_concierge_review_required INTEGER, is_inspector_choice_allowed INTEGER, is_pdf_pipeline_enabled INTEGER, auto_sign_on_publish_default INTEGER, is_team_mode_default TEXT, is_apprentice_review_required INTEGER, is_guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, reinspection_statuses TEXT, is_collab_editing_enabled INTEGER NOT NULL DEFAULT 1, company_address TEXT, is_pdf_footer_shown INTEGER, is_pdf_page_numbers_shown INTEGER, is_pdf_license_shown INTEGER, sms_byo_provider TEXT, email_byo_provider TEXT, is_managed_eligible INTEGER NOT NULL DEFAULT 0, managed_provider TEXT NOT NULL DEFAULT \'twilio\', is_reserve_schedule_enabled INTEGER, reserve_term_years INTEGER, inflation_rate_bps INTEGER, default_timezone TEXT NOT NULL DEFAULT \'UTC\', booking_slot_mode TEXT NOT NULL DEFAULT \'fixed\', booking_slot_interval_min INTEGER NOT NULL DEFAULT 30, holiday_region TEXT, holiday_public_policy TEXT NOT NULL DEFAULT \'open\', holiday_internal_policy TEXT NOT NULL DEFAULT \'advisory\', default_locale TEXT NOT NULL DEFAULT \'en-US\', currency TEXT NOT NULL DEFAULT \'USD\', is_archive_revoking_access INTEGER NOT NULL DEFAULT 0, legal_mode TEXT NOT NULL DEFAULT \'hosted\', custom_privacy_url TEXT, custom_terms_url TEXT, privacy_body TEXT, terms_body TEXT, date_format TEXT NOT NULL DEFAULT \'us\', time_format TEXT NOT NULL DEFAULT \'12h\', booking_conflict_policy TEXT NOT NULL DEFAULT \'advisory\', updated_at INTEGER);';
export const INSPECTION_RESULTS_TEST_DDL =
'CREATE TABLE IF NOT EXISTS inspection_results (id TEXT PRIMARY KEY NOT NULL, tenant_id TEXT NOT NULL, inspection_id TEXT NOT NULL, data TEXT NOT NULL, ydoc_state BLOB, last_synced_at INTEGER NOT NULL, rating_system_id TEXT, rating_system_snapshot TEXT, report_id TEXT);';
diff --git a/tests/seed-fixtures.ts b/tests/seed-fixtures.ts
index cbfd796ce..e41b0d866 100644
--- a/tests/seed-fixtures.ts
+++ b/tests/seed-fixtures.ts
@@ -9,6 +9,8 @@
* Idempotent — re-running with the same fixture ids is a no-op.
*/
import { execSync } from 'child_process';
+import { existsSync } from 'fs';
+import path from 'path';
const ADMIN_EMAIL = 'admin-seed@seed.test';
const LEAD_EMAIL = 'inspector-a@seed.test';
@@ -17,23 +19,69 @@ const ADMIN_FULL_EMAIL = 'admin-full@seed.test';
const MULTI_EMAIL = 'multi-tenant-user@seed.test';
const BRANCH_B_EMAIL = 'branch-b@seed.test';
-// PBKDF2-SHA256 of 'seedpassword' with a fixed salt — pre-computed so we
-// don't have to import the password helper into a setup script. Format
-// matches server/lib/password.ts (salt:iterations:hash all base64).
-// Verified manually by hashing 'seedpassword' through the same routine.
+// PBKDF2-SHA256 of 'seedpassword' — pre-computed so this setup script does not
+// have to import the password helper.
+//
+// The format is `pbkdf2:hex(salt):hex(hash)`, exactly what `hashPassword()` in
+// server/lib/password.ts emits: a `pbkdf2:` PREFIX, hex (not base64), and no
+// iterations field (they are fixed at 100_000 in that module). The prefix is
+// load-bearing rather than decorative — `verifyPassword()` branches on it, and
+// without it every stored value falls through to the legacy plain-SHA-256
+// comparison, which no pbkdf2 digest can ever satisfy. A previous value here
+// was base64 with an iterations segment and no prefix, so it took that legacy
+// branch and NO seeded account could log in.
+//
+// The salt is the ASCII string `seedsaltseedsalt` so this stays reproducible:
+// node -e "console.log(require('crypto').pbkdf2Sync('seedpassword',
+// Buffer.from('seedsaltseedsalt'), 100000, 32, 'sha256').toString('hex'))"
const SEED_PASSWORD_HASH =
- 'c2VlZHNhbHRzZWVkc2FsdA==:100000:5VlRX7Qd5LRMc+IT5Z3rWUmWzkn29w7Vw31o0kHGymY=';
+ 'pbkdf2:7365656473616c747365656473616c74:75505a5ed1b1d3f91d138d9a55f63a6a546cff94f02ef47b8cc763009b8cb551';
-const TENANT_A_ID = '00000000-0000-0000-0000-000000000aaa';
+/**
+ * Tenant A IS the standalone workspace, not a workspace beside it.
+ *
+ * `POST /api/auth/login` in standalone mode looks the user up under
+ * `SINGLE_TENANT_ID || '00000000-0000-0000-0000-000000000000'` (server/api/auth.ts)
+ * — the tenant is never derived from the submitted email. A fixture user in any
+ * other tenant is therefore unloggable by construction, whatever its password
+ * hash says. Tenant A used to be `…0aaa`, so even a correct hash could not have
+ * produced a session.
+ *
+ * Tenant B stays a genuinely separate tenant: it exists to give the multi-tenant
+ * fixtures a second workspace to be switched INTO, which is a portal/SaaS flow,
+ * not a standalone password login.
+ */
+const TENANT_A_ID = '00000000-0000-0000-0000-000000000000';
const TENANT_B_ID = '00000000-0000-0000-0000-000000000bbb';
+/**
+ * Address the DB the way global-setup does: by BINDING (`DB`) against the same
+ * wrangler config the worker was built from.
+ *
+ * Both halves are load-bearing and both were wrong here. `openinspection-standalone-db`
+ * is not a database in any config in this repo (the name is `openinspection-db`,
+ * the binding is `DB`), and without `-c` wrangler auto-discovers only
+ * `wrangler.jsonc` — so a run driven by `WRANGLER_CONFIG` or `wrangler.local.jsonc`
+ * would target a different persisted SQLite than the worker reads. This is the
+ * identical mistake global-setup.ts documents having made and fixed; the fix was
+ * never carried across to this file, so `seedFixtures` threw on its very first
+ * statement and global-setup swallowed it as a warning.
+ */
+function d1Command(cwd: string): (sql: string) => string {
+ const cfg =
+ process.env.WRANGLER_CONFIG ||
+ (existsSync(path.join(cwd, 'wrangler.local.jsonc')) ? 'wrangler.local.jsonc' : 'wrangler.jsonc');
+ // Collapse the whitespace of these multi-line template literals: on Windows
+ // execSync spawns through cmd.exe, where an embedded newline ends the command.
+ return (sql: string) => {
+ const flat = sql.replace(/\s+/g, ' ').trim().replaceAll('"', '\\"');
+ return `npx wrangler d1 execute DB --local -c ${cfg} --command "${flat}" --yes`;
+ };
+}
+
function d1(sql: string, cwd: string): void {
- const escaped = sql.replaceAll('"', '\\"');
try {
- execSync(
- `npx wrangler d1 execute openinspection-standalone-db --local --command "${escaped}" --yes`,
- { cwd, stdio: 'pipe' },
- );
+ execSync(d1Command(cwd)(sql), { cwd, stdio: 'pipe' });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Re-raise so the setup fails loudly when a fixture row violates
@@ -53,9 +101,17 @@ export function seedFixtures(appDir: string): void {
VALUES ('${TENANT_B_ID}', 'Seed Tenant B', 'seed-b', 'active', 'shared', 'free', 5, '${now}')`, cwd);
// Tenant A users.
+ //
+ // Roles come from ROLES in server/lib/auth/roles.ts — owner / manager /
+ // inspector / agent. These rows previously said `admin`, which is not one of
+ // them: `requireRole('owner', …)` never matches it and `getCapabilities()`
+ // indexes ROLE_DEFAULTS by role, so an `admin` row has NO capability set at
+ // all. The drizzle `{ enum: [...] }` is type-layer only and costs no DDL, so
+ // SQLite accepted the value and the damage only showed up as authorization
+ // failures far from here.
d1(`INSERT OR REPLACE INTO users (id, tenant_id, email, password_hash, name, role, created_at)
VALUES ('11111111-1111-1111-1111-111111111aa1', '${TENANT_A_ID}',
- '${ADMIN_EMAIL}', '${SEED_PASSWORD_HASH}', 'Seed Admin', 'admin', '${now}')`, cwd);
+ '${ADMIN_EMAIL}', '${SEED_PASSWORD_HASH}', 'Seed Admin', 'owner', '${now}')`, cwd);
d1(`INSERT OR REPLACE INTO users (id, tenant_id, email, password_hash, name, role, created_at)
VALUES ('22222222-2222-2222-2222-222222222aa1', '${TENANT_A_ID}',
'${LEAD_EMAIL}', '${SEED_PASSWORD_HASH}', 'Lead Inspector', 'inspector', '${now}')`, cwd);
@@ -69,32 +125,45 @@ export function seedFixtures(appDir: string): void {
'active', 'shared', 'free', 1, '${now}')`, cwd);
d1(`INSERT OR REPLACE INTO users (id, tenant_id, email, password_hash, name, role, created_at)
VALUES ('55555555-5555-5555-5555-555555555cc1', '00000000-0000-0000-0000-000000000cc1',
- '${ADMIN_FULL_EMAIL}', '${SEED_PASSWORD_HASH}', 'At-Cap Admin', 'admin', '${now}')`, cwd);
+ '${ADMIN_FULL_EMAIL}', '${SEED_PASSWORD_HASH}', 'At-Cap Admin', 'owner', '${now}')`, cwd);
// Multi-tenant fixture users (tenant A primary + tenant B branch).
d1(`INSERT OR REPLACE INTO users (id, tenant_id, email, password_hash, name, role, created_at)
VALUES ('66666666-6666-6666-6666-666666666aa1', '${TENANT_A_ID}',
- '${MULTI_EMAIL}', '${SEED_PASSWORD_HASH}', 'Multi-Tenant Primary', 'admin', '${now}')`, cwd);
+ '${MULTI_EMAIL}', '${SEED_PASSWORD_HASH}', 'Multi-Tenant Primary', 'owner', '${now}')`, cwd);
d1(`INSERT OR REPLACE INTO users (id, tenant_id, email, password_hash, name, role, created_at)
VALUES ('77777777-7777-7777-7777-777777777bb1', '${TENANT_B_ID}',
- '${BRANCH_B_EMAIL}', '${SEED_PASSWORD_HASH}', 'Branch B Identity', 'admin', '${now}')`, cwd);
+ '${BRANCH_B_EMAIL}', '${SEED_PASSWORD_HASH}', 'Branch B Identity', 'owner', '${now}')`, cwd);
- // Inspections — empty / half-done / team / delivered / republished
+ // Inspections — empty / half-done / team / published / delivered / republished
// referenced by the E2E spec stubs. Templates intentionally NULL so
// the editor falls back to the seed template path.
- const inspectionRow = (id: string, addr: string, status: string, tenantId = TENANT_A_ID) =>
+ //
+ // Column names and status values are BOTH the current ones. This row used to
+ // name `price` / `payment_required` / `agreement_required` (now `price_cents`
+ // / `is_payment_required` / `is_agreement_required`) and to pass `draft` and
+ // `delivered` as the order status — neither is in INSPECTION_STATUS
+ // (requested / scheduled / confirmed / completed / cancelled); "published" is
+ // a REPORT status, which is the separate column set alongside it here.
+ const inspectionRow = (
+ id: string,
+ addr: string,
+ status: string,
+ reportStatus: string,
+ tenantId = TENANT_A_ID,
+ ) =>
`INSERT OR REPLACE INTO inspections
- (id, tenant_id, inspector_id, property_address, date, status, payment_status,
- price, payment_required, agreement_required, created_at)
+ (id, tenant_id, inspector_id, property_address, date, status, report_status, payment_status,
+ price_cents, is_payment_required, is_agreement_required, created_at)
VALUES ('${id}', '${tenantId}',
'22222222-2222-2222-2222-222222222aa1', '${addr}',
- '2026-06-01', '${status}', 'unpaid', 0, 0, 0, '${now}')`;
- d1(inspectionRow('seed-empty-inspection', '1 Empty St', 'draft'), cwd);
- d1(inspectionRow('seed-half-done-inspection', '2 Half Done Ave', 'draft'), cwd);
- d1(inspectionRow('seed-team-inspection', '3 Team Mode Rd', 'draft'), cwd);
- d1(inspectionRow('seed-published-inspection', '4 Published Way', 'delivered'), cwd);
- d1(inspectionRow('seed-delivered-inspection', '5 Delivered Ln', 'delivered'), cwd);
- d1(inspectionRow('seed-republished-inspection', '6 Republished Ct', 'delivered'), cwd);
+ '2026-06-01', '${status}', '${reportStatus}', 'unpaid', 0, 0, 0, '${now}')`;
+ d1(inspectionRow('seed-empty-inspection', '1 Empty St', 'scheduled', 'in_progress'), cwd);
+ d1(inspectionRow('seed-half-done-inspection', '2 Half Done Ave', 'scheduled', 'in_progress'), cwd);
+ d1(inspectionRow('seed-team-inspection', '3 Team Mode Rd', 'scheduled', 'in_progress'), cwd);
+ d1(inspectionRow('seed-published-inspection', '4 Published Way', 'completed', 'published'), cwd);
+ d1(inspectionRow('seed-delivered-inspection', '5 Delivered Ln', 'completed', 'published'), cwd);
+ d1(inspectionRow('seed-republished-inspection', '6 Republished Ct', 'completed', 'published'), cwd);
console.info('[seed-fixtures] Seeded tenants + 7 users + 6 inspections.');
}
diff --git a/tests/unit/agreements/agreement-signers.spec.ts b/tests/unit/agreements/agreement-signers.spec.ts
index fde996331..eea25a469 100644
--- a/tests/unit/agreements/agreement-signers.spec.ts
+++ b/tests/unit/agreements/agreement-signers.spec.ts
@@ -138,11 +138,11 @@ describe('AgreementService — signer-level envelope state machine', () => {
const link1 = await svc.getSignerLink(TENANT_A, r.requestId, signers[0].id);
const link2 = await svc.getSignerLink(TENANT_A, r.requestId, signers[1].id);
- const first = await svc.markSignedBySigner(link1, 'sig-jane', { signedAtMs: 1000, channel: 'remote' });
+ const first = await svc.markSignedBySigner(link1, 'sig-jane', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null });
expect(first.envelopeCompletedNow).toBe(false);
expect(first.envelopeStatus).toBe('viewed');
- const second = await svc.markSignedBySigner(link2, 'sig-john', { signedAtMs: 2000, channel: 'remote' });
+ const second = await svc.markSignedBySigner(link2, 'sig-john', { signedAtMs: 2000, channel: 'remote', languageDisclosureVersion: null });
expect(second.envelopeCompletedNow).toBe(true);
expect(second.envelopeStatus).toBe('signed');
@@ -163,7 +163,7 @@ describe('AgreementService — signer-level envelope state machine', () => {
const signers = await testDb.select().from(schema.agreementSigners)
.orderBy(asc(schema.agreementSigners.createdAt)).all();
const link1 = await svc.getSignerLink(TENANT_A, r.requestId, signers[0].id);
- const res = await svc.markSignedBySigner(link1, 'sig-jane', { signedAtMs: 1000, channel: 'in_person' });
+ const res = await svc.markSignedBySigner(link1, 'sig-jane', { signedAtMs: 1000, channel: 'in_person', languageDisclosureVersion: null });
expect(res.envelopeCompletedNow).toBe(true);
expect(res.envelopeStatus).toBe('signed');
});
@@ -205,9 +205,9 @@ describe('AgreementService — signer-level envelope state machine', () => {
});
const s = await testDb.select().from(schema.agreementSigners).all();
const link = await svc.getSignerLink(TENANT_A, r.requestId, s[0].id);
- const first = await svc.markSignedBySigner(link, 'sig', { signedAtMs: 1000, channel: 'remote' });
+ const first = await svc.markSignedBySigner(link, 'sig', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null });
expect(first.envelopeCompletedNow).toBe(true);
- const second = await svc.markSignedBySigner(link, 'sig-again', { signedAtMs: 2000, channel: 'remote' });
+ const second = await svc.markSignedBySigner(link, 'sig-again', { signedAtMs: 2000, channel: 'remote', languageDisclosureVersion: null });
expect(second.envelopeCompletedNow).toBe(false);
});
@@ -328,6 +328,7 @@ describe('AgreementService — signer-level envelope state machine', () => {
signedAtMs: 5000, channel: 'in_person',
ipAddress: '1.2.3.4', userAgent: 'UA/1.0',
onBehalfOf: 'Jane Buyer', onBehalfDisclaimer: 'authorized agent',
+ languageDisclosureVersion: null,
});
const row = await testDb.select().from(schema.agreementSigners).where(eq(schema.agreementSigners.id, s[0].id)).get();
expect(row!.channel).toBe('in_person');
@@ -338,6 +339,32 @@ describe('AgreementService — signer-level envelope state machine', () => {
expect(row!.signatureBase64).toBe('sig');
});
+ // The version is evidence, so it has to land on the row that carries the
+ // signature — not be inferred later from a timestamp against a changelog.
+ it('records the language-disclosure version the caller states', async () => {
+ const r = await svc.findOrCreate(TENANT_A, INSP_ID, { signers: [{ name: 'Jane', email: 'jane@test.com' }], completionPolicy: 'one' });
+ const s = await testDb.select().from(schema.agreementSigners).all();
+ const link = await svc.getSignerLink(TENANT_A, r.requestId, s[0].id);
+ await svc.markSignedBySigner(link, 'sig', {
+ signedAtMs: 5000, channel: 'remote', languageDisclosureVersion: 7,
+ });
+ const row = await testDb.select().from(schema.agreementSigners).where(eq(schema.agreementSigners.id, s[0].id)).get();
+ // 7, not "whatever the constant says today" — the service must not
+ // substitute its own idea of the current version for the caller's claim.
+ expect(row!.languageDisclosureVersion).toBe(7);
+ });
+
+ it('leaves the version NULL when the caller cannot vouch for a screen', async () => {
+ const r = await svc.findOrCreate(TENANT_A, INSP_ID, { signers: [{ name: 'Jane', email: 'jane@test.com' }], completionPolicy: 'one' });
+ const s = await testDb.select().from(schema.agreementSigners).all();
+ const link = await svc.getSignerLink(TENANT_A, r.requestId, s[0].id);
+ await svc.markSignedBySigner(link, 'sig', {
+ signedAtMs: 5000, channel: 'in_person', languageDisclosureVersion: null,
+ });
+ const row = await testDb.select().from(schema.agreementSigners).where(eq(schema.agreementSigners.id, s[0].id)).get();
+ expect(row!.languageDisclosureVersion).toBeNull();
+ });
+
it('single-fire: sign A (1/2) then sign B TWICE -> exactly one envelopeCompletedNow=true', async () => {
const r = await svc.findOrCreate(TENANT_A, INSP_ID, {
signers: [
@@ -352,13 +379,13 @@ describe('AgreementService — signer-level envelope state machine', () => {
const linkB = await svc.getSignerLink(TENANT_A, r.requestId, signers[1].id);
// A signs first: envelope 1/2, not complete.
- const a = await svc.markSignedBySigner(linkA, 'sig-jane', { signedAtMs: 1000, channel: 'remote' });
+ const a = await svc.markSignedBySigner(linkA, 'sig-jane', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null });
expect(a.envelopeCompletedNow).toBe(false);
// B signs (2/2) — this completes the envelope. Second call is the
// idempotent re-sign of an already-signed signer.
- const b1 = await svc.markSignedBySigner(linkB, 'sig-john', { signedAtMs: 2000, channel: 'remote' });
- const b2 = await svc.markSignedBySigner(linkB, 'sig-john-again', { signedAtMs: 3000, channel: 'remote' });
+ const b1 = await svc.markSignedBySigner(linkB, 'sig-john', { signedAtMs: 2000, channel: 'remote', languageDisclosureVersion: null });
+ const b2 = await svc.markSignedBySigner(linkB, 'sig-john-again', { signedAtMs: 3000, channel: 'remote', languageDisclosureVersion: null });
const fires = [a, b1, b2].filter((x) => x.envelopeCompletedNow).length;
expect(fires).toBe(1);
@@ -383,8 +410,8 @@ describe('AgreementService — signer-level envelope state machine', () => {
// deterministically, but the service awaits between read + write, so the
// atomic claim (conditional UPDATE row-count) is what guarantees single-fire.
const [c1, c2] = await Promise.all([
- svc.markSignedBySigner(link, 'sig-1', { signedAtMs: 1000, channel: 'remote' }),
- svc.markSignedBySigner(link, 'sig-2', { signedAtMs: 1000, channel: 'remote' }),
+ svc.markSignedBySigner(link, 'sig-1', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null }),
+ svc.markSignedBySigner(link, 'sig-2', { signedAtMs: 1000, channel: 'remote', languageDisclosureVersion: null }),
]);
const fires = [c1, c2].filter((x) => x.envelopeCompletedNow).length;
expect(fires).toBeLessThanOrEqual(1);
@@ -475,7 +502,7 @@ describe('AgreementService — signer-level envelope state machine', () => {
// Sign as Jane → envelope may stay 'viewed' (policy 'all', Bob outstanding),
// but Jane's signer row becomes 'signed'. Her token must still resolve.
await svc.markViewedBySigner(janeToken);
- await svc.markSignedBySigner(janeToken, 'data:image/png;base64,XX', { signedAtMs: Date.now(), channel: 'remote' });
+ await svc.markSignedBySigner(janeToken, 'data:image/png;base64,XX', { signedAtMs: Date.now(), channel: 'remote', languageDisclosureVersion: null });
const link = await svc.getSignerLinkByEmail(TENANT_A, INSP_ID, 'jane@test.com');
expect(link).toBeTruthy();
});
diff --git a/tests/unit/agreements/agreement.service.spec.ts b/tests/unit/agreements/agreement.service.spec.ts
index 96d5e66d5..2adf06215 100644
--- a/tests/unit/agreements/agreement.service.spec.ts
+++ b/tests/unit/agreements/agreement.service.spec.ts
@@ -29,6 +29,6 @@ describe('AgreementService', () => {
it('markSignedBySigner on a declined signer throws Conflict', async () => {
const { token } = await svc.findOrCreate(TENANT_A, INSP_ID);
await svc.markDeclinedBySigner(token);
- await expect(svc.markSignedBySigner(token, 'sig', { signedAtMs: Date.now(), channel: 'remote' })).rejects.toThrow();
+ await expect(svc.markSignedBySigner(token, 'sig', { signedAtMs: Date.now(), channel: 'remote', languageDisclosureVersion: null })).rejects.toThrow();
});
});
diff --git a/tests/unit/agreements/agreements-render.spec.ts b/tests/unit/agreements/agreements-render.spec.ts
index 31d6b0244..d77a5a065 100644
--- a/tests/unit/agreements/agreements-render.spec.ts
+++ b/tests/unit/agreements/agreements-render.spec.ts
@@ -7,6 +7,7 @@ import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
import { agreementRenderHandler, certRenderHandler } from '../../../server/api/agreements-render';
+import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../../../server/lib/legal/agreement-language-disclosure';
const TENANT_A = '00000000-0000-0000-0000-000000000001';
const INSP_ID = '00000000-0000-0000-0000-000000000010';
@@ -362,3 +363,234 @@ describe('cert-render handler', () => {
expect(body).toContain('hash2aaaaaaaaaaa');
});
});
+
+// ---------------------------------------------------------------------------
+// Language disclosure in the ARCHIVED copy (the document a dispute produces).
+//
+// The signing screen and this document have to agree. A signer told on screen
+// that the agreement is English-only, holding a signed PDF that says nothing of
+// the kind, is left worse off than if we had never shown the note: the record
+// now contradicts what happened.
+//
+// Equally, the note must stay OUT of the body box. That div holds the pinned
+// content snapshot verbatim, `content_hash` is taken over the stored string, and
+// anything added inside it would both rewrite the record of what was signed and
+// make us the author of a term in a contract we are not a party to.
+//
+// And the agreement has to be MUTUAL: the document may only carry the note when
+// the signatures on it recorded the version of the copy that is live now. There
+// is no archive of superseded copy, so against an older signature the choice is
+// between printing nothing and printing words that signer never read.
+// ---------------------------------------------------------------------------
+
+const SIGNER_ID = '00000000-0000-0000-0000-000000000200';
+
+/**
+ * A signed signer on REQ_ID whose record says which disclosure version it saw.
+ * `version` null = the record says nothing (pre-feature signature, or the
+ * on-site API surface the platform does not draw).
+ */
+async function insertSignedSigner(
+ db: BetterSQLite3Database,
+ version: number | null,
+): Promise {
+ await db.insert(schema.agreementSigners).values({
+ id: SIGNER_ID, tenantId: TENANT_A, requestId: REQ_ID,
+ name: 'Jane Doe', email: 'jane@x', role: 'client', status: 'signed',
+ signatureBase64: 'data:image/png;base64,clientsig',
+ signedAt: new Date(), createdAt: new Date(),
+ languageDisclosureVersion: version,
+ });
+}
+
+/** The verbatim contents of the `.body` box — the snapshot, and nothing else. */
+function bodyBoxOf(html: string): string {
+ // escapeHtml() leaves no markup inside the box, so the first closing tag is
+ // the box's own. Asserted below rather than assumed.
+ const m = html.match(/([\s\S]*?)<\/div>/);
+ return m ? m[1] : '';
+}
+
+describe('agreement-render handler — language disclosure', () => {
+ let db: BetterSQLite3Database
;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ await db.insert(schema.tenants).values({
+ id: TENANT_A, name: 'A', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.inspections).values({
+ id: INSP_ID, tenantId: TENANT_A, propertyAddress: '1 Main St', clientName: 'Jane',
+ clientEmail: 'jane@x', date: '2026-06-01', status: 'requested', paymentStatus: 'unpaid',
+ price: 0, createdAt: new Date(),
+ } as never);
+ await db.insert(schema.agreements).values({
+ id: AGR_ID, tenantId: TENANT_A, name: 'Standard', content: 'Agreement body
',
+ version: 1, createdAt: new Date(),
+ });
+ await db.insert(schema.agreementRequests).values({
+ id: REQ_ID, tenantId: TENANT_A, inspectionId: INSP_ID, agreementId: AGR_ID,
+ clientEmail: 'jane@x', clientName: 'Jane Doe',
+ token: TOKEN_A, status: 'signed',
+ signatureBase64: 'data:image/png;base64,clientsig',
+ signedAt: new Date(),
+ contentSnapshot: 'Snapshot at sign time
',
+ contentHash: 'deadbeef',
+ createdAt: new Date(),
+ });
+ await insertSignedSigner(db, AGREEMENT_LANGUAGE_DISCLOSURE.version);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+ });
+
+ it('carries the disclosure into the signed document', async () => {
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ expect(html).toMatch(/provided in English/i);
+ expect(html).toContain('Not part of this agreement');
+ // The wrapper travels with it: the shape is what marks it as a note.
+ expect(html).toContain('role="note"');
+ });
+
+ it('places it OUTSIDE the body box', async () => {
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ const box = bodyBoxOf(html);
+ // Prove the extractor sees the box before trusting what it does not see.
+ expect(box).toContain('Snapshot at sign time');
+ expect(box).not.toMatch(/provided in English/i);
+ expect(box).not.toContain('Not part of this agreement');
+ // …and it lands after the box, before the signatures — a note about the
+ // document, read in the order a person reads the page.
+ expect(html.indexOf('Not part of this agreement')).toBeGreaterThan(html.indexOf('Snapshot at sign time'));
+ const sigBlock = html.indexOf('');
+ expect(sigBlock).toBeGreaterThan(-1);
+ expect(html.indexOf('Not part of this agreement')).toBeLessThan(sigBlock);
+ });
+
+ it('writes nothing — the snapshot and its hash survive the render', async () => {
+ await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const row = await db.select().from(schema.agreementRequests)
+ .where(eq(schema.agreementRequests.id, REQ_ID)).get();
+ expect(row!.contentSnapshot).toBe('
Snapshot at sign time
');
+ expect(row!.contentSnapshot).not.toMatch(/provided in English/i);
+ // contentHash is SHA-256 of the stored string. Because the disclosure never
+ // enters that string, no existing signature is invalidated by shipping this.
+ expect(row!.contentHash).toBe('deadbeef');
+ const agreement = await db.select().from(schema.agreements)
+ .where(eq(schema.agreements.id, AGR_ID)).get();
+ expect(agreement!.content).toBe('
Agreement body
');
+ });
+
+ // The three cases below are the whole reason the version is on the signature.
+ // Each replaces the signer seeded in beforeEach, so the ONLY difference
+ // between them and the passing case above is what the record says.
+
+ it('omits it when the signature recorded no version at all', async () => {
+ await db.delete(schema.agreementSigners).where(eq(schema.agreementSigners.id, SIGNER_ID));
+ await insertSignedSigner(db, null);
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ // The document still renders — it is only the claim that is withheld.
+ expect(html).toContain('Snapshot at sign time');
+ expect(html).not.toMatch(/provided in English/i);
+ expect(html).not.toContain('Not part of this agreement');
+ });
+
+ it('omits it when the signature recorded a SUPERSEDED version', async () => {
+ await db.delete(schema.agreementSigners).where(eq(schema.agreementSigners.id, SIGNER_ID));
+ await insertSignedSigner(db, AGREEMENT_LANGUAGE_DISCLOSURE.version - 1);
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ expect(html).toContain('Snapshot at sign time');
+ // Superseded copy is not archived. Printing today's words here would put a
+ // sentence in front of a judge that this signer demonstrably did not read.
+ expect(html).not.toMatch(/provided in English/i);
+ });
+
+ it('omits it when ONE of several signers has no version — the document is one record', async () => {
+ await db.insert(schema.agreementSigners).values({
+ id: '00000000-0000-0000-0000-000000000201', tenantId: TENANT_A, requestId: REQ_ID,
+ name: 'John Doe', email: 'john@x', role: 'co_client', status: 'signed',
+ signatureBase64: 'data:image/png;base64,cosig',
+ signedAt: new Date(), createdAt: new Date(),
+ languageDisclosureVersion: null,
+ });
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ expect(html).not.toMatch(/provided in English/i);
+ });
+
+ it('the legacy envelope-level signature (no signer rows) carries no claim', async () => {
+ await db.delete(schema.agreementSigners).where(eq(schema.agreementSigners.requestId, REQ_ID));
+ const res = await agreementRenderHandler({} as D1Database, 'acme', REQ_ID);
+ const html = await res.text();
+ // Fallback block still renders the signature…
+ expect(html).toContain('Jane Doe');
+ // …and says nothing about a notice nobody recorded.
+ expect(html).not.toMatch(/provided in English/i);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The certificate of completion is a different kind of document: it states
+// FACTS ABOUT the signing event rather than reproducing what was signed. So it
+// can report a superseded version number honestly — "notice v1 was displayed"
+// is true forever — where the archived copy above cannot reproduce v1's words.
+// ---------------------------------------------------------------------------
+describe('cert-render handler — language disclosure version', () => {
+ let db: BetterSQLite3Database
;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ await db.insert(schema.tenants).values({
+ id: TENANT_A, name: 'A', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.inspections).values({
+ id: INSP_ID, tenantId: TENANT_A, propertyAddress: '1 Main St', clientName: 'Jane',
+ clientEmail: 'jane@x', date: '2026-06-01', status: 'requested', paymentStatus: 'unpaid',
+ price: 0, createdAt: new Date(),
+ } as never);
+ await db.insert(schema.agreements).values({
+ id: AGR_ID, tenantId: TENANT_A, name: 'Standard', content: 'Agreement body
',
+ version: 1, createdAt: new Date(),
+ });
+ await db.insert(schema.agreementRequests).values({
+ id: REQ_ID, tenantId: TENANT_A, inspectionId: INSP_ID, agreementId: AGR_ID,
+ clientEmail: 'jane@x', clientName: 'Jane Doe',
+ token: TOKEN_A, status: 'signed',
+ signatureBase64: 'data:image/png;base64,clientsig',
+ signedAt: new Date(), createdAt: new Date(),
+ });
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+ });
+
+ it('reports the version the signer was shown', async () => {
+ await insertSignedSigner(db, AGREEMENT_LANGUAGE_DISCLOSURE.version);
+ const res = await certRenderHandler({} as D1Database, REQ_ID);
+ const html = await res.text();
+ expect(html).toContain(`Language notice v${AGREEMENT_LANGUAGE_DISCLOSURE.version} displayed`);
+ });
+
+ it('reports a version that is NOT the current one rather than suppressing it', async () => {
+ const superseded = AGREEMENT_LANGUAGE_DISCLOSURE.version - 1;
+ await insertSignedSigner(db, superseded);
+ const res = await certRenderHandler({} as D1Database, REQ_ID);
+ const html = await res.text();
+ expect(html).toContain(`Language notice v${superseded} displayed`);
+ });
+
+ it('says nothing when nothing was recorded — no "v0", no "none"', async () => {
+ await insertSignedSigner(db, null);
+ const res = await certRenderHandler({} as D1Database, REQ_ID);
+ const html = await res.text();
+ // Prove the roster rendered before trusting the absence.
+ expect(html).toContain('Jane Doe');
+ expect(html).not.toMatch(/Language notice/i);
+ });
+});
diff --git a/tests/unit/agreements/language-disclosure.spec.ts b/tests/unit/agreements/language-disclosure.spec.ts
new file mode 100644
index 000000000..8bdbfa22d
--- /dev/null
+++ b/tests/unit/agreements/language-disclosure.spec.ts
@@ -0,0 +1,403 @@
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, it, expect } from 'vitest';
+import {
+ AGREEMENT_LANGUAGE_DISCLOSURE as D,
+ DISCLOSURE_SANITIZER_PROFILE,
+ signaturesRecordCurrentDisclosure,
+} from '../../../server/lib/legal/agreement-language-disclosure';
+import { sanitizeAgreementHtml } from '../../../server/services/agreement/sanitizer';
+
+const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+
+describe('agreement language disclosure', () => {
+ it('states the fact', () => {
+ expect(D.html).toMatch(/provided in English/i);
+ expect(D.html).toMatch(/translated before signing/i);
+ });
+
+ it('makes NO contractual assertion', () => {
+ // Counsel: a disclosure may state a fact, it may not decide which text
+ // prevails. Every word below allocates risk between two parties we are
+ // not one of. This test is the line, and it is why the plan was rewritten.
+ // The heading is held to the same standard as the sentence: it is shown
+ // to the signer in the same block and carries the same risk of reading
+ // as a term.
+ for (const forbidden of [/govern/i, /prevail/i, /controls?/i,
+ /binding/i, /conflict between/i, /shall/i]) {
+ expect(D.html).not.toMatch(forbidden);
+ expect(D.label).not.toMatch(forbidden);
+ }
+ });
+
+ it('carries a heading that says what the block is NOT', () => {
+ // Position is the whole instruction, and a reader does not infer position
+ // from a border. The heading states it in words, so it travels with the
+ // copy instead of living in whichever component happens to render it.
+ expect(D.label).toMatch(/not part of this agreement/i);
+ // Plain text: renderers escape it or print it as a text node. Markup here
+ // would mean two sanitizer stories for one constant.
+ expect(D.label).not.toMatch(/[<>]/);
+ });
+
+ it('does not reproduce the InterNACHI clause', () => {
+ // That wording is written for the INSPECTOR to place in THEIR agreement.
+ // Borrowing it makes the platform the author of a term.
+ expect(D.html).not.toMatch(/QUALIFIED EXPERT TRANSLATE THIS AGREEMENT/i);
+ });
+
+ it('contains no element the sanitizer strips', () => {
+ expect(D.html).not.toMatch(/<(a|img|svg|script|iframe)/i);
+ });
+
+ it('is versioned', () => {
+ expect(D.version).toBeGreaterThan(0);
+ });
+
+ it('is frozen — the copy is not a string a component may edit', () => {
+ expect(Object.isFrozen(D)).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// This repo is PUBLIC. The module's header used to carry counsel's preliminary
+// position, the platform's legal posture, and a citation to a document that
+// exists only in the private superproject — and the guard here asserted all of
+// it STAYED. That was backwards twice over: it published private legal analysis
+// from an open-source file, and it could only be verified from a checkout that
+// has the superproject above it, so it failed on CI, where this repo is checked
+// out alone. That failure was the useful part: a guard that cannot run where the
+// code is published is not guarding the code that is published.
+//
+// So the guard is inverted. What must survive is the ENGINEERING instruction —
+// do not turn this notice into a contractual term, do not grow it into
+// translated agreements. What must NOT survive is anything a reader outside this
+// company was never meant to see.
+// ---------------------------------------------------------------------------
+describe('agreement language disclosure — the module stays publishable', () => {
+ const MODULE = 'server/lib/legal/agreement-language-disclosure.ts';
+ const src = () => readFileSync(join(REPO_ROOT, MODULE), 'utf8');
+
+ it('cites no path outside this repository', () => {
+ // A private path in a public file is either a leak or a dead link, and
+ // both are found by the same check. `docs/legal/` lives in the
+ // superproject; nothing here may reach for it.
+ expect(src()).not.toMatch(/docs\/legal\//);
+ // Prove the read is of the module and not an empty string.
+ expect(src()).toContain('DISCLOSURE_VERSION');
+ });
+
+ it('carries no counsel record, jurisdiction analysis, or platform legal posture', () => {
+ for (const forbidden of [/counsel/i, /\b1632\b/, /Civil Code/i, /not a party/i]) {
+ expect(src(), `${forbidden} reads as private legal material in a public repo`)
+ .not.toMatch(forbidden);
+ }
+ });
+
+ it('still stops the next reader from translating the agreement body', () => {
+ // This is the instruction worth keeping, and it survives the pruning
+ // above only because it is asserted here. It is engineering guidance —
+ // what this feature is not — with no legal claim attached.
+ expect(src()).toMatch(/translating the agreement body|agreement BODY/i);
+ expect(src()).toMatch(/own legal advice/i);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// What an EVIDENCE surface may say. The signing screen shows a person the copy
+// that exists while they are standing there — always truthful. A document
+// produced afterwards is making a claim about the past, and may only make it
+// when the record supports it.
+// ---------------------------------------------------------------------------
+describe('agreement language disclosure — what the record supports', () => {
+ it('says yes when every signature recorded the version that is live now', () => {
+ expect(signaturesRecordCurrentDisclosure([D.version])).toBe(true);
+ expect(signaturesRecordCurrentDisclosure([D.version, D.version])).toBe(true);
+ });
+
+ it('says no when a signature recorded nothing', () => {
+ // Pre-feature signatures, and the on-site API surface where the caller —
+ // not the platform — draws the screen.
+ expect(signaturesRecordCurrentDisclosure([null])).toBe(false);
+ expect(signaturesRecordCurrentDisclosure([undefined])).toBe(false);
+ });
+
+ it('says no when a signature recorded a DIFFERENT version', () => {
+ // Superseded copy is archived nowhere: a bump replaces the string. So the
+ // only alternatives for an older signature are printing nothing and
+ // printing words that signer never saw.
+ expect(signaturesRecordCurrentDisclosure([D.version - 1])).toBe(false);
+ expect(signaturesRecordCurrentDisclosure([D.version + 1])).toBe(false);
+ });
+
+ it('needs EVERY signature, not just one — the document is a single record', () => {
+ expect(signaturesRecordCurrentDisclosure([D.version, null])).toBe(false);
+ expect(signaturesRecordCurrentDisclosure([null, D.version])).toBe(false);
+ });
+
+ it('says no on an empty set — "every" is vacuously true and would be a lie', () => {
+ // A document with no signatures on it is not a document that shows what
+ // anyone was shown.
+ expect(signaturesRecordCurrentDisclosure([])).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Who may put a version on the record. Only a surface the PLATFORM renders can
+// state what a signer saw; an API that hands the agreement text to a caller and
+// takes back a signature knows nothing about the screen in between.
+// ---------------------------------------------------------------------------
+describe('agreement language disclosure — who may claim a version', () => {
+ const PLATFORM_RENDERED_SIGN_ROUTE = 'server/api/bookings/agreement.ts';
+ const CALLER_RENDERED_SIGN_ROUTE = 'server/api/inspections/agreements.ts';
+
+ /** The `languageDisclosureVersion:` argument each sign call passes. */
+ function versionArgIn(src: string): string | null {
+ const m = src.match(/languageDisclosureVersion:\s*([^,\n]+)/);
+ return m ? m[1].trim() : null;
+ }
+
+ it('the extractor actually extracts', () => {
+ expect(versionArgIn(' languageDisclosureVersion: null,')).toBe('null');
+ expect(versionArgIn('const x = 1;')).toBeNull();
+ });
+
+ it('the route serving the platform-drawn signing pages records the live version', () => {
+ const src = readFileSync(join(REPO_ROOT, PLATFORM_RENDERED_SIGN_ROUTE), 'utf8');
+ expect(versionArgIn(src), `${PLATFORM_RENDERED_SIGN_ROUTE} stopped recording a version`)
+ .toBe('AGREEMENT_LANGUAGE_DISCLOSURE.version');
+ });
+
+ it('the on-site API route records NOTHING — it did not draw the screen', () => {
+ // `GET /:id/agreement` hands the agreement text to a caller that renders
+ // its own surface. A version written here would assert something the
+ // platform cannot know. Give this endpoint a surface we render and the
+ // answer changes; until then null is the only true value.
+ const src = readFileSync(join(REPO_ROOT, CALLER_RENDERED_SIGN_ROUTE), 'utf8');
+ expect(versionArgIn(src), `${CALLER_RENDERED_SIGN_ROUTE} now claims a version it cannot know`)
+ .toBe('null');
+ });
+});
+
+/** Every element name that appears in `html`, lowercased. */
+function tagsIn(html: string): string[] {
+ return [...html.matchAll(/<\/?([a-z][a-z0-9-]*)\b/gi)].map((m) => m[1].toLowerCase());
+}
+
+/** Every attribute name that appears in `html`, lowercased. */
+function attrsIn(html: string): string[] {
+ return [...html.matchAll(/\s([a-z][a-z0-9-]*)\s*=/gi)].map((m) => m[1].toLowerCase());
+}
+
+// DOMPurify is deliberately NOT invoked in this spec. Measured here: under
+// happy-dom it drops the outermost element and applies no allow-list at all
+// (`hi
` sanitizes to `hi`; a `` nested one level deep
+// survives a profile that permits neither). A round-trip assertion against it
+// would have passed while proving nothing about a browser. What IS faithful is
+// DOMPurify's contract — a tag absent from ALLOWED_TAGS is removed — applied to
+// the two allow-lists read from their real source below.
+describe('agreement language disclosure — what reaches the screen', () => {
+ it('its own render profile covers every tag and attribute in the copy', () => {
+ for (const tag of tagsIn(D.html)) {
+ expect(DISCLOSURE_SANITIZER_PROFILE.ALLOWED_TAGS).toContain(tag);
+ }
+ for (const attr of attrsIn(D.html)) {
+ expect(DISCLOSURE_SANITIZER_PROFILE.ALLOWED_ATTR).toContain(attr);
+ }
+ // The extractor is the test; prove it can see something.
+ expect(tagsIn(D.html)).toContain('section');
+ expect(attrsIn(D.html)).toContain('role');
+ });
+
+ it('the render profile admits no element that could carry a payload', () => {
+ for (const tag of ['a', 'img', 'svg', 'script', 'iframe', 'style', 'form']) {
+ expect(DISCLOSURE_SANITIZER_PROFILE.ALLOWED_TAGS).not.toContain(tag);
+ }
+ for (const attr of DISCLOSURE_SANITIZER_PROFILE.ALLOWED_ATTR) {
+ expect(attr).not.toMatch(/^on|href|src|style/i);
+ }
+ });
+
+ it('the agreement view component would strip the wrapper — so it must not render this', () => {
+ // Read the allow-list from , the component the agreement
+ // body is rendered with, rather than restating it here: a copy would go
+ // stale and turn this into a green that means nothing.
+ const src = readFileSync(join(REPO_ROOT, 'app/components/SanitizedHtml.tsx'), 'utf8');
+ const tags = src.match(/const ALLOWED_TAGS = (\[[^\]]*\])/);
+ const attrs = src.match(/const ALLOWED_ATTR = (\[[^\]]*\])/);
+ expect(tags, 'SanitizedHtml ALLOWED_TAGS moved — this guard went blind').not.toBeNull();
+ expect(attrs, 'SanitizedHtml ALLOWED_ATTR moved — this guard went blind').not.toBeNull();
+ const viewTags: string[] = JSON.parse(tags![1]);
+ const viewAttrs: string[] = JSON.parse(attrs![1]);
+ expect(viewTags).toContain('p');
+
+ // DOMPurify removes any tag outside ALLOWED_TAGS, keeping its children. So
+ // routing the disclosure through the tenant-content component delivers the
+ // sentence with its wrapper gone — a loose paragraph, indistinguishable
+ // from a term. Task: render it with DISCLOSURE_SANITIZER_PROFILE instead.
+ expect(viewTags).not.toContain('section');
+ expect(viewAttrs).not.toContain('role');
+ });
+
+ it('is DESTROYED by the agreement-body sanitizer — it is not agreement content', () => {
+ // sanitizeAgreementHtml() is the write-time sanitizer for `agreements.content`,
+ // and its allow-list is the Quill toolbar: no , no `role`. So the
+ // disclosure cannot travel through the agreement pipeline intact — pasted into
+ // the body it arrives stripped of the wrapper that marks it as NOT a clause,
+ // i.e. as an anonymous paragraph among the terms. That is the failure mode
+ // counsel ruled out, and this asserts the pipeline itself refuses the shape.
+ const throughAgreementSanitizer = sanitizeAgreementHtml(D.html);
+ expect(throughAgreementSanitizer).not.toBe(D.html);
+ expect(throughAgreementSanitizer).not.toMatch(/ {
+ // The detector is the whole test. A scan that cannot see the thing it looks
+ // for passes vacuously forever, so prove it sees one before trusting a zero.
+ it('the importer detector actually detects', () => {
+ expect(importsDisclosure(
+ `import { AGREEMENT_LANGUAGE_DISCLOSURE } from '../lib/legal/${DISCLOSURE_MODULE}';`,
+ )).toBe(true);
+ expect(importsDisclosure(
+ `const m = await import('../../lib/legal/${DISCLOSURE_MODULE}');`,
+ )).toBe(true);
+ expect(importsDisclosure(`import { sanitizeAgreementHtml } from './sanitizer';`)).toBe(false);
+ });
+
+ it('no module that composes the agreement body imports the disclosure', () => {
+ const sources = [...walk(join(REPO_ROOT, 'server')), ...walk(join(REPO_ROOT, 'app'))]
+ .map((file) => ({ file: relative(REPO_ROOT, file).replace(/\\/g, '/'), src: readFileSync(file, 'utf8') }));
+
+ // Explicit hosts, plus anything that touches the agreement-body sanitizer.
+ // Named paths are asserted to exist so a rename fails loudly instead of
+ // quietly shrinking the guard to nothing.
+ //
+ // These are the modules that BUILD THE STRING stored in
+ // `agreements.content` (and its pinned snapshot). A document renderer that
+ // merely contains that string is a different thing and is governed by the
+ // next test — see the note there; listing one here would have forbidden the
+ // archived copy from carrying the disclosure at all, which is the one place
+ // it matters most.
+ const NAMED_HOSTS = [
+ 'server/services/agreement/sanitizer.ts',
+ 'server/services/agreement/template.ts',
+ 'server/services/agreement.service.ts',
+ ];
+ const known = new Set(sources.map((s) => s.file));
+ for (const host of NAMED_HOSTS) expect(known.has(host)).toBe(true);
+
+ const agreementBody = sources.filter(
+ (s) => NAMED_HOSTS.includes(s.file)
+ || s.file.startsWith('server/services/agreement/')
+ || s.src.includes('sanitizeAgreementHtml'),
+ );
+ expect(agreementBody.length).toBeGreaterThanOrEqual(NAMED_HOSTS.length);
+
+ const offenders = agreementBody.filter((s) => importsDisclosure(s.src)).map((s) => s.file);
+ // A neutral platform disclosure that is composed into the contract text is
+ // no longer neutral and no longer a disclosure: it is a term we wrote in a
+ // contract we are not party to. Render it beside the agreement instead.
+ expect(offenders).toEqual([]);
+ });
+
+ // The counterpart to the scan above. "Beside the agreement" has to hold on
+ // every surface that shows an agreement, and the archived copy is the one a
+ // dispute actually produces: a disclosure the signer saw on screen and cannot
+ // find in the signed document is worse than no disclosure, because the record
+ // then contradicts what happened. So this asserts PRESENCE, and
+ // `tests/unit/agreements/agreements-render.spec.ts` asserts the placement —
+ // outside the body box, with the stored string untouched.
+ it('the archived copy renderer carries the disclosure', () => {
+ const RENDERER = 'server/api/agreements-render.ts';
+ const src = readFileSync(join(REPO_ROOT, RENDERER), 'utf8');
+ expect(importsDisclosure(src), `${RENDERER} no longer shows the disclosure`).toBe(true);
+ });
+
+ // Which allow-list the browser pass uses cannot be settled in a DOM test:
+ // both components emit the server string on the first pass and only diverge
+ // once DOMPurify runs, and DOMPurify under happy-dom applies no allow-list at
+ // all. The choice is legible in source, so it is asserted there — and seen for
+ // real in a browser.
+ const DISCLOSURE_COMPONENT = 'app/components/agreements/AgreementLanguageDisclosure.tsx';
+
+ it('the browser renderer sanitizes with the disclosure profile', () => {
+ const src = readFileSync(join(REPO_ROOT, DISCLOSURE_COMPONENT), 'utf8');
+ expect(src).toContain('DISCLOSURE_SANITIZER_PROFILE');
+ });
+
+ it('the browser renderer does NOT route the copy through the tenant-content component', () => {
+ // Its allow-list is the Quill toolbar: no , no `role`. Reusing it
+ // would hand the reader a loose paragraph among the terms — the exact
+ // reading this disclosure exists to prevent.
+ const src = readFileSync(join(REPO_ROOT, DISCLOSURE_COMPONENT), 'utf8');
+ expect(/import\s*\{[^}]*\bSanitizedHtml\b[^}]*\}/.test(src)).toBe(false);
+ });
+
+ it('every signing surface renders the disclosure component', () => {
+ // One import per surface. A surface that grows its own copy of the block,
+ // or quietly drops it, shows up here rather than in a dispute.
+ const SIGNING_SURFACES = [
+ 'app/components/portal/sections/AgreementSection.tsx',
+ 'app/components/checkout/SignCard.tsx',
+ ];
+ for (const surface of SIGNING_SURFACES) {
+ const src = readFileSync(join(REPO_ROOT, surface), 'utf8');
+ expect(src, `${surface} does not render the disclosure`)
+ .toMatch(/ {
+ // A pending signer has been shown nothing and recorded nothing. Folding it
+ // into the check would suppress the notice on a document whose actual
+ // signatories all saw it — a false negative that looks like caution.
+ const src = readFileSync(join(REPO_ROOT, 'server/api/public/verify.ts'), 'utf8');
+ expect(src).toContain('signaturesRecordCurrentDisclosure');
+ expect(
+ /languageDisclosureCurrent[\s\S]{0,400}?status\s*===\s*'signed'/.test(src),
+ 'the verifier endpoint no longer restricts the check to signed signers',
+ ).toBe(true);
+ });
+
+ it('the public verifier renders the disclosure ONLY behind the record check', () => {
+ // /verify is an evidence surface, not a signing surface: it shows what
+ // was signed, to someone who was not there. Reusing the same component is
+ // right — one block, one wording — but it may only appear when the server
+ // has confirmed every signature recorded the version that is live now.
+ // The gate is a server-decided boolean precisely so this page cannot
+ // re-derive the rule and drift from the archived copy.
+ const src = readFileSync(join(REPO_ROOT, 'app/routes/public/verify.tsx'), 'utf8');
+ expect(src).toMatch(/ {
it('returns the rewritten text with surrounding quotes stripped', async () => {
mockGeminiOK('"Major cracking observed at NW corner; recommend evaluation."');
- const svc = new AIService({} as D1Database, 'test-key');
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'test-model');
const out = await svc.rewriteComment({
itemLabel: 'Roof Covering', sectionTitle: 'Roof', tab: 'defects',
originalComment: 'Cracks observed.', instruction: 'add NW corner detail',
@@ -61,7 +61,7 @@ describe('Spec 5B P2B — AIService.rewriteComment', () => {
it('includes item / section / tab / category / location in the prompt', async () => {
mockGeminiOK('rewritten body');
- const svc = new AIService({} as D1Database, 'test-key');
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'test-model');
await svc.rewriteComment({
itemLabel: 'Roof Covering',
sectionTitle: 'Roof',
@@ -85,7 +85,7 @@ describe('Spec 5B P2B — AIService.rewriteComment', () => {
it('omits defect-only context fields when tab is not "defects"', async () => {
mockGeminiOK('rewritten');
- const svc = new AIService({} as D1Database, 'test-key');
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'test-model');
await svc.rewriteComment({
itemLabel: 'Inspection Method',
sectionTitle: 'Roof',
@@ -103,7 +103,7 @@ describe('Spec 5B P2B — AIService.rewriteComment', () => {
it('throws on Gemini error responses', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, text: async () => 'rate limited' } as Response);
- const svc = new AIService({} as D1Database, 'test-key');
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'test-model');
await expect(svc.rewriteComment({
itemLabel: 'Roof', sectionTitle: 'Roof', tab: 'defects',
originalComment: 'foo', instruction: 'shorten',
diff --git a/tests/unit/ai/model-config.spec.ts b/tests/unit/ai/model-config.spec.ts
new file mode 100644
index 000000000..3fca737c2
--- /dev/null
+++ b/tests/unit/ai/model-config.spec.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { AIService } from '../../../server/services/ai.service';
+
+/**
+ * The AI model is configuration, not a source-code constant.
+ *
+ * The request URL used to hardcode `gemini-1.5-flash`, so every AI feature was
+ * quality-capped at one model with no way to change it. The model now arrives
+ * as configuration, and — like every other credential/endpoint in this repo —
+ * there is NO baked-in fallback: an unconfigured model fails closed.
+ *
+ * The fail-closed cases are the load-bearing ones. A suite that only exercises
+ * the configured path passes just as happily against a hardcoded default, which
+ * is exactly the bug this file exists to prevent.
+ */
+describe('AIService — model configuration', () => {
+ const fetchMock = vi.fn();
+ let originalFetch: typeof globalThis.fetch;
+
+ beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: async () => ({ candidates: [{ content: { parts: [{ text: 'ok' }] } }] }),
+ } as Response);
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ const REWRITE_INPUT = {
+ itemLabel: 'Roof', sectionTitle: 'Roof', tab: 'defects' as const,
+ originalComment: 'foo', instruction: 'shorten',
+ };
+
+ it('sends the configured model in the request URL', async () => {
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'gemini-3.1-flash-lite');
+ await svc.rewriteComment(REWRITE_INPUT);
+ expect(String(fetchMock.mock.calls[0]![0])).toContain('gemini-3.1-flash-lite');
+ });
+
+ it('carries no trace of the retired hardcoded pin', async () => {
+ // Asserting the ABSENCE of the stale pin rather than the presence of a
+ // specific model: pinning this to today's choice would make this test
+ // the thing that has to be edited on every model upgrade.
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', 'some-other-model');
+ await svc.rewriteComment(REWRITE_INPUT);
+ expect(String(fetchMock.mock.calls[0]![0])).not.toContain('gemini-1.5-flash');
+ });
+
+ it('fails closed — rewriteComment throws when no model is configured', async () => {
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', '');
+ await expect(svc.rewriteComment(REWRITE_INPUT)).rejects.toThrow(/no AI model is configured/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('fails closed — suggestComment throws rather than degrading to an empty list', async () => {
+ // suggestComment swallows RUNTIME failures into `[]`. A missing model is
+ // a configuration failure, not a runtime one: it must reach the caller
+ // as a 503 so the UI says "configure AI" instead of "no suggestions".
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', '');
+ await expect(svc.suggestComment({ itemName: 'Roof', sectionName: 'Roof' }))
+ .rejects.toThrow(/no AI model is configured/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('fails closed in standalone too — the dev mock never covers a missing model', async () => {
+ // A self-hoster WITH a key but no model must get a clear error, not
+ // `[DEV] ...` placeholder prose silently written into a real report.
+ const svc = new AIService({} as D1Database, 'test-key', 'standalone', '');
+ await expect(svc.rewriteComment(REWRITE_INPUT)).rejects.toThrow(/no AI model is configured/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('fails closed on the unguarded summary path as well', async () => {
+ const svc = new AIService({} as D1Database, 'test-key', 'saas', '');
+ await expect(svc.generateProfessionalComment('rough note'))
+ .rejects.toThrow(/no AI model is configured/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('still dev-mocks in standalone when there is no key at all', async () => {
+ // Unchanged behavior: the local-development mock is gated on the KEY
+ // being absent, and a missing model does not widen it.
+ const svc = new AIService({} as D1Database, '', 'standalone', '');
+ const out = await svc.rewriteComment(REWRITE_INPUT);
+ expect(out).toMatch(/^\[DEV\] /);
+ });
+});
diff --git a/tests/unit/ai/resolve-provider.spec.ts b/tests/unit/ai/resolve-provider.spec.ts
new file mode 100644
index 000000000..f7eec0825
--- /dev/null
+++ b/tests/unit/ai/resolve-provider.spec.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest';
+import { resolveAi } from '../../../server/lib/ai/resolve-provider';
+import { RecordingAiProvider } from '../../../server/lib/ai/providers/recording';
+import type { AiProvider } from '../../../server/lib/ai/provider';
+import { SAAS_PROFILE, STANDALONE_PROFILE } from '../../../server/lib/deployment-profile';
+
+/**
+ * Credential-source resolution for AI calls.
+ *
+ * `resolveAi` owns one decision — which key an AI call runs on, or whether it
+ * runs at all. Every branch below is a rule someone could plausibly "simplify"
+ * away, so each is pinned by a test that fails loudly if it disappears.
+ */
+describe('resolveAi', () => {
+ const SAAS = SAAS_PROFILE;
+ const STANDALONE = STANDALONE_PROFILE;
+ const base = { managedKey: 'platform-key', model: 'a-model' };
+
+ it('prefers the tenant own key', () => {
+ const r = resolveAi({ ...base, profile: SAAS, tenantKey: 'k', managedEntitled: true, underCap: true });
+ expect(r).toMatchObject({ source: 'byo' });
+ });
+
+ it('prefers the tenant own key even when managed is fully available', () => {
+ // BYOK is never silently overridden: a tenant who configured a key
+ // keeps spending on it, and the platform never takes over the bill.
+ const r = resolveAi({ ...base, profile: SAAS, tenantKey: 'k', managedEntitled: true, underCap: true });
+ expect(r?.source).toBe('byo');
+ });
+
+ it('uses managed when the tenant has no key, is entitled, and is under cap', () => {
+ const r = resolveAi({ ...base, profile: SAAS, tenantKey: null, managedEntitled: true, underCap: true });
+ expect(r).toMatchObject({ source: 'managed' });
+ });
+
+ it('returns null — feature OFF — when over cap', () => {
+ // Not "degraded", not "silently English": the caller already handles the
+ // not-configured shape, and reusing it means one failure path, not two.
+ expect(resolveAi({ ...base, profile: SAAS, tenantKey: null, managedEntitled: true, underCap: false })).toBeNull();
+ });
+
+ it('returns null when the tenant is not entitled to managed', () => {
+ expect(resolveAi({ ...base, profile: SAAS, tenantKey: null, managedEntitled: false, underCap: true })).toBeNull();
+ });
+
+ it('never offers managed in standalone', () => {
+ // Absent, not disabled: a self-hosted deploy has no platform to bill.
+ expect(resolveAi({ ...base, profile: STANDALONE, tenantKey: null, managedEntitled: true, underCap: true })).toBeNull();
+ });
+
+ it('still resolves BYO in standalone — the profile check gates only managed', () => {
+ // The standalone guard must not become "AI is off in standalone".
+ const r = resolveAi({ ...base, profile: STANDALONE, tenantKey: 'k', managedEntitled: false, underCap: true });
+ expect(r).toMatchObject({ source: 'byo' });
+ });
+
+ it('fails closed when the deployment has no managed key provisioned', () => {
+ // An entitlement with nothing behind it is OFF, not a runtime credential
+ // error surfacing halfway through a report.
+ const r = resolveAi({ profile: SAAS, tenantKey: null, managedKey: null, managedEntitled: true, underCap: true, model: 'a-model' });
+ expect(r).toBeNull();
+ });
+
+ it('passes the configured model through and adds no default of its own', () => {
+ // The resolver must not invent a model to paper over missing config;
+ // the adapter fails closed on an empty one (see model-config.spec).
+ const r = resolveAi({ profile: SAAS, tenantKey: 'k', managedEntitled: false, underCap: true, model: '' });
+ expect(r?.provider.id).toBe('gemini');
+ return expect(r!.provider.complete({ prompt: 'x' })).rejects.toThrow(/no AI model is configured/i);
+ });
+});
+
+describe('AiProvider contract', () => {
+ it('is satisfiable without any backend-specific concept', async () => {
+ // The recording double implements the whole interface and mentions no
+ // vendor shape. If this ever stops compiling, the contract has leaked
+ // a backend detail and a second backend just became a rewrite.
+ const provider: AiProvider = new RecordingAiProvider(['hello']);
+ const out = await provider.complete({ prompt: 'ask', temperature: 0.1 });
+ expect(out.text).toBe('hello');
+ expect((provider as RecordingAiProvider).requests[0]).toMatchObject({ prompt: 'ask', temperature: 0.1 });
+ });
+});
diff --git a/tests/unit/auth/auth.service.spec.ts b/tests/unit/auth/auth.service.spec.ts
index 7ef0774f7..ae08251a2 100644
--- a/tests/unit/auth/auth.service.spec.ts
+++ b/tests/unit/auth/auth.service.spec.ts
@@ -82,7 +82,7 @@ describe('AuthService', () => {
createdAt: new Date(),
});
- const result = await authService.validateCredentials(email, password);
+ const result = await authService.validateCredentials(email, password, 't1');
expect(result.id).toBe('u1');
});
@@ -99,7 +99,7 @@ describe('AuthService', () => {
createdAt: new Date(),
});
- await expect(authService.validateCredentials(email, 'wrong'))
+ await expect(authService.validateCredentials(email, 'wrong', 't1'))
.rejects.toThrow('Invalid email or password');
});
@@ -123,7 +123,7 @@ describe('AuthService', () => {
role: 'owner', createdAt: new Date(),
});
- const result = await authService.validateCredentials(email, memberPw);
+ const result = await authService.validateCredentials(email, memberPw, 't1');
expect(result.id).toBe('member-1');
expect(result.tenantId).toBe('t1');
});
@@ -137,7 +137,7 @@ describe('AuthService', () => {
role: 'agent', createdAt: new Date(),
});
// Correct password, but the row is excluded → generic invalid-credentials.
- await expect(authService.validateCredentials(email, password))
+ await expect(authService.validateCredentials(email, password, 't1'))
.rejects.toThrow('Invalid email or password');
});
diff --git a/tests/unit/auth/login-tenant-scope.spec.ts b/tests/unit/auth/login-tenant-scope.spec.ts
new file mode 100644
index 000000000..827f30929
--- /dev/null
+++ b/tests/unit/auth/login-tenant-scope.spec.ts
@@ -0,0 +1,97 @@
+/**
+ * Standalone login row selection must be tenant-scoped: a same-email global
+ * agent (`users.tenant_id IS NULL`, `role='agent'`) must never be the row
+ * authenticated by `/login`. See spec login-email-ambiguity.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { AuthService } from '../../../server/services/auth.service';
+import { MockKV } from '../mocks';
+import { createTestDb, setupSchema } from '../db';
+import { users, tenants } from '../../../server/lib/db/schema';
+import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import * as schema from '../../../server/lib/db/schema';
+
+// Mock the drizzle-orm/d1 module to return our in-memory SQLite DB — mirrors
+// the harness in auth.service.spec.ts. AuthService.getDrizzle() calls
+// drizzle(this.db), so the mock returns the test db regardless of the ctor's
+// first arg.
+vi.mock('drizzle-orm/d1', () => ({
+ drizzle: vi.fn(),
+}));
+
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+const TENANT = 't1';
+
+describe('AuthService.findLoginUser — tenant-scoped, fail-closed row selection', () => {
+ let authService: AuthService;
+ let mockKV: MockKV;
+ let testDb: BetterSQLite3Database;
+ let sqlite: any;
+
+ beforeEach(async () => {
+ const setup = createTestDb();
+ testDb = setup.db;
+ sqlite = setup.sqlite;
+ await setupSchema(sqlite);
+
+ (mockDrizzle as any).mockReturnValue(testDb);
+ mockKV = new MockKV();
+
+ await testDb.insert(tenants).values({
+ id: TENANT,
+ name: 'Test Tenant',
+ slug: 'test',
+ createdAt: new Date(),
+ });
+
+ // Same email on two rows: a tenant-scoped inspector, and a global
+ // (NULL-tenant) agent account. tenantId is nullable in the schema
+ // (Agent Accounts A1), so this seeds cleanly without a NOT NULL
+ // constraint violation.
+ await testDb.insert(users).values({
+ id: 'u_insp',
+ tenantId: TENANT,
+ email: 'dup@x.com',
+ passwordHash: 'H',
+ role: 'inspector',
+ createdAt: new Date(),
+ });
+ await testDb.insert(users).values({
+ id: 'u_agent',
+ tenantId: null,
+ email: 'dup@x.com',
+ passwordHash: 'H',
+ role: 'agent',
+ createdAt: new Date(),
+ } as any);
+
+ authService = new AuthService({} as any, mockKV as any);
+ });
+
+ afterEach(() => {
+ sqlite.close();
+ vi.clearAllMocks();
+ });
+
+ it('selects the tenant inspector, never the global agent', async () => {
+ const user = await authService.findLoginUser('dup@x.com', TENANT);
+ expect(user?.id).toBe('u_insp');
+ });
+
+ it('returns null when only a global (NULL-tenant) agent matches', async () => {
+ // Seed a lone global agent under a DIFFERENT email so the tenant-scoped
+ // query for it has nothing to match.
+ await testDb.insert(users).values({
+ id: 'u_agent_only',
+ tenantId: null,
+ email: 'only-agent@x.com',
+ passwordHash: 'H',
+ role: 'agent',
+ createdAt: new Date(),
+ } as any);
+
+ const user = await authService.findLoginUser('only-agent@x.com', TENANT);
+ expect(user).toBeNull();
+ });
+});
diff --git a/tests/unit/automations/message-template-resolve.spec.ts b/tests/unit/automations/message-template-resolve.spec.ts
new file mode 100644
index 000000000..e8efe15b4
--- /dev/null
+++ b/tests/unit/automations/message-template-resolve.spec.ts
@@ -0,0 +1,165 @@
+/**
+ * Multilingual delivery — picking the right LANGUAGE VARIANT of a template.
+ *
+ * The interesting behaviour is not "the Spanish row comes back when it exists".
+ * It is what happens when it does NOT: a tenant who has authored no Spanish
+ * variant must keep sending English, because silence is the one unacceptable
+ * outcome for a notification. Every fallback rung below is therefore seeded so
+ * that a WRONG answer is a different, observable string — a resolver that
+ * hardcoded English, or one that quietly widened its tenant filter, fails here
+ * rather than passing on a happy path.
+ *
+ * Rows are inserted in an ADVERSE order (the variant that must NOT win first)
+ * so no assertion can be satisfied by a row-order accident.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { MessageTemplateService } from '../../../server/services/message-template.service';
+import { eq } from 'drizzle-orm';
+import { messageTemplates, tenantConfigs, tenants } from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+const T = 'tenant-1';
+const OTHER = 'tenant-2';
+
+describe('template resolution by locale', () => {
+ let testDb: BetterSQLite3Database;
+ let svc: MessageTemplateService;
+
+ beforeEach(async () => {
+ const fx = createTestDb(); testDb = fx.db; await setupSchema(fx.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb);
+ svc = new MessageTemplateService({} as D1Database);
+ });
+
+ const setTenantLocale = async (tenantId: string, locale: string) => {
+ await testDb.insert(tenants).values({
+ id: tenantId, name: tenantId, slug: tenantId, status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await testDb.insert(tenantConfigs)
+ .values({ tenantId, defaultLocale: locale, updatedAt: new Date() });
+ };
+
+ it('returns the exact locale variant when it exists', async () => {
+ // Spanish first: if the resolver walked rows in insertion order and
+ // stopped at the first match, this ordering would hide the bug.
+ const es = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Recordatorio', body: 'es-body', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+
+ expect((await svc.resolveForLocale(T, en.id, 'es-419'))!.id).toBe(es.id);
+ expect((await svc.resolveForLocale(T, es.id, 'en'))!.id).toBe(en.id);
+ });
+
+ it('reduces a regional tag to its catalogue variant', async () => {
+ const es = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'es-body', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'en-body', locale: 'en' });
+ // A contact who stored es-MX is a Spanish speaker, not an English one.
+ expect((await svc.resolveForLocale(T, en.id, 'es-MX'))!.id).toBe(es.id);
+ });
+
+ it('falls back to the tenant default locale before English', async () => {
+ // The tenant's configured locale is a full BCP-47 tag; the column holds
+ // catalogue locales. If the resolver compared them raw, this rung would
+ // never fire and the English row below would win.
+ await setTenantLocale(T, 'es-MX');
+ const es = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Recordatorio', body: 'es-body', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+
+ // Nothing is known about this recipient's language (a null
+ // `contacts.locale` is the common case — the booking form deliberately
+ // does not ask on the agent-on-behalf branch).
+ const picked = await svc.resolveForLocale(T, en.id, null);
+ expect(picked!.id).toBe(es.id);
+ expect(picked!.body).toBe('es-body');
+ });
+
+ it('keeps sending English when the tenant authored no Spanish variant', async () => {
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+ const picked = await svc.resolveForLocale(T, en.id, 'es-419');
+ // Degrade, never block: a null here is a notification that never goes out.
+ expect(picked).not.toBeNull();
+ expect(picked!.body).toBe('en-body');
+ });
+
+ it('returns the referenced row when neither the tenant default nor English exists', async () => {
+ // The last rung. A tenant whose only variant is Spanish, addressing an
+ // English reader, still sends something.
+ const es = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'es-body', locale: 'es-419' });
+ const picked = await svc.resolveForLocale(T, es.id, 'en');
+ expect(picked!.id).toBe(es.id);
+ });
+
+ it('never returns a template from another tenant', async () => {
+ // The other tenant's Spanish copy is created FIRST and is the only
+ // es-419 row in the table. A resolver that walked locales without
+ // pinning the tenant would leak this company's outbound copy.
+ await setTenantLocale(T, 'es-MX');
+ const leak = await svc.create(OTHER, { name: 'Reminder', channel: 'email', subject: 'Recordatorio', body: 'LEAKED', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+
+ const picked = await svc.resolveForLocale(T, en.id, 'es-419');
+ expect(picked!.tenantId).toBe(T);
+ expect(picked!.id).not.toBe(leak.id);
+ expect(picked!.body).toBe('en-body');
+ });
+
+ it('never crosses channels', async () => {
+ // Same tenant, same name, different channel: an SMS variant is not a
+ // translation of an email template, and rendering one as the other
+ // would send a plain-text stub as an HTML body.
+ const smsEs = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'sms-es', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en-body', locale: 'en' });
+
+ const picked = await svc.resolveForLocale(T, en.id, 'es-419');
+ expect(picked!.id).not.toBe(smsEs.id);
+ expect(picked!.channel).toBe('email');
+ });
+
+ it('is deterministic when a tenant holds duplicate variants', async () => {
+ // Nothing enforces uniqueness on (tenant, name, channel, locale) —
+ // `create` accepts any name and `update` renames freely — so duplicates
+ // are reachable and the resolver must not pick at random.
+ const first = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'A', body: 'first', locale: 'es-419' });
+ const second = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'B', body: 'second', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'C', body: 'en-body', locale: 'en' });
+
+ const a = await svc.resolveForLocale(T, en.id, 'es-419');
+ const b = await svc.resolveForLocale(T, en.id, 'es-419');
+ expect(a!.id).toBe(b!.id);
+ expect([first.id, second.id]).toContain(a!.id);
+ });
+
+ it('defaults an unknown id to null and an unsupported authored tag to en', async () => {
+ expect(await svc.resolveForLocale(T, 'nope', 'en')).toBeNull();
+ // A variant stored under a language we have no messages for would be
+ // unreachable by the chain; it lands on 'en' at create time instead.
+ const t = await svc.create(T, { name: 'Reminder', channel: 'sms', body: 'b', locale: 'fr-FR' });
+ expect(t.locale).toBe('en');
+ });
+
+ it('lists every variant of one template, oldest first', async () => {
+ const es = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Recordatorio', body: 'es', locale: 'es-419' });
+ const en = await svc.create(T, { name: 'Reminder', channel: 'email', subject: 'Reminder', body: 'en', locale: 'en' });
+ await svc.create(T, { name: 'Other', channel: 'email', subject: 'X', body: 'x', locale: 'en' });
+
+ // Age `es` explicitly. `createdAt` is millisecond precision and these two
+ // creates routinely land in the SAME millisecond, at which point the
+ // service's `|| a.id.localeCompare(b.id)` tiebreak decides — and the ids
+ // are random nanoids, so the order is a coin flip. This test used to fail
+ // roughly half the time under load and pass every time in isolation,
+ // which reads as "flaky test" and is really "the fixture never
+ // established the difference it asserts on".
+ testDb.update(messageTemplates)
+ .set({ createdAt: new Date(Date.now() - 60_000) })
+ .where(eq(messageTemplates.id, es.id))
+ .run();
+
+ const variants = await svc.variantsOf(T, en.id);
+ expect(variants.map((v) => v.id)).toEqual([es.id, en.id]);
+ });
+});
diff --git a/tests/unit/automations/recipient-locale.spec.ts b/tests/unit/automations/recipient-locale.spec.ts
new file mode 100644
index 000000000..019b39985
--- /dev/null
+++ b/tests/unit/automations/recipient-locale.spec.ts
@@ -0,0 +1,211 @@
+/**
+ * One trigger firing, two languages.
+ *
+ * The recipient's locale is NOT the request's locale. A trigger fires from
+ * another user's request, from cron, or from a queue consumer — and in the last
+ * two there is no request at all, so `getLocale()` answers `baseLocale` for
+ * everyone. Every assertion here is written so that an implementation which
+ * read the ambient locale, or which hardcoded English, would produce a
+ * DIFFERENT, observable string.
+ *
+ * The mixed-locale inspection is the subject: an English-reading agent and a
+ * Spanish-reading client on the same property, notified by one rule.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+import { AutomationService } from '../../../server/services/automation.service';
+import { PeopleService } from '../../../server/services/people.service';
+import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles';
+// eslint-disable-next-line no-restricted-imports -- the ambient locale is the thing under test; a spec that cannot set it cannot prove rendering ignores it.
+import { overwriteGetLocale, baseLocale } from '~/paraglide/runtime';
+
+const T = '00000000-0000-0000-0000-00000000c0de';
+const OTHER_T = '00000000-0000-0000-0000-00000000c0df';
+const INSP = '00000000-0000-0000-0000-00000000cafe';
+const roleProfileId = (key: string) => `crp_${T}_${key}`;
+
+let db: BetterSQLite3Database;
+let svc: AutomationService;
+
+beforeEach(async () => {
+ const fx = createTestDb();
+ db = fx.db;
+ await setupSchema(fx.sqlite);
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(db);
+
+ for (const [id, slug] of [[T, 'acme-c0de'], [OTHER_T, 'other-c0df']] as const) {
+ await db.insert(schema.tenants).values({
+ id, name: 'Acme', slug, status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ } as never);
+ }
+ // The tenant reads English. Every Spanish string below therefore has to
+ // come from the RECIPIENT, not from the company's own default.
+ await db.insert(schema.tenantConfigs).values({
+ tenantId: T, defaultLocale: 'en-US', updatedAt: new Date(),
+ } as never);
+ await seedRoleProfiles(db, T, new Date(1));
+ await db.insert(schema.inspections).values({
+ id: INSP, tenantId: T, propertyAddress: '12 Oak Lane', date: '2026-06-01',
+ status: 'completed', reportStatus: 'published', paymentStatus: 'unpaid',
+ price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(),
+ } as never);
+
+ svc = new AutomationService({} as D1Database);
+ vi.spyOn(svc, 'ensureSeeds').mockResolvedValue();
+});
+
+afterEach(() => {
+ overwriteGetLocale(() => baseLocale);
+});
+
+async function addPerson(id: string, opts: { role: string; email: string; locale: string | null; type?: 'client' | 'agent' }) {
+ await db.insert(schema.contacts).values({
+ id, tenantId: T, type: opts.type ?? 'client', name: id, email: opts.email,
+ locale: opts.locale, createdAt: new Date(),
+ } as never);
+ await new PeopleService({ DB: {} as D1Database }).addPerson(T, INSP, id, roleProfileId(opts.role));
+}
+
+/** Seeds the Spanish variant FIRST so no assertion can be satisfied by a row
+ * that merely happens to come back earlier. */
+async function seedInAppTemplate(opts: { spanish: boolean; tenantId?: string }) {
+ const tenantId = opts.tenantId ?? T;
+ if (opts.spanish) {
+ await db.insert(schema.messageTemplates).values({
+ id: `tpl-es-${tenantId}`, tenantId, name: 'Report ready (in-app)', channel: 'in_app',
+ subject: 'Informe listo — {{property_address}}', body: 'Publicado por {{company_name}}.',
+ variables: null, isSeeded: true, locale: 'es-419',
+ createdAt: new Date(1), updatedAt: new Date(1),
+ } as never);
+ }
+ await db.insert(schema.messageTemplates).values({
+ id: `tpl-en-${tenantId}`, tenantId, name: 'Report ready (in-app)', channel: 'in_app',
+ subject: 'Report ready — {{property_address}}', body: 'Published by {{company_name}}.',
+ variables: null, isSeeded: true, locale: 'en',
+ createdAt: new Date(2), updatedAt: new Date(2),
+ } as never);
+}
+
+/**
+ * The rule dispatches by EMAIL and carries an in-app template for the notice
+ * header's wording — the header is written for every inserted log whatever the
+ * channel. Deliberately not `channels: ["in_app"]`: `resolveRuleRecipients`
+ * resolves any non-`email` channel through the PHONE column, so an `in_app` rule
+ * aimed at contacts resolves nobody at all. That is a pre-existing defect on a
+ * path unrelated to language, and fixing it inside a locale change would hide it.
+ */
+async function seedRule(opts: { inAppTemplateId: string | null }) {
+ await db.insert(schema.automations).values({
+ id: 'auto-locale', tenantId: T, name: 'Tell everyone', trigger: 'report.published',
+ recipientKind: 'all', recipientRoleProfileId: null, delayMinutes: 0,
+ subjectTemplate: '', bodyTemplate: '', channels: '["email"]',
+ inAppTemplateId: opts.inAppTemplateId,
+ active: true, isDefault: false, createdAt: new Date(),
+ } as never);
+}
+
+const fire = () => svc.trigger({
+ tenantId: T, inspectionId: INSP, triggerEvent: 'report.published',
+ companyName: 'Acme', reportBaseUrl: 'https://app.example.com',
+});
+
+const titleFor = async (contactId: string) => {
+ const rows = await db.select().from(schema.notifications);
+ return rows.find((r) => r.contactId === contactId)?.title ?? null;
+};
+
+describe('recipient-locale notifications', () => {
+ it('renders one trigger firing in two languages', async () => {
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await addPerson('agent-1', { role: 'buyer_agent', email: 'a@example.com', locale: 'en', type: 'agent' });
+ await seedInAppTemplate({ spanish: true });
+ await seedRule({ inAppTemplateId: `tpl-en-${T}` });
+
+ await fire();
+
+ expect(await titleFor('agent-1')).toBe('Report ready — 12 Oak Lane');
+ expect(await titleFor('client-1')).toBe('Informe listo — 12 Oak Lane');
+ });
+
+ it('does not read the ambient locale', async () => {
+ // Set the ambient locale to Spanish and notify an English reader. If
+ // rendering leaks ambient state this returns Spanish — which is exactly
+ // what happens in a cron context today, only inverted and invisible.
+ // The rule carries no in-app template, so this exercises `titleFor`,
+ // the one place server code reads the message catalogue.
+ overwriteGetLocale(() => 'es-419');
+ await addPerson('agent-1', { role: 'buyer_agent', email: 'a@example.com', locale: 'en', type: 'agent' });
+ await seedRule({ inAppTemplateId: null });
+
+ await fire();
+
+ expect(await titleFor('agent-1')).toBe('Report published — 12 Oak Lane');
+ });
+
+ it('renders the built-in title in the recipient\'s language, not the base locale', async () => {
+ // The mirror of the test above, and the reason it is not enough on its
+ // own: a `titleFor` that ignored its argument entirely would pass an
+ // English-only assertion.
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await seedRule({ inAppTemplateId: null });
+
+ await fire();
+
+ expect(await titleFor('client-1')).toBe('Informe publicado — 12 Oak Lane');
+ });
+
+ it('keeps sending English to a Spanish reader when no Spanish variant exists', async () => {
+ // Degrade, never block. A tenant who has authored nothing in Spanish
+ // must still deliver something — silence is the one unacceptable
+ // outcome for a notification.
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await seedInAppTemplate({ spanish: false });
+ await seedRule({ inAppTemplateId: `tpl-en-${T}` });
+
+ await fire();
+
+ expect(await titleFor('client-1')).toBe('Report ready — 12 Oak Lane');
+ });
+
+ it('treats a null contact locale as absence, falling through to the tenant default', async () => {
+ // The booking form deliberately does not ask for a language on the
+ // agent-on-behalf branch, so NULL is the common case. It must mean
+ // "fall back", never "fail" — and never "Spanish because the last
+ // recipient was".
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await addPerson('other-1', { role: 'buyer_agent', email: 'o@example.com', locale: null, type: 'agent' });
+ await seedInAppTemplate({ spanish: true });
+ await seedRule({ inAppTemplateId: `tpl-en-${T}` });
+
+ await fire();
+
+ expect(await titleFor('client-1')).toBe('Informe listo — 12 Oak Lane');
+ expect(await titleFor('other-1')).toBe('Report ready — 12 Oak Lane');
+ });
+
+ it('never picks another tenant\'s variant', async () => {
+ // The only es-419 row in the table belongs to someone else, under the
+ // same name and channel. A fallback chain that walked locales without
+ // pinning the tenant would put another company's copy in this client's
+ // inbox.
+ await db.insert(schema.messageTemplates).values({
+ id: 'tpl-leak', tenantId: OTHER_T, name: 'Report ready (in-app)', channel: 'in_app',
+ subject: 'FILTRADO — {{property_address}}', body: 'x',
+ variables: null, isSeeded: true, locale: 'es-419',
+ createdAt: new Date(1), updatedAt: new Date(1),
+ } as never);
+ await addPerson('client-1', { role: 'client', email: 'c@example.com', locale: 'es-419' });
+ await seedInAppTemplate({ spanish: false });
+ await seedRule({ inAppTemplateId: `tpl-en-${T}` });
+
+ await fire();
+
+ expect(await titleFor('client-1')).toBe('Report ready — 12 Oak Lane');
+ });
+});
diff --git a/tests/unit/automations/resolve-recipients.spec.ts b/tests/unit/automations/resolve-recipients.spec.ts
index d2d3840ee..a5f2790f2 100644
--- a/tests/unit/automations/resolve-recipients.spec.ts
+++ b/tests/unit/automations/resolve-recipients.spec.ts
@@ -74,7 +74,7 @@ describe('AutomationService.resolveRecipients', () => {
);
expect(result).toEqual([
- { contactId: 'c-buyer-1', roleKey: 'buyer_agent', email: 'buyer-agent@example.com' },
+ { contactId: 'c-buyer-1', roleKey: 'buyer_agent', email: 'buyer-agent@example.com', locale: 'en' },
]);
});
@@ -95,8 +95,8 @@ describe('AutomationService.resolveRecipients', () => {
);
expect(result.slice().sort((a, b) => a.contactId.localeCompare(b.contactId))).toEqual([
- { contactId: 'c-buyer-1', roleKey: 'buyer_agent', email: 'buyer-agent@example.com' },
- { contactId: 'c-client-1', roleKey: 'client', email: 'jane@example.com' },
+ { contactId: 'c-buyer-1', roleKey: 'buyer_agent', email: 'buyer-agent@example.com', locale: 'en' },
+ { contactId: 'c-client-1', roleKey: 'client', email: 'jane@example.com', locale: 'en' },
]);
expect(result.some(r => r.contactId === 'c-listing-1')).toBe(false);
});
@@ -115,7 +115,7 @@ describe('AutomationService.resolveRecipients', () => {
);
expect(result).toEqual([
- { contactId: 'u-inspector-1', roleKey: 'inspector', email: 'inspector@example.com' },
+ { contactId: 'u-inspector-1', roleKey: 'inspector', email: 'inspector@example.com', locale: 'en' },
]);
});
diff --git a/tests/unit/automations/staff-recipients.spec.ts b/tests/unit/automations/staff-recipients.spec.ts
index 471e55cbd..7ad34248d 100644
--- a/tests/unit/automations/staff-recipients.spec.ts
+++ b/tests/unit/automations/staff-recipients.spec.ts
@@ -140,9 +140,12 @@ describe('staff recipients (B2)', () => {
await createHeadersForInsertedLogs(
db,
{ tenantId: T, inspectionId: INSP, triggerEvent: 'report.published' },
- () => ({ title: 'Report published', body: null }),
+ async () => ({ title: 'Report published', body: null }),
// No class: this fixture exercises the XOR, not the preference gate.
() => undefined,
+ // No tenant config seeded here, so the resolver would answer 'en'
+ // anyway; stubbing it keeps this fixture about the XOR.
+ async () => 'en',
[{ id: 'log-staff-1', automationId: null, sendAt: new Date(0), recipientContactId: 'u-owner', recipientRoleKey: STAFF_ROLE_KEY }],
);
diff --git a/tests/unit/billing/checkout-public.spec.ts b/tests/unit/billing/checkout-public.spec.ts
index b176a3c67..aca3fbc6d 100644
--- a/tests/unit/billing/checkout-public.spec.ts
+++ b/tests/unit/billing/checkout-public.spec.ts
@@ -148,7 +148,13 @@ describe('GET /api/public/checkout/:token (Track I-a Task 7)', () => {
expect(d.envelope.progress).toEqual({ signed: 0, total: 1 });
// Phase B — the checkout payload now carries the invoice's snapshot
// currency (defaults to USD for this seed) so the pay UI renders it.
- expect(d.invoice).toEqual({ id: INV_ID, amountCents: 45000, currency: 'USD', status: expect.any(String) });
+ // `amountPaidCents` rides the SAME explicit projection: null here means
+ // nothing has been received, which is distinct from "partial, unknown"
+ // and from a zero the reader could mistake for a settled balance.
+ expect(d.invoice).toEqual({
+ id: INV_ID, amountCents: 45000, amountPaidCents: null,
+ currency: 'USD', status: expect.any(String),
+ });
expect(d.payment).toEqual({ required: true, paid: false });
expect(d.inspection).toEqual({ id: INSP_ID, propertyAddress: '1 Main St' });
expect(d.branding).toEqual({ companyName: 'Acme Inspections', primaryColor: '#ff5500' });
diff --git a/tests/unit/bookings/booking-contact-upsert.spec.ts b/tests/unit/bookings/booking-contact-upsert.spec.ts
index f950200b2..8d8b58267 100644
--- a/tests/unit/bookings/booking-contact-upsert.spec.ts
+++ b/tests/unit/bookings/booking-contact-upsert.spec.ts
@@ -226,7 +226,86 @@ describe('POST /book — client contact upsert (#111 / IA-18)', () => {
expect(primary2?.contactId).toBe(contactId);
});
- // 3. Contact-upsert failure → booking still succeeds (200), inspection row
+ // 3. A stated language preference lands on the contact, and its ABSENCE
+ // stays absent. These two are one pair: the field exists so someone can
+ // count who asked for another language, and that count only means
+ // anything if a booking that said nothing is distinguishable from one
+ // that chose English.
+ it('stores the language the client chose on their contact', async () => {
+ const { app } = buildApp(db, booking, contact);
+ const res = await app.request('/book', morningBody({ locale: 'es-419' }), FAKE_ENV, FAKE_EXEC_CTX);
+ expect(res.status).toBe(200);
+
+ const { eq, and } = await import('drizzle-orm');
+ const row = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.email, 'client@test.com'))).get();
+ expect(row?.locale).toBe('es-419');
+ });
+
+ it('never clears a stored language when a later booking says nothing', async () => {
+ // Silence is not a retraction — only a NEW answer replaces the old one.
+ const { app } = buildApp(db, booking, contact);
+ await app.request('/book', morningBody({ timeSlot: 'custom', customTime: '08:00', locale: 'es-419' }), FAKE_ENV, FAKE_EXEC_CTX);
+ await app.request('/book', morningBody({ timeSlot: 'custom', customTime: '10:00' }), FAKE_ENV, FAKE_EXEC_CTX);
+
+ const { eq, and } = await import('drizzle-orm');
+ const rows = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.type, 'client'))).all();
+ expect(rows.length).toBe(1);
+ expect(rows[0].locale).toBe('es-419');
+ });
+
+ it('leaves the locale NULL when the client did not choose one', async () => {
+ const { app } = buildApp(db, booking, contact);
+ const res = await app.request('/book', morningBody(), FAKE_ENV, FAKE_EXEC_CTX);
+ expect(res.status).toBe(200);
+
+ const { eq, and } = await import('drizzle-orm');
+ const row = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.email, 'client@test.com'))).get();
+ // NULL means "fall through to the tenant default", never "English".
+ expect(row?.locale).toBeNull();
+ });
+
+ it('stores a regional variant as the catalogue we can actually speak', async () => {
+ const { app } = buildApp(db, booking, contact);
+ const res = await app.request('/book', morningBody({ locale: 'es-MX' }), FAKE_ENV, FAKE_EXEC_CTX);
+ expect(res.status).toBe(200);
+
+ const { eq, and } = await import('drizzle-orm');
+ const row = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.email, 'client@test.com'))).get();
+ expect(row?.locale).toBe('es-419');
+ });
+
+ it('accepts a booking in a language we do not speak, and stores no promise', async () => {
+ // Rejecting the booking would be the worse failure by far: the request
+ // is for an inspection, not for a translation.
+ const { app } = buildApp(db, booking, contact);
+ const res = await app.request('/book', morningBody({ locale: 'fr-FR' }), FAKE_ENV, FAKE_EXEC_CTX);
+ expect(res.status).toBe(200);
+
+ const { eq, and } = await import('drizzle-orm');
+ const row = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.email, 'client@test.com'))).get();
+ expect(row?.locale).toBeNull();
+ });
+
+ it('lets a returning client change their mind about the language', async () => {
+ // Unlike name and phone, which fill forward, the newer answer wins:
+ // being written to in English after asking for Spanish is the failure.
+ const { app } = buildApp(db, booking, contact);
+ await app.request('/book', morningBody({ timeSlot: 'custom', customTime: '08:00', locale: 'en' }), FAKE_ENV, FAKE_EXEC_CTX);
+ await app.request('/book', morningBody({ timeSlot: 'custom', customTime: '10:00', locale: 'es-419' }), FAKE_ENV, FAKE_EXEC_CTX);
+
+ const { eq, and } = await import('drizzle-orm');
+ const rows = await db.select().from(contacts)
+ .where(and(eq(contacts.tenantId, T1), eq(contacts.type, 'client'))).all();
+ expect(rows.length).toBe(1);
+ expect(rows[0].locale).toBe('es-419');
+ });
+
+ // 4. Contact-upsert failure → booking still succeeds (200), inspection row
// exists with NO primary client linked (inspection_people write never
// ran since there is no contact id to link), and a warn was logged.
it('does not fail the booking when contact upsert throws (non-fatal)', async () => {
diff --git a/tests/unit/calendar/calendar-items-tz.spec.ts b/tests/unit/calendar/calendar-items-tz.spec.ts
index 8b0c4a1b6..b1b2e602c 100644
--- a/tests/unit/calendar/calendar-items-tz.spec.ts
+++ b/tests/unit/calendar/calendar-items-tz.spec.ts
@@ -262,4 +262,94 @@ describe('calendar items — timezone-correct civil date bucketing', () => {
// Shanghai (UTC+8) → 2026-07-18 04:00, not New York (UTC-4) 2026-07-17 16:00.
expect(event).toMatchObject({ civilDate: '2026-07-18', startTime: '04:00' });
});
+
+ /**
+ * A radon pickup is not entered, it is COMPUTED: the sample sits for at
+ * least 48 hours, so the pickup instant is `dropOff + 48h`. That is plain
+ * millisecond arithmetic, which means the two things below are both true and
+ * neither is obvious — and the calendar is where a human finds out.
+ *
+ * Spring-forward 2027 in the US is 2027-03-14. A drop-off at 09:00 EST on
+ * 2027-03-13 is 14:00Z; exactly 48h later is 14:00Z on 2027-03-15, which in
+ * New York is 10:00 EDT. The wall clock MOVED. Anyone re-deriving the day or
+ * the hour from the UTC instant (`start.toISOString().slice(0, 10)`) gets
+ * this wrong in both directions, which is why the civil parts are resolved
+ * server-side in the viewer's zone and never recomputed downstream.
+ */
+ async function seedComputedPickup(scheduledAt: Date, id: string) {
+ const now = new Date();
+ await testDb.insert(schema.inspections).values({
+ id: `inspection-${id}`,
+ tenantId: TENANT,
+ inspectorId: INSPECTOR,
+ propertyAddress: '9 Radon Way',
+ date: '2027-03-13',
+ status: 'scheduled',
+ paymentStatus: 'unpaid',
+ price: 0,
+ paymentRequired: false,
+ agreementRequired: false,
+ createdAt: now,
+ });
+ await testDb.insert(schema.eventTypes).values({
+ id: `event-type-${id}`,
+ tenantId: TENANT,
+ name: 'Radon pickup',
+ slug: `radon-pickup-${id}`,
+ defaultDurationMin: 20,
+ defaultPriceCents: 0,
+ color: '#6366f1',
+ sortOrder: 0,
+ active: true,
+ createdAt: now,
+ });
+ await testDb.insert(schema.inspectionEvents).values({
+ id: `event-${id}`,
+ tenantId: TENANT,
+ inspectionId: `inspection-${id}`,
+ eventTypeId: `event-type-${id}`,
+ inspectorId: INSPECTOR,
+ scheduledAt,
+ durationMin: 20,
+ priceCents: 0,
+ status: 'scheduled',
+ createdAt: now,
+ });
+ }
+
+ async function pickupItem() {
+ const items = await listCalendarItems({} as D1Database, TENANT, {
+ start: '2027-03-12',
+ end: '2027-03-16',
+ userIds: [INSPECTOR],
+ effectiveTz: 'America/New_York',
+ });
+ return items.find((item) => item.kind === 'inspection_event');
+ }
+
+ it('reports the shifted wall clock when a 48h pickup crosses spring-forward', async () => {
+ // 2027-03-13 09:00 EST = 14:00Z. +48h = 2027-03-15T14:00Z = 10:00 EDT.
+ const dropOff = new Date('2027-03-13T14:00:00.000Z');
+ await seedComputedPickup(new Date(dropOff.getTime() + 48 * 3_600_000), 'dst');
+
+ expect(await pickupItem()).toMatchObject({
+ civilDate: '2027-03-15',
+ // NOT '09:00'. Adding 48 hours of elapsed time across a DST boundary
+ // does not preserve the hour, and the calendar must show the hour the
+ // inspector will actually be standing there.
+ startTime: '10:00',
+ });
+ });
+
+ it('keeps a late-evening pickup on its own civil day in a UTC-negative zone', async () => {
+ // 2027-03-15T02:00Z is 2027-03-14 22:00 in New York. Slicing the ISO
+ // instant would file this under the 15th — a whole day early, on the
+ // other side of the boundary from the UTC-positive case above.
+ await seedComputedPickup(new Date('2027-03-15T02:00:00.000Z'), 'eve');
+
+ expect(await pickupItem()).toMatchObject({
+ civilDate: '2027-03-14',
+ startTime: '22:00',
+ });
+ });
});
diff --git a/tests/unit/calendar/day-slots.spec.ts b/tests/unit/calendar/day-slots.spec.ts
new file mode 100644
index 000000000..6e5b241e2
--- /dev/null
+++ b/tests/unit/calendar/day-slots.spec.ts
@@ -0,0 +1,142 @@
+/**
+ * GET /api/schedule/day-slots — staff Find-a-Time.
+ *
+ * HTTP-level, because the two things worth pinning here are both middleware or
+ * boundary behaviour: the `scheduleOthers` gate (a handler-level test would
+ * answer 200 for everybody), and the fact that a caller-supplied inspector list
+ * NARROWS the qualified set rather than replacing it. The second one is the
+ * quiet security-shaped bug: if an arbitrary id could be echoed back as "free",
+ * the wizard would happily assign work to somebody who cannot take it.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { HonoConfig } from '../../../server/types/hono';
+import type { UserRole } from '../../../server/types/auth';
+import { AppError } from '../../../server/lib/errors';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+const getTenantSlots = vi.fn();
+const getQualifiedInspectorIds = vi.fn();
+vi.mock('../../../server/services/booking.service', () => ({
+ BookingService: class {
+ getTenantSlots = getTenantSlots;
+ getQualifiedInspectorIds = getQualifiedInspectorIds;
+ },
+}));
+
+// eslint-disable-next-line import/order
+import scheduleRoutes from '../../../server/api/schedule-week-summary';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const ACTOR = '00000000-0000-0000-0000-000000000099';
+const ADA = '00000000-0000-0000-0000-0000000000a1';
+const BO = '00000000-0000-0000-0000-0000000000b2';
+const DAY = '2026-06-01';
+
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+function buildApp(
+ db: BetterSQLite3Database,
+ role: UserRole,
+ overrides: Record | null = null,
+) {
+ (mockDrizzle as ReturnType).mockReturnValue(db);
+ const app = new OpenAPIHono();
+
+ app.onError((err, c) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT);
+ c.set('userRole', role);
+ c.set('user', { sub: ACTOR, role, tenantId: TENANT });
+ c.set('sdb', {
+ getById: async () => ({ permissionOverrides: overrides }),
+ } as unknown as HonoConfig['Variables']['sdb']);
+ await next();
+ });
+
+ app.route('/api/schedule', scheduleRoutes);
+ return app;
+}
+
+function get(app: OpenAPIHono, query = `date=${DAY}`) {
+ return app.request(`/api/schedule/day-slots?${query}`, {}, FAKE_ENV);
+}
+
+interface SlotsPayload {
+ date: string;
+ intervalMin: number;
+ slots: Array<{ time: string; available: boolean; inspectorIds: string[] }>;
+ holidayAdvisory: { date: string; name: string } | null;
+}
+
+describe('GET /api/schedule/day-slots', () => {
+ let db: BetterSQLite3Database;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db;
+ await setupSchema(fixture.sqlite);
+ getTenantSlots.mockReset();
+ getQualifiedInspectorIds.mockReset();
+ getQualifiedInspectorIds.mockResolvedValue([ADA, BO]);
+ getTenantSlots.mockResolvedValue({
+ slots: [{ time: '09:00', available: true, inspectorIds: [ADA] }],
+ });
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await db.insert(schema.tenantConfigs).values({
+ tenantId: TENANT,
+ bookingSlotIntervalMin: 45,
+ updatedAt: new Date(),
+ });
+ });
+
+ it('inspector without the scheduleOthers override → 403', async () => {
+ const res = await get(buildApp(db, 'inspector'));
+ expect(res.status).toBe(403);
+ });
+
+ it('inspector WITH the scheduleOthers override → 200', async () => {
+ const res = await get(buildApp(db, 'inspector', { scheduleOthers: true }));
+ expect(res.status).toBe(200);
+ });
+
+ it('names the free inspectors — the part the public slots surface withholds', async () => {
+ const res = await get(buildApp(db, 'owner'));
+ expect(res.status).toBe(200);
+ const body = await res.json() as { data: SlotsPayload };
+ expect(body.data.slots[0].inspectorIds).toEqual([ADA]);
+ expect(body.data.intervalMin).toBe(45);
+ });
+
+ it('NARROWS the qualified set with userIds instead of replacing it', async () => {
+ const outsider = '00000000-0000-0000-0000-0000000000ff';
+ await get(buildApp(db, 'owner'), `date=${DAY}&userIds=${ADA},${outsider}`);
+ // ADA survives because she was qualified; the outsider does not become
+ // bookable by being named in a query string.
+ expect(getTenantSlots).toHaveBeenCalledWith(TENANT, DAY, [], [ADA]);
+ });
+
+ it('considers everyone qualified when no userIds are given', async () => {
+ await get(buildApp(db, 'owner'));
+ expect(getTenantSlots).toHaveBeenCalledWith(TENANT, DAY, [], [ADA, BO]);
+ });
+
+ it('rejects a malformed date rather than guessing one', async () => {
+ const res = await get(buildApp(db, 'owner'), 'date=next-tuesday');
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/tests/unit/calendar/dispatch-board.spec.ts b/tests/unit/calendar/dispatch-board.spec.ts
new file mode 100644
index 000000000..7fd750705
--- /dev/null
+++ b/tests/unit/calendar/dispatch-board.spec.ts
@@ -0,0 +1,213 @@
+/**
+ * GET /api/calendar/dispatch — the board feed.
+ *
+ * HTTP-level against `app.request` for the same reason the reschedule spec is:
+ * the gate is middleware. `requireCapability('scheduleOthers')` is what decides
+ * who may see the whole team's day, and a test that called the handler directly
+ * would answer 200 for everybody.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import { eq } from 'drizzle-orm';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { HonoConfig } from '../../../server/types/hono';
+import type { UserRole } from '../../../server/types/auth';
+import { AppError } from '../../../server/lib/errors';
+import { INSPECTION_STATUS } from '../../../server/lib/status/inspection-status';
+import { REPORT_STATUS } from '../../../server/lib/status/report-status';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+// eslint-disable-next-line import/order
+import calendarRoutes from '../../../server/api/calendar';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const ACTOR = '00000000-0000-0000-0000-000000000099';
+const ZOE = '00000000-0000-0000-0000-0000000000a1';
+const ADAM = '00000000-0000-0000-0000-0000000000b2';
+const DAY = '2026-06-01';
+const START_MS = Date.UTC(2026, 5, 1, 9, 0, 0);
+
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+interface BoardPayload {
+ date: string;
+ conflictPolicy: string;
+ slotIntervalMin: number;
+ dayStartMs: number;
+ inspectors: Array<{ id: string; name: string | null }>;
+ items: Array<{ id: string; kind: string; allDay: boolean; startTime?: string; userId?: string; meta?: Record }>;
+ unassigned: Array<{ id: string }>;
+}
+
+function buildApp(
+ db: BetterSQLite3Database,
+ role: UserRole,
+ overrides: Record | null = null,
+) {
+ (mockDrizzle as ReturnType).mockReturnValue(db);
+ const app = new OpenAPIHono();
+
+ app.onError((err, c) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT);
+ c.set('userRole', role);
+ c.set('user', { sub: ACTOR, role, tenantId: TENANT });
+ c.set('sdb', {
+ getById: async () => ({ permissionOverrides: overrides }),
+ } as unknown as HonoConfig['Variables']['sdb']);
+ await next();
+ });
+
+ app.route('/api/calendar', calendarRoutes);
+ return app;
+}
+
+async function seedInspection(
+ db: BetterSQLite3Database,
+ id: string,
+ overrides: Partial = {},
+) {
+ await db.insert(schema.inspections).values({
+ id,
+ tenantId: TENANT,
+ propertyAddress: `${id} St`,
+ date: DAY,
+ status: INSPECTION_STATUS.SCHEDULED,
+ reportStatus: REPORT_STATUS.IN_PROGRESS,
+ paymentStatus: 'unpaid',
+ price: 0,
+ paymentRequired: false,
+ agreementRequired: false,
+ createdAt: new Date(),
+ ...overrides,
+ });
+}
+
+describe('GET /api/calendar/dispatch', () => {
+ let db: BetterSQLite3Database;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ db = fixture.db as BetterSQLite3Database;
+ await setupSchema(fixture.sqlite);
+ await db.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ // Adverse insert order: Zoe first, Adam second. A response that happened
+ // to come back in insertion order would fail the sort assertion rather
+ // than pass by accident.
+ await db.insert(schema.users).values([
+ { id: ZOE, tenantId: TENANT, email: 'zoe@example.com', name: 'Zoe', role: 'inspector', passwordHash: 'h', createdAt: new Date() },
+ { id: ADAM, tenantId: TENANT, email: 'adam@example.com', name: 'Adam', role: 'inspector', passwordHash: 'h', createdAt: new Date() },
+ { id: ACTOR, tenantId: TENANT, email: 'owner@example.com', name: 'Owner', role: 'owner', passwordHash: 'h', createdAt: new Date() },
+ ]);
+ await db.insert(schema.tenantConfigs).values({
+ tenantId: TENANT, defaultTimezone: 'UTC', updatedAt: new Date(),
+ });
+ });
+
+ const get = (app: ReturnType, date = DAY) =>
+ app.request(`/api/calendar/dispatch?date=${date}`, {}, FAKE_ENV);
+
+ it('inspector without the scheduleOthers override → 403', async () => {
+ const res = await get(buildApp(db, 'inspector'));
+ expect(res.status).toBe(403);
+ });
+
+ it('inspector WITH the scheduleOthers override → 200', async () => {
+ const res = await get(buildApp(db, 'inspector', { scheduleOthers: true }));
+ expect(res.status).toBe(200);
+ });
+
+ it('owner → 200 with the roster sorted by display name', async () => {
+ const res = await get(buildApp(db, 'owner'));
+ expect(res.status).toBe(200);
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.inspectors.map((i) => i.name)).toEqual(['Adam', 'Owner', 'Zoe']);
+ expect(body.data.date).toBe(DAY);
+ expect(body.data.conflictPolicy).toBe('advisory');
+ });
+
+ it('ships the snap lattice and the tenant-local midnight a drag needs', async () => {
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ // Not decoration: the board turns a dropped pixel into an instant with
+ // dayStartMs + minutes*60000, so a wrong or missing anchor silently
+ // reschedules to the wrong day, and a wrong interval produces starts the
+ // booking engine would never have offered.
+ expect(body.data.slotIntervalMin).toBe(30);
+ expect(body.data.dayStartMs).toBe(Date.UTC(2026, 5, 1, 0, 0, 0));
+ });
+
+ it('echoes a non-default booking_slot_interval_min rather than assuming 30', async () => {
+ await db.update(schema.tenantConfigs)
+ .set({ bookingSlotIntervalMin: 45 })
+ .where(eq(schema.tenantConfigs.tenantId, TENANT));
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.slotIntervalMin).toBe(45);
+ });
+
+ it('echoes the tenant booking_conflict_policy', async () => {
+ await db.update(schema.tenantConfigs)
+ .set({ bookingConflictPolicy: 'block' })
+ .where(eq(schema.tenantConfigs.tenantId, TENANT));
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.conflictPolicy).toBe('block');
+ });
+
+ it('layers the scheduled instant onto inspection items', async () => {
+ await seedInspection(db, 'timed-1', {
+ scheduledStartMs: new Date(START_MS),
+ scheduledEndMs: new Date(START_MS + 90 * 60_000),
+ durationMin: 90,
+ });
+ await db.insert(schema.inspectionInspectors).values({
+ inspectionId: 'timed-1', userId: ADAM, tenantId: TENANT, role: 'lead', createdAt: new Date(),
+ });
+
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ const item = body.data.items.find((i) => i.id === 'timed-1');
+ expect(item?.allDay).toBe(false);
+ expect(item?.startTime).toBe('09:00');
+ expect(item?.meta?.scheduledStartMs).toBe(START_MS);
+ expect(item?.meta?.durationMin).toBe(90);
+ });
+
+ it('leaves an inspection with no instant all-day', async () => {
+ await seedInspection(db, 'untimed-1');
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.items.find((i) => i.id === 'untimed-1')?.allDay).toBe(true);
+ });
+
+ it('puts only the nobody-assigned inspections in the unassigned lane', async () => {
+ await seedInspection(db, 'assigned-1');
+ await db.insert(schema.inspectionInspectors).values({
+ inspectionId: 'assigned-1', userId: ZOE, tenantId: TENANT, role: 'lead', createdAt: new Date(),
+ });
+ await seedInspection(db, 'orphan-1');
+ // Legacy rows carry the assignment on the column instead of the link
+ // table; those are NOT unassigned, and a filter that only looked at the
+ // link table would wrongly sweep them into the lane.
+ await seedInspection(db, 'legacy-1', { inspectorId: ZOE });
+
+ const res = await get(buildApp(db, 'owner'));
+ const body = await res.json() as { data: BoardPayload };
+ expect(body.data.unassigned.map((i) => i.id)).toEqual(['orphan-1']);
+ expect(body.data.items.map((i) => i.id).sort()).toEqual(['assigned-1', 'legacy-1', 'orphan-1']);
+ });
+});
diff --git a/tests/unit/calendar/event-followup-delay.spec.ts b/tests/unit/calendar/event-followup-delay.spec.ts
new file mode 100644
index 000000000..a7f8a1a26
--- /dev/null
+++ b/tests/unit/calendar/event-followup-delay.spec.ts
@@ -0,0 +1,128 @@
+// @vitest-environment node
+/**
+ * The follow-up delay is a property of the EVENT TYPE, not of the codebase.
+ *
+ * 72 hours was hard-coded in EventService. That is a radon answer — sampling is
+ * a 48-hour standard and the lab takes its own time — and it is wrong for a
+ * sewer scope, whose results exist the moment the camera comes out.
+ *
+ * Two properties matter and they pull in opposite directions, so both are
+ * asserted: an untouched tenant must see exactly the old 72 hours on deploy,
+ * and a tenant who configures ZERO must get zero. `||` instead of `??` passes
+ * the first and silently fails the second, which is the whole trap.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { eq, and } from 'drizzle-orm';
+import { EventService } from '../../../server/services/event.service';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+const TENANT = '00000000-0000-0000-0000-000000000097';
+const hours = (n: number) => n * 3600_000;
+const COMPLETED_AT = Date.UTC(2026, 7, 3, 14, 0, 0);
+
+describe('EventService.followUpSendAt — per-event-type delay', () => {
+ let svc: EventService;
+ let testDb: BetterSQLite3Database;
+ let sewerEventTypeId: string;
+
+ const setFollowUpHours = (id: string, followUpDelayHours: number) =>
+ testDb.update(schema.eventTypes).set({ followUpDelayHours })
+ .where(and(eq(schema.eventTypes.id, id), eq(schema.eventTypes.tenantId, TENANT)));
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ testDb = fixture.db;
+ await setupSchema(fixture.sqlite);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (mockDrizzle as any).mockReturnValue(testDb);
+ svc = new EventService({} as D1Database);
+ await testDb.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await svc.bulkSeed(TENANT);
+ const types = await svc.listEventTypes(TENANT);
+ sewerEventTypeId = types.find(t => t.slug === 'sewer_scope')!.id as string;
+ });
+
+ it('defaults to the existing 72 hours when unset', async () => {
+ // Nothing moves for anyone on deploy. This is the whole safety property:
+ // the seeded types carry no explicit value, so they land on the column
+ // default, which is the constant that used to be in the code.
+ const at = await svc.followUpSendAt(TENANT, sewerEventTypeId, COMPLETED_AT);
+ expect(at - COMPLETED_AT).toBe(hours(72));
+ });
+
+ it('uses the event type value when configured', async () => {
+ await setFollowUpHours(sewerEventTypeId, 6);
+ const at = await svc.followUpSendAt(TENANT, sewerEventTypeId, COMPLETED_AT);
+ expect(at - COMPLETED_AT).toBe(hours(6));
+ });
+
+ it('treats zero as a real value, not as unset', async () => {
+ // A sewer scope's results exist when the camera comes out. Read with
+ // `||`, this returns 72 hours and the setting cannot express its most
+ // useful value at all.
+ await setFollowUpHours(sewerEventTypeId, 0);
+ expect(await svc.followUpSendAt(TENANT, sewerEventTypeId, COMPLETED_AT)).toBe(COMPLETED_AT);
+ });
+
+ it('falls back to 72 hours when the event type cannot be resolved', async () => {
+ expect(await svc.followUpSendAt(TENANT, 'no-such-type', COMPLETED_AT) - COMPLETED_AT).toBe(hours(72));
+ expect(await svc.followUpSendAt(TENANT, null, COMPLETED_AT) - COMPLETED_AT).toBe(hours(72));
+ });
+
+ it('never reads another tenant\'s setting', async () => {
+ await testDb.insert(schema.tenants).values({
+ id: 'other', name: 'Other', slug: 'other', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await setFollowUpHours(sewerEventTypeId, 0);
+ // Same row id, different tenant asking: the scoped read finds nothing
+ // and falls back rather than honouring a neighbour's configuration.
+ expect(await svc.followUpSendAt('other', sewerEventTypeId, COMPLETED_AT) - COMPLETED_AT)
+ .toBe(hours(72));
+ });
+
+ it('queues the follow-up log at the configured delay', async () => {
+ // The value has to reach the row the cron reads, not just the helper.
+ await testDb.insert(schema.contacts).values({
+ id: 'contact-followup', tenantId: TENANT, type: 'client', name: 'Jane Client',
+ email: 'jane@example.com', phone: null, createdAt: new Date(),
+ });
+ await testDb.insert(schema.inspections).values({
+ id: 'insp-followup', tenantId: TENANT, propertyAddress: '1 Main St',
+ clientName: null, clientEmail: null, clientPhone: null,
+ date: '2026-08-01', status: 'confirmed', paymentStatus: 'unpaid', price: 0,
+ agreementRequired: false, paymentRequired: false, createdAt: new Date(),
+ });
+ const { seedRoleProfiles } = await import('../../../server/services/seed/seed-role-profiles');
+ await seedRoleProfiles(testDb, TENANT, new Date(1));
+ const { PeopleService } = await import('../../../server/services/people.service');
+ await new PeopleService({ DB: {} as D1Database })
+ .addPerson(TENANT, 'insp-followup', 'contact-followup', `crp_${TENANT}_client`);
+ await testDb.insert(schema.automations).values({
+ id: 'auto-followup-delay', tenantId: TENANT, name: 'Followup', trigger: 'event.completed',
+ recipientKind: 'role', recipientRoleProfileId: `crp_${TENANT}_client`, delayMinutes: 0,
+ subjectTemplate: 'x', bodyTemplate: 'x', active: true, createdAt: new Date(),
+ });
+ await setFollowUpHours(sewerEventTypeId, 0);
+
+ const before = Date.now();
+ const event = await svc.createEvent(TENANT, 'insp-followup', {
+ eventTypeId: sewerEventTypeId, durationMin: 60,
+ scheduledAt: new Date(Date.now() + 7 * 86_400_000),
+ });
+ await svc.updateEventStatus(TENANT, event.id, 'completed');
+
+ const log = await testDb.select().from(schema.automationLogs)
+ .where(eq(schema.automationLogs.automationId, 'auto-followup-delay')).get();
+ // Zero delay: "now", not three days from now.
+ expect(new Date(log!.sendAt as Date).getTime()).toBeLessThan(before + hours(1));
+ });
+});
diff --git a/tests/unit/calendar/event-results-received.spec.ts b/tests/unit/calendar/event-results-received.spec.ts
new file mode 100644
index 000000000..e527177c2
--- /dev/null
+++ b/tests/unit/calendar/event-results-received.spec.ts
@@ -0,0 +1,135 @@
+// @vitest-environment node
+/**
+ * `results_received` used to be a dead state.
+ *
+ * It existed in the schema, in the API's Zod enum, and in EventService, which
+ * wrote `results_received_at` and told nobody — the automation trigger list had
+ * only `event.created` and `event.completed`. So the single moment a radon
+ * client has been waiting 48 hours for notified no one, while "we completed the
+ * pickup" did.
+ *
+ * These specs assert the two halves of that fix separately, because a trigger
+ * that fires on BOTH transitions would pass a test that only looked at the
+ * results one: completing the pickup is not the lab result arriving.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { eq } from 'drizzle-orm';
+import { EventService } from '../../../server/services/event.service';
+import { PeopleService } from '../../../server/services/people.service';
+import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+const TENANT = '00000000-0000-0000-0000-000000000098';
+const CLIENT = 'contact-client-rr';
+const INSP = 'insp-event-rr';
+
+describe('EventService — event.results_received', () => {
+ let svc: EventService;
+ let testDb: BetterSQLite3Database;
+ let eventTypeId: string;
+ const roleProfileId = (key: string) => `crp_${TENANT}_${key}`;
+
+ /**
+ * Which TRIGGERS actually fired for an inspection, read back the way the
+ * product does it — a queued `automation_logs` row, resolved through the
+ * rule that produced it. Asserting on the rows alone would pass for a log
+ * queued by the wrong rule entirely.
+ */
+ async function firedTriggers(inspectionId: string): Promise {
+ const logs = await testDb.select().from(schema.automationLogs)
+ .where(eq(schema.automationLogs.inspectionId, inspectionId)).all();
+ const rules = await testDb.select().from(schema.automations).all();
+ const triggerById = new Map(rules.map(r => [r.id as string, r.trigger as string]));
+ return logs
+ .map(l => triggerById.get(l.automationId as string))
+ .filter((t): t is string => Boolean(t));
+ }
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ testDb = fixture.db;
+ await setupSchema(fixture.sqlite);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (mockDrizzle as any).mockReturnValue(testDb);
+ svc = new EventService({} as D1Database);
+
+ await testDb.insert(schema.tenants).values({
+ id: TENANT, name: 'Acme', slug: 'acme', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await seedRoleProfiles(testDb, TENANT, new Date(1));
+ await testDb.insert(schema.contacts).values({
+ id: CLIENT, tenantId: TENANT, type: 'client', name: 'Jane Client',
+ email: 'jane@example.com', phone: null, createdAt: new Date(),
+ });
+ await testDb.insert(schema.inspections).values({
+ id: INSP, tenantId: TENANT, propertyAddress: '1 Main St',
+ clientName: null, clientEmail: null, clientPhone: null,
+ date: '2026-08-01', status: 'confirmed', paymentStatus: 'unpaid', price: 0,
+ agreementRequired: false, paymentRequired: false, createdAt: new Date(),
+ });
+ await new PeopleService({ DB: {} as D1Database })
+ .addPerson(TENANT, INSP, CLIENT, roleProfileId('client'));
+
+ await svc.bulkSeed(TENANT);
+ eventTypeId = (await svc.listEventTypes(TENANT))[0].id as string;
+
+ // Both rules exist up front, so "did not fire" below is a statement
+ // about the trigger and not about a missing rule.
+ await testDb.insert(schema.automations).values([
+ {
+ id: 'auto-results-received', tenantId: TENANT, name: 'Results Received',
+ trigger: 'event.results_received', recipientKind: 'role',
+ recipientRoleProfileId: roleProfileId('client'), delayMinutes: 0,
+ subjectTemplate: 'x', bodyTemplate: 'x', active: true, createdAt: new Date(),
+ },
+ {
+ id: 'auto-followup', tenantId: TENANT, name: 'Followup',
+ trigger: 'event.completed', recipientKind: 'role',
+ recipientRoleProfileId: roleProfileId('client'), delayMinutes: 0,
+ subjectTemplate: 'x', bodyTemplate: 'x', active: true, createdAt: new Date(),
+ },
+ ]);
+ });
+
+ async function createEvent() {
+ return svc.createEvent(TENANT, INSP, {
+ eventTypeId, durationMin: 60,
+ scheduledAt: new Date(Date.now() + 7 * 86_400_000),
+ });
+ }
+
+ it('fires event.results_received when results are marked received', async () => {
+ const event = await createEvent();
+ await svc.updateEventStatus(TENANT, event.id, 'results_received');
+ expect(await firedTriggers(INSP)).toContain('event.results_received');
+ });
+
+ it('does not fire it on completion', async () => {
+ // Completing the pickup is not the same as the lab result arriving —
+ // the sample only reaches the lab afterwards.
+ const event = await createEvent();
+ await svc.updateEventStatus(TENANT, event.id, 'completed');
+ const fired = await firedTriggers(INSP);
+ expect(fired).toContain('event.completed');
+ expect(fired).not.toContain('event.results_received');
+ });
+
+ it('stamps the visit on the queued log so the copy can name the event type', async () => {
+ // {{event_type_name}} is resolved from automation_logs.event_id at
+ // delivery (deliver-email.ts). Without the stamp the client is told
+ // "your results are in".
+ const event = await createEvent();
+ await svc.updateEventStatus(TENANT, event.id, 'results_received');
+ const logs = await testDb.select().from(schema.automationLogs)
+ .where(eq(schema.automationLogs.automationId, 'auto-results-received')).all();
+ expect(logs).toHaveLength(1);
+ expect(logs[0].eventId).toBe(event.id);
+ expect(logs[0].recipient).toBe('jane@example.com');
+ });
+});
diff --git a/tests/unit/client-portal/public-invoice-field-projection.spec.ts b/tests/unit/client-portal/public-invoice-field-projection.spec.ts
index a637abfec..dd6c8b497 100644
--- a/tests/unit/client-portal/public-invoice-field-projection.spec.ts
+++ b/tests/unit/client-portal/public-invoice-field-projection.spec.ts
@@ -39,6 +39,7 @@ const FULL_ROW = {
clientName: 'Dana Buyer',
clientEmail: 'dana@example.com',
amountCents: 5000,
+ amountPaidCents: 2000,
lineItems: [{ description: 'Home inspection', amountCents: 5000 }],
dueDate: '2026-08-01',
notes: 'Client haggled; do not discount again.',
@@ -54,7 +55,7 @@ const FULL_ROW = {
};
const LEAKED = ['tenantId', 'contactId', 'notes', 'qboSyncStatus', 'paymentMethod', 'partialPaidAt', 'voidedAt', 'clientEmail', 'sentAt'] as const;
-const KEPT = ['id', 'amountCents', 'currency', 'status', 'createdAt', 'dueDate', 'clientName', 'lineItems', 'brand', 'tenantSlug'] as const;
+const KEPT = ['id', 'amountCents', 'amountPaidCents', 'currency', 'status', 'createdAt', 'dueDate', 'clientName', 'lineItems', 'brand', 'tenantSlug'] as const;
describe('IA-86 — public invoice response carries only declared fields', () => {
let testDb: BetterSQLite3Database;
@@ -112,6 +113,20 @@ describe('IA-86 — public invoice response carries only declared fields', () =>
expect(data.lineItems).toEqual([{ description: 'Home inspection', amountCents: 5000 }]);
});
+ /**
+ * The projection cuts both ways: it stops undeclared fields leaking, and it
+ * silently DROPS a field someone forgot to declare. `amountPaidCents` was
+ * added to `invoices` and populated by the QuickBooks sync while this schema
+ * said nothing about it — so the column was written, the row carried it, and
+ * zod removed it one line before the payer's page. Nothing failed; the value
+ * simply never arrived. Pin it, because the next money column will land the
+ * same way.
+ */
+ it('ships the amount already received, which the schema used to strip', async () => {
+ const { data } = await fetchInvoice();
+ expect(data.amountPaidCents).toBe(2000);
+ });
+
it('the raw text of the response contains no private note', async () => {
const res = await buildApp().request(`/api/public/inspections/${INSP}/invoice?token=${encodeURIComponent(token)}`);
expect(await res.text()).not.toContain('do not discount again');
diff --git a/tests/unit/contacts/contact-locale-write.spec.ts b/tests/unit/contacts/contact-locale-write.spec.ts
new file mode 100644
index 000000000..4fb94f4fc
--- /dev/null
+++ b/tests/unit/contacts/contact-locale-write.spec.ts
@@ -0,0 +1,141 @@
+/**
+ * Staff can set a contact's language, and can take it back off.
+ *
+ * Two things have to hold at once, and they pull in opposite directions:
+ *
+ * - An update that never MENTIONS `locale` must leave a stored preference
+ * alone. `UpdateContactSchema` is `CreateContactSchema.partial()`, and zod's
+ * `.partial()` KEEPS a `.default()` — so a field with a default arrives on
+ * every request whether or not the caller sent it, and the handler writes it
+ * over whatever was there. This repo has lost label data exactly that way.
+ * The assertion below is therefore on the ABSENCE OF THE KEY in the object
+ * handed to the service, not on its value: a default of `null` would pass a
+ * value check and still be a silent overwrite of a real choice.
+ *
+ * - An explicit `null` must clear it. Staff set this as a correction — the
+ * client said so on the phone, or mis-clicked on the booking form — and a
+ * correction path that cannot get back to "not stated" is not a correction
+ * path. That is why the handler tests `'locale' in raw` rather than
+ * `!== undefined`.
+ *
+ * And whatever is stored has to be a locale `resolveContactLocale` would hand
+ * back, or NULL. A stored `fr-FR` is a promise broken at send time.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import contactRoutes from '../../../server/api/contacts';
+import { ContactService } from '../../../server/services/contact.service';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import type { HonoConfig } from '../../../server/types/hono';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+const TENANT = '00000000-0000-0000-0000-000000000001';
+const CONTACT = '00000000-0000-0000-0000-0000000000c1';
+
+function contactsApp(services: Record) {
+ const app = new OpenAPIHono();
+ app.use('*', async (c, next) => {
+ c.set('userRole', 'owner');
+ c.set('user', { sub: 'u1' } as never);
+ c.set('tenantId', TENANT);
+ c.set('services', services as never);
+ await next();
+ });
+ app.route('/api/contacts', contactRoutes);
+ return app;
+}
+
+async function put(updateContact: ReturnType, body: unknown) {
+ return contactsApp({ contact: { updateContact } }).request(
+ `/api/contacts/${CONTACT}`,
+ { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
+ {},
+ );
+}
+
+describe('PUT /api/contacts/{id} — what reaches the service', () => {
+ it('omits locale entirely when the caller never sent it', async () => {
+ const updateContact = vi.fn().mockResolvedValue({ id: CONTACT });
+ const res = await put(updateContact, { name: 'Jane' });
+
+ expect(res.status).toBe(200);
+ const data = updateContact.mock.calls[0][2] as Record;
+ // The key, not the value. A schema default would put `locale` here
+ // holding something plausible, and the write would land.
+ expect(Object.hasOwn(data, 'locale')).toBe(false);
+ });
+
+ it('passes a chosen locale through', async () => {
+ const updateContact = vi.fn().mockResolvedValue({ id: CONTACT });
+ const res = await put(updateContact, { name: 'Jane', locale: 'es-419' });
+
+ expect(res.status).toBe(200);
+ expect(updateContact.mock.calls[0][2]).toMatchObject({ locale: 'es-419' });
+ });
+
+ it('passes an explicit null through, so "not set" is reachable again', async () => {
+ const updateContact = vi.fn().mockResolvedValue({ id: CONTACT });
+ const res = await put(updateContact, { name: 'Jane', locale: null });
+
+ expect(res.status).toBe(200);
+ const data = updateContact.mock.calls[0][2] as Record;
+ expect(Object.hasOwn(data, 'locale')).toBe(true);
+ expect(data.locale).toBeNull();
+ });
+});
+
+describe('ContactService locale writes', () => {
+ let svc: ContactService;
+ let testDb: BetterSQLite3Database;
+
+ const storedLocale = async () =>
+ (await testDb.select().from(schema.contacts).all())[0]?.locale ?? null;
+
+ beforeEach(async () => {
+ const fixture = createTestDb();
+ testDb = fixture.db;
+ await setupSchema(fixture.sqlite);
+ await testDb.insert(schema.tenants).values({
+ id: TENANT, name: 'A', slug: 'a', status: 'active',
+ deploymentMode: 'shared', tier: 'free', createdAt: new Date(),
+ });
+ await testDb.insert(schema.contacts).values({
+ id: CONTACT, tenantId: TENANT, type: 'client', name: 'Jane',
+ email: 'jane@test.com', locale: 'es-419', createdAt: new Date(),
+ });
+ (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb);
+ svc = new ContactService({} as D1Database);
+ });
+
+ it('leaves a stored preference alone when the patch does not mention it', async () => {
+ await svc.updateContact(CONTACT, TENANT, { name: 'Jane Smith' });
+ expect(await storedLocale()).toBe('es-419');
+ });
+
+ it('clears it on an explicit null', async () => {
+ await svc.updateContact(CONTACT, TENANT, { locale: null });
+ expect(await storedLocale()).toBeNull();
+ });
+
+ it('reduces a regional variant to the catalogue we have', async () => {
+ await svc.updateContact(CONTACT, TENANT, { locale: 'es-MX' });
+ expect(await storedLocale()).toBe('es-419');
+ });
+
+ it('stores null rather than a language we cannot speak', async () => {
+ await svc.updateContact(CONTACT, TENANT, { locale: 'fr-FR' });
+ expect(await storedLocale()).toBeNull();
+ });
+
+ it('normalizes on create too', async () => {
+ const created = await svc.createContact(TENANT, { type: 'client', name: 'Bob', locale: 'es-MX' });
+ expect(created.locale).toBe('es-419');
+
+ const unset = await svc.createContact(TENANT, { type: 'client', name: 'Ann' });
+ expect(unset.locale).toBeNull();
+ });
+});
diff --git a/tests/unit/contacts/contact-locale.spec.ts b/tests/unit/contacts/contact-locale.spec.ts
new file mode 100644
index 000000000..0a753116c
--- /dev/null
+++ b/tests/unit/contacts/contact-locale.spec.ts
@@ -0,0 +1,113 @@
+import { readFileSync } from 'node:fs';
+import * as path from 'node:path';
+import { describe, it, expect } from 'vitest';
+import { normalizeLocale, resolveContactLocale, SUPPORTED_CONTACT_LOCALES } from '../../../server/lib/i18n/contact-locale';
+
+const NONE = { contactLocale: null, linkedUserLocale: null, tenantDefault: null, acceptLanguage: null };
+
+/**
+ * The tag reduction, tested directly rather than only through the resolver.
+ *
+ * This is the load-bearing half of UI-locale activation (#269): the stored
+ * preferences are BCP-47 and do NOT match the Paraglide tags. `tenant_configs
+ * .default_locale` ships as 'en-US' and the settings picker offers 'en-US' /
+ * 'es-419' (`app/lib/locales.ts`), while the catalogue is compiled for 'en' /
+ * 'es-419'. If 'en-US' reduced to null, every tenant default would fall through
+ * to the browser hint and the tenant setting would silently stop working — a
+ * failure that looks exactly like "the feature does nothing".
+ *
+ * Returning NULL rather than a default is the other half: every caller is a
+ * precedence chain (`resolveContactLocale` here, `resolveUiLocale` for the UI),
+ * and a wrong non-null answer would stop the chain at its first rung and make
+ * every later rung unreachable.
+ */
+describe('normalizeLocale', () => {
+ it('reduces the tags the product actually stores', () => {
+ // Both values the settings pickers can write.
+ expect(normalizeLocale('en-US')).toBe('en');
+ expect(normalizeLocale('es-419')).toBe('es-419');
+ });
+
+ it('lands every Spanish variant on the one Spanish catalogue', () => {
+ expect(normalizeLocale('es')).toBe('es-419');
+ expect(normalizeLocale('es-MX')).toBe('es-419');
+ expect(normalizeLocale('es-ES')).toBe('es-419');
+ });
+
+ it('is case-insensitive, because Accept-Language is not normalised for us', () => {
+ expect(normalizeLocale('EN-us')).toBe('en');
+ expect(normalizeLocale('ES-419')).toBe('es-419');
+ });
+
+ it('returns null for unsupported and absent input, never a default', () => {
+ expect(normalizeLocale('fr-FR')).toBeNull();
+ expect(normalizeLocale('')).toBeNull();
+ expect(normalizeLocale(null)).toBeNull();
+ expect(normalizeLocale(undefined)).toBeNull();
+ // Malformed enough that `new Intl.Locale` throws — must be an absence,
+ // not the thing that breaks the page.
+ expect(normalizeLocale('not a locale!!')).toBeNull();
+ });
+});
+
+describe('resolveContactLocale', () => {
+ it('prefers what the contact told us', () => {
+ expect(resolveContactLocale({ ...NONE,
+ contactLocale: 'es-MX', linkedUserLocale: 'en-US', tenantDefault: 'en-US',
+ })).toBe('es-419');
+ });
+
+ it('uses the linked agent user account when the contact has no preference', () => {
+ // An agent contact linked to a real user (agent_user_id) has a locale on
+ // that user row; a plain client contact has nothing.
+ expect(resolveContactLocale({ ...NONE,
+ linkedUserLocale: 'es-419', tenantDefault: 'en-US',
+ })).toBe('es-419');
+ });
+
+ it('falls through an unsupported preference instead of stopping on it', () => {
+ expect(resolveContactLocale({ ...NONE,
+ contactLocale: 'fr-FR', tenantDefault: 'es-419',
+ })).toBe('es-419');
+ });
+
+ it('ends at English', () => {
+ expect(resolveContactLocale(NONE)).toBe('en');
+ });
+
+ it('uses the tenant default before the browser hint', () => {
+ expect(resolveContactLocale({ ...NONE,
+ tenantDefault: 'en-US', acceptLanguage: 'es-419',
+ })).toBe('en');
+ });
+
+ it('reads the highest-weighted supported entry out of Accept-Language', () => {
+ expect(resolveContactLocale({ ...NONE,
+ acceptLanguage: 'fr-FR,es-MX;q=0.9,en;q=0.8',
+ })).toBe('es-419');
+ // q defaults to 1 and order breaks ties, so a bare list takes the first.
+ expect(resolveContactLocale({ ...NONE, acceptLanguage: 'en-GB,es-MX' })).toBe('en');
+ expect(resolveContactLocale({ ...NONE, acceptLanguage: '*' })).toBe('en');
+ });
+
+ it('treats junk and empty strings as an absence, not as a choice', () => {
+ expect(resolveContactLocale({ ...NONE, contactLocale: '', tenantDefault: 'es-419' })).toBe('es-419');
+ expect(resolveContactLocale({ ...NONE, contactLocale: 'not a locale!!', tenantDefault: 'es-419' })).toBe('es-419');
+ });
+
+ it('matches a region-qualified tag case-insensitively', () => {
+ expect(resolveContactLocale({ ...NONE, contactLocale: 'ES-419' })).toBe('es-419');
+ });
+});
+
+describe('SUPPORTED_CONTACT_LOCALES', () => {
+ // server/ cannot import the paraglide runtime (BFF boundary, enforced by
+ // no-restricted-imports), so the supported set is restated there. Assert the
+ // equality instead of asking a comment to keep it true: resolving to a
+ // locale the catalogue has no messages for renders English anyway, silently.
+ it('is exactly the set of locales the message catalogue is compiled for', () => {
+ const settingsPath = path.resolve(__dirname, '../../../project.inlang/settings.json');
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { locales: string[] };
+ expect([...SUPPORTED_CONTACT_LOCALES].sort()).toEqual([...settings.locales].sort());
+ });
+});
diff --git a/tests/unit/contacts/demand-signal-queries.spec.ts b/tests/unit/contacts/demand-signal-queries.spec.ts
new file mode 100644
index 000000000..a73b496ef
--- /dev/null
+++ b/tests/unit/contacts/demand-signal-queries.spec.ts
@@ -0,0 +1,174 @@
+/**
+ * The multilingual demand signal is a NUMBER SOMEONE DECIDES ON, so the SQL
+ * that produces it is treated as code: it lives in
+ * `docs/developers/multilingual-demand-signal.md`, and this spec runs every
+ * query in that document against a database built from the real migrations.
+ *
+ * Two failures are worth catching mechanically, and neither one is visible to
+ * a reader of the doc:
+ *
+ * 1. **Drift.** A renamed column leaves the queries syntactically fine and
+ * operationally dead — discovered on the day someone needs the answer,
+ * which is the one day it cannot be fixed retroactively. Executing the
+ * blocks here turns that into a red test at rename time. (Repo convention:
+ * a "must stay in sync" comment becomes an assertion, not prose.)
+ *
+ * 2. **A number quoted without its caveat.** The question is only asked where
+ * a client speaks for themselves; the agent-on-behalf booking path carries
+ * no `locale` at all, so those clients sit in `(not stated)` whatever they
+ * speak. A bare percentage is therefore a lie, and the caveat has to travel
+ * with the SQL rather than sit two screens above it — anything copied out
+ * of the doc must carry it. Hence the `Undercount:` assertion below.
+ *
+ * The fixture is deliberately adversarial about the buckets that get conflated:
+ * an archived Spanish speaker, an agent contact with a locale, a stated `en`
+ * (a choice, not an absence) and a NULL (an absence, not English).
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { readFileSync } from 'node:fs';
+import * as path from 'node:path';
+import { createTestDb, setupSchema } from '../db';
+import * as schema from '../../../server/lib/db/schema';
+
+const DOC = path.resolve(
+ __dirname,
+ '../../../docs/developers/multilingual-demand-signal.md',
+);
+
+/** Every fenced ```sql block in the doc, keyed by its `-- Query X` marker. */
+function sqlBlocks(): Map {
+ const markdown = readFileSync(DOC, 'utf8');
+ const blocks = new Map();
+ for (const match of markdown.matchAll(/```sql\n([\s\S]*?)```/g)) {
+ const sql = match[1];
+ const label = /--\s*Query\s+([A-Z])\b/.exec(sql)?.[1];
+ expect(label, `every SQL block must open with a "-- Query X" marker:\n${sql}`).toBeTruthy();
+ blocks.set(label as string, sql);
+ }
+ return blocks;
+}
+
+const T1 = '00000000-0000-0000-0000-000000000a01';
+const T2 = '00000000-0000-0000-0000-000000000a02';
+const CLIENT_ROLE = 'role-client';
+const AGENT_ROLE = 'role-agent';
+
+let sqlite: ReturnType['sqlite'];
+let blocks: Map;
+
+/** Run a doc query and return its rows. */
+function run(label: string): Record[] {
+ const sql = blocks.get(label);
+ if (!sql) throw new Error(`doc has no "-- Query ${label}" block`);
+ return sqlite.prepare(sql).all() as Record[];
+}
+
+beforeAll(async () => {
+ const fixture = createTestDb();
+ sqlite = fixture.sqlite;
+ await setupSchema(sqlite);
+ blocks = sqlBlocks();
+
+ const db = fixture.db;
+ const day = 86_400_000;
+ const now = new Date('2026-08-01T12:00:00Z').getTime();
+
+ await db.insert(schema.tenants).values([
+ { id: T1, name: 'Tenant One', slug: 't1', createdAt: new Date(now) },
+ { id: T2, name: 'Tenant Two', slug: 't2', createdAt: new Date(now) },
+ ]);
+
+ await db.insert(schema.contacts).values([
+ // Tenant 1 — the population the decision rule is about.
+ { id: 'c-es', tenantId: T1, type: 'client', name: 'Stated Spanish', locale: 'es-419', createdAt: new Date(now) },
+ { id: 'c-en', tenantId: T1, type: 'client', name: 'Stated English', locale: 'en', createdAt: new Date(now + day) },
+ { id: 'c-null', tenantId: T1, type: 'client', name: 'Never Asked', locale: null, createdAt: new Date(now + 2 * day) },
+ // Archived: retired, and must not inflate a live count.
+ { id: 'c-gone', tenantId: T1, type: 'client', name: 'Archived Spanish', locale: 'es-419', createdAt: new Date(now), archivedAt: new Date(now + day) },
+ // An AGENT with a stated locale: real data, but not client demand.
+ { id: 'c-agent', tenantId: T1, type: 'agent', name: 'Agent With Locale', locale: 'es-419', createdAt: new Date(now) },
+ // Tenant 2 — proves the per-tenant breakdown does not merge tenants.
+ { id: 'c-t2', tenantId: T2, type: 'client', name: 'Other Tenant', locale: null, createdAt: new Date(now) },
+ ]);
+
+ await db.insert(schema.contactRoleProfiles).values([
+ { id: CLIENT_ROLE, tenantId: T1, key: 'client', label: 'Client', kind: 'client', createdAt: new Date(now), updatedAt: new Date(now) },
+ { id: AGENT_ROLE, tenantId: T1, key: 'buyer_agent', label: "Buyer's Agent", kind: 'agent', createdAt: new Date(now), updatedAt: new Date(now) },
+ ]);
+
+ await db.insert(schema.inspections).values([
+ // Placed BY an agent for the client: concierge_status is set.
+ { id: 'i-concierge', tenantId: T1, propertyAddress: '1 Agent Way', date: '2026-08-10', createdAt: new Date(now), conciergeStatus: 'awaiting_client' },
+ // Booked by the client themselves: no concierge status.
+ { id: 'i-self', tenantId: T1, propertyAddress: '2 Self Street', date: '2026-08-11', createdAt: new Date(now) },
+ ]);
+
+ await db.insert(schema.inspectionPeople).values([
+ { id: 'p1', tenantId: T1, inspectionId: 'i-concierge', contactId: 'c-null', roleProfileId: CLIENT_ROLE, createdAt: new Date(now) },
+ // The referring agent on the same concierge inspection: not a client,
+ // so it must not be counted as one who was never asked.
+ { id: 'p2', tenantId: T1, inspectionId: 'i-concierge', contactId: 'c-agent', roleProfileId: AGENT_ROLE, createdAt: new Date(now) },
+ { id: 'p3', tenantId: T1, inspectionId: 'i-self', contactId: 'c-es', roleProfileId: CLIENT_ROLE, createdAt: new Date(now) },
+ ]);
+});
+
+describe('multilingual demand signal — the documented queries', () => {
+ it('publishes at least the four labelled queries', () => {
+ expect([...blocks.keys()].sort()).toEqual(['A', 'B', 'C', 'D']);
+ });
+
+ it('carries the agent-booked undercount inside every query, so a copy-paste cannot lose it', () => {
+ for (const [label, sql] of blocks) {
+ expect(sql, `Query ${label} must state the undercount in its own comment header`)
+ .toMatch(/Undercount:/);
+ }
+ });
+
+ it('A: counts stated languages, keeping "(not stated)" out of the English bucket', () => {
+ // Archived and non-client rows are excluded; a stated `en` stands on
+ // its own because it is an answer, not an absence. Both tenants are in
+ // scope — this is the operator's whole-database view.
+ // Compared as a set: `en` and `es-419` tie on count here, and SQLite
+ // does not promise an order between tied rows.
+ const byLanguage = Object.fromEntries(
+ run('A').map((row) => [row.stated_language, row.contacts]),
+ );
+ expect(byLanguage).toEqual({ '(not stated)': 2, 'es-419': 1, en: 1 });
+ });
+
+ it('B: reports the ratio the decision rule tests, and the answer rate that qualifies it', () => {
+ // live_clients spans both tenants: this is the operator's whole-database
+ // view, and query D is the per-tenant split.
+ expect(run('B')).toEqual([
+ { live_clients: 4, stated: 2, stated_non_english: 1 },
+ ]);
+ });
+
+ it('C: sizes the blind spot — clients booked for them, never asked', () => {
+ // c-null only. c-es was on a self-booked inspection; c-agent is on the
+ // concierge one but is not a client.
+ expect(run('C')).toEqual([{ agent_booked_clients: 1 }]);
+ });
+
+ it('D: separates tenants, and exposes the created_at clustering that marks seed data', () => {
+ const rows = run('D');
+ expect(rows).toEqual([
+ {
+ tenant_id: T1,
+ live_clients: 3,
+ stated: 2,
+ first_created_ms: expect.any(Number),
+ last_created_ms: expect.any(Number),
+ distinct_days: 3,
+ },
+ {
+ tenant_id: T2,
+ live_clients: 1,
+ stated: 0,
+ first_created_ms: expect.any(Number),
+ last_created_ms: expect.any(Number),
+ distinct_days: 1,
+ },
+ ]);
+ });
+});
diff --git a/tests/unit/events/event-capabilities.spec.ts b/tests/unit/events/event-capabilities.spec.ts
new file mode 100644
index 000000000..6af8a85bb
--- /dev/null
+++ b/tests/unit/events/event-capabilities.spec.ts
@@ -0,0 +1,162 @@
+import { describe, it, expect, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import type { Context } from 'hono';
+import type { HonoConfig } from '../../../server/types/hono';
+import type { Role } from '../../../server/lib/auth/roles';
+import { AppError, ErrorCode } from '../../../server/lib/errors';
+
+/**
+ * Who may do what to a visit — asserted as HTTP status codes from real
+ * requests, not as a service call.
+ *
+ * A capability checked inside a service function proves the FUNCTION checks it;
+ * it says nothing about whether the route mounts the check. This repo has
+ * already shipped a capability (`viewCommunication`) that was declared,
+ * defaulted per role, returned by `/me`, documented, unit-asserted — and
+ * enforced nowhere. Likewise `createRoutesStub` does not run middleware, so an
+ * authorization test built on a rendered component is a false green: it proves
+ * the button is hidden, not that the API refuses the request somebody types by
+ * hand.
+ *
+ * So: build the router the way `server/index.ts` mounts it, send a real
+ * request, and read the status code off the response.
+ *
+ * | action | who |
+ * |-----------------------|---------------------|
+ * | mark a visit complete | inspector — required|
+ * | mark results_received | owner/manager |
+ * | delete a visit | owner/manager |
+ * | delete a report | owner/manager |
+ */
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn(() => ({})) }));
+const deleteReport = vi.fn(async () => undefined);
+vi.mock('../../../server/lib/inspection/reports', () => ({ deleteReport: (...a: unknown[]) => deleteReport(...(a as [])) }));
+
+// Imported AFTER the mocks above are registered.
+/* eslint-disable import/first */
+import eventsRoutes from '../../../server/api/events';
+import inspectionReportRoutes from '../../../server/api/inspections/reports';
+/* eslint-enable import/first */
+
+const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+const USER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
+const EVENT_ID = 'evt_1';
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+/**
+ * The API app as the worker assembles it: the same routers, the same mount
+ * paths (`server/index.ts` routes eventsRoutes at `/api`; inspectionReportRoutes
+ * is folded into `/api/inspections`), and the same AppError → status mapping the
+ * global handler performs. Only the auth middleware is replaced, by the one
+ * thing a test must be able to vary: which role is calling.
+ */
+function buildApp(role: Role, event: Record = {}) {
+ const app = new OpenAPIHono();
+ app.onError((err: unknown, c: Context) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status as 500);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT_ID);
+ c.set('user', { sub: USER_ID, role, tenantId: TENANT_ID });
+ c.set('userRole', role);
+ c.set('services', { event } as unknown as HonoConfig['Variables']['services']);
+ await next();
+ });
+ app.route('/api', eventsRoutes);
+ app.route('/api/inspections', inspectionReportRoutes);
+ return app;
+}
+
+const putStatus = (role: Role, status: string, event: Record = {}) =>
+ buildApp(role, event).request(`/api/events/${EVENT_ID}`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ status }),
+ }, FAKE_ENV);
+
+describe('PUT /api/events/:id — marking a visit complete', () => {
+ it('lets an inspector complete a visit (200)', async () => {
+ const updateEventStatus = vi.fn().mockResolvedValue(undefined);
+ const res = await putStatus('inspector', 'completed', { updateEventStatus });
+ expect(res.status).toBe(200);
+ expect(updateEventStatus).toHaveBeenCalledWith(TENANT_ID, EVENT_ID, 'completed');
+ });
+
+ it('lets an inspector cancel a visit they are standing at (200)', async () => {
+ const updateEventStatus = vi.fn().mockResolvedValue(undefined);
+ const res = await putStatus('inspector', 'cancelled', { updateEventStatus });
+ expect(res.status).toBe(200);
+ });
+
+ it('refuses an unknown status with 400 before any role question', async () => {
+ const updateEventStatus = vi.fn();
+ const res = await putStatus('inspector', 'results-received', { updateEventStatus });
+ expect(res.status).toBe(400);
+ expect(updateEventStatus).not.toHaveBeenCalled();
+ });
+});
+
+describe('PUT /api/events/:id — marking results_received', () => {
+ it('rejects results_received from an inspector with 403', async () => {
+ const updateEventStatus = vi.fn();
+ const res = await putStatus('inspector', 'results_received', { updateEventStatus });
+ expect(res.status).toBe(403);
+ const body = await res.json() as { error: { code: string } };
+ expect(body.error.code).toBe(ErrorCode.FORBIDDEN);
+ // The refusal must happen before the write, not after it.
+ expect(updateEventStatus).not.toHaveBeenCalled();
+ });
+
+ it('allows results_received from a manager (200)', async () => {
+ const updateEventStatus = vi.fn().mockResolvedValue(undefined);
+ const res = await putStatus('manager', 'results_received', { updateEventStatus });
+ expect(res.status).toBe(200);
+ expect(updateEventStatus).toHaveBeenCalledWith(TENANT_ID, EVENT_ID, 'results_received');
+ });
+
+ it('allows results_received from an owner (200)', async () => {
+ const updateEventStatus = vi.fn().mockResolvedValue(undefined);
+ const res = await putStatus('owner', 'results_received', { updateEventStatus });
+ expect(res.status).toBe(200);
+ });
+});
+
+describe('DELETE /api/events/:id — removing a visit', () => {
+ it('rejects an inspector with 403', async () => {
+ const deleteEvent = vi.fn();
+ const res = await buildApp('inspector', { deleteEvent })
+ .request(`/api/events/${EVENT_ID}`, { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(403);
+ expect(deleteEvent).not.toHaveBeenCalled();
+ });
+
+ it('allows a manager (200)', async () => {
+ const deleteEvent = vi.fn().mockResolvedValue(undefined);
+ const res = await buildApp('manager', { deleteEvent })
+ .request(`/api/events/${EVENT_ID}`, { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(200);
+ expect(deleteEvent).toHaveBeenCalledWith(TENANT_ID, EVENT_ID);
+ });
+});
+
+describe('DELETE /api/inspections/:id/reports/:reportId — destroying a deliverable', () => {
+ it('rejects an inspector with 403 and never reaches the delete', async () => {
+ deleteReport.mockClear();
+ const res = await buildApp('inspector')
+ .request('/api/inspections/insp_1/reports/rep_1', { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(403);
+ expect(deleteReport).not.toHaveBeenCalled();
+ });
+
+ it('lets a manager through to the delete (200)', async () => {
+ deleteReport.mockClear();
+ const res = await buildApp('manager')
+ .request('/api/inspections/insp_1/reports/rep_1', { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(200);
+ expect(deleteReport).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/unit/i18n/ui-locale.spec.ts b/tests/unit/i18n/ui-locale.spec.ts
new file mode 100644
index 000000000..c5c20e34f
--- /dev/null
+++ b/tests/unit/i18n/ui-locale.spec.ts
@@ -0,0 +1,259 @@
+import { describe, it, expect } from 'vitest';
+import {
+ readUiLocaleCookie,
+ resolveUiLocale,
+ setUiLocaleInCookieHeader,
+ uiLocaleSetCookie,
+ uiLocaleStampFor,
+ withResolvedUiLocale,
+} from '../../../server/lib/i18n/ui-locale';
+
+/**
+ * Every fixture starts from ALL FOUR sources present and ADVERSE — each test
+ * then names the one rung it is about. A chain test seeded with only the value
+ * it expects back passes against a hardcoded return, which is how a resolver
+ * ships answering one thing forever.
+ */
+const ADVERSE = {
+ userLocale: 'fr-FR',
+ tenantDefault: 'fr-FR',
+ cookie: 'fr-FR',
+ acceptLanguage: 'fr-FR,fr;q=0.9',
+};
+
+describe('resolveUiLocale precedence', () => {
+ it('prefers the viewer’s own stored choice above everything', () => {
+ expect(resolveUiLocale({
+ userLocale: 'es-419',
+ tenantDefault: 'en-US',
+ cookie: 'en',
+ acceptLanguage: 'en-GB,en;q=0.9',
+ })).toBe('es-419');
+ // ...and the other way round, so a test that only ever expects Spanish
+ // cannot pass on a resolver that only ever answers Spanish.
+ expect(resolveUiLocale({
+ userLocale: 'en-US',
+ tenantDefault: 'es-419',
+ cookie: 'es-419',
+ acceptLanguage: 'es-419,es;q=0.9',
+ })).toBe('en');
+ });
+
+ it('falls to the tenant default when the viewer has none', () => {
+ expect(resolveUiLocale({
+ ...ADVERSE, userLocale: null, tenantDefault: 'es-MX',
+ })).toBe('es-419');
+ expect(resolveUiLocale({
+ ...ADVERSE, userLocale: null, tenantDefault: 'en-US', cookie: 'es-419',
+ })).toBe('en');
+ });
+
+ it('lets the cookie beat the browser, because it is the explicit choice', () => {
+ // The switcher's whole contract. A Spanish-browsered viewer who picks
+ // English must KEEP English on the next page load; ranking the browser
+ // above the cookie hands it straight back and reads as a dead control.
+ expect(resolveUiLocale({
+ userLocale: null, tenantDefault: null,
+ cookie: 'en', acceptLanguage: 'es-419,es;q=0.9,en;q=0.5',
+ })).toBe('en');
+ expect(resolveUiLocale({
+ userLocale: null, tenantDefault: null,
+ cookie: 'es-419', acceptLanguage: 'en-US,en;q=0.9',
+ })).toBe('es-419');
+ });
+
+ it('falls to Accept-Language when no preference of any kind is stored', () => {
+ expect(resolveUiLocale({
+ userLocale: null, tenantDefault: null, cookie: null,
+ acceptLanguage: 'es-419,es;q=0.9,en;q=0.5',
+ })).toBe('es-419');
+ // Weighted, not first-listed: 'fr' outranks nothing we speak, so the
+ // scan must continue to the Spanish entry rather than stop.
+ expect(resolveUiLocale({
+ userLocale: null, tenantDefault: null, cookie: null,
+ acceptLanguage: 'fr-FR,es-MX;q=0.8,en;q=0.3',
+ })).toBe('es-419');
+ });
+
+ it('ends at English, never at undefined', () => {
+ expect(resolveUiLocale(ADVERSE)).toBe('en');
+ expect(resolveUiLocale({})).toBe('en');
+ });
+
+ it('ignores an unsupported value instead of stopping the chain on it', () => {
+ // A viewer whose stored locale names a language we dropped must still
+ // get their tenant's language, not a silent drop to English.
+ expect(resolveUiLocale({
+ ...ADVERSE, userLocale: 'de-DE', tenantDefault: 'es-419',
+ })).toBe('es-419');
+ // Same for junk that makes `new Intl.Locale` throw.
+ expect(resolveUiLocale({
+ ...ADVERSE, userLocale: 'not a locale!!', tenantDefault: 'es-419',
+ })).toBe('es-419');
+ });
+});
+
+describe('readUiLocaleCookie', () => {
+ it('finds the value among the other cookies a real request carries', () => {
+ expect(readUiLocaleCookie('__Host-inspector_token=abc.def; PARAGLIDE_LOCALE=es-419; oi-color-scheme=dark'))
+ .toBe('es-419');
+ });
+
+ it('returns null when absent, empty, or only a name that ENDS with ours', () => {
+ expect(readUiLocaleCookie('__Host-inspector_token=abc')).toBeNull();
+ expect(readUiLocaleCookie('')).toBeNull();
+ expect(readUiLocaleCookie(null)).toBeNull();
+ // A substring match here would read someone else's cookie as the locale.
+ expect(readUiLocaleCookie('XPARAGLIDE_LOCALE=es-419')).toBeNull();
+ expect(readUiLocaleCookie('PARAGLIDE_LOCALE=')).toBeNull();
+ });
+
+ it('does NOT normalise — a stale tag must read back as itself', () => {
+ // The caller compares this against a resolved locale to decide whether
+ // to rewrite. Normalising here would report 'en-US' as already correct
+ // and the cookie would never be replaced.
+ expect(readUiLocaleCookie('PARAGLIDE_LOCALE=en-US')).toBe('en-US');
+ });
+});
+
+describe('setUiLocaleInCookieHeader', () => {
+ it('keeps every other cookie — the JWT rides in this header', () => {
+ const out = setUiLocaleInCookieHeader('__Host-inspector_token=abc.def; oi-color-scheme=dark', 'es-419');
+ expect(out).toContain('__Host-inspector_token=abc.def');
+ expect(out).toContain('oi-color-scheme=dark');
+ expect(out).toContain('PARAGLIDE_LOCALE=es-419');
+ });
+
+ it('replaces an existing value rather than appending a second one', () => {
+ const out = setUiLocaleInCookieHeader('PARAGLIDE_LOCALE=en; a=1', 'es-419');
+ expect(out.match(/PARAGLIDE_LOCALE=/g)).toHaveLength(1);
+ expect(readUiLocaleCookie(out)).toBe('es-419');
+ });
+
+ it('handles a request that carried no cookies at all', () => {
+ expect(setUiLocaleInCookieHeader(null, 'es-419')).toBe('PARAGLIDE_LOCALE=es-419');
+ });
+});
+
+describe('withResolvedUiLocale (the seam)', () => {
+ const req = (headers: Record) =>
+ new Request('https://example.test/login', { headers });
+
+ it('stamps the browser’s language onto a first visit that has no cookie', () => {
+ const out = withResolvedUiLocale(req({ 'Accept-Language': 'es-419,es;q=0.9' }));
+ expect(readUiLocaleCookie(out.headers.get('Cookie'))).toBe('es-419');
+ });
+
+ it('leaves the request untouched once the cookie already agrees', () => {
+ const original = req({ Cookie: 'PARAGLIDE_LOCALE=es-419', 'Accept-Language': 'en-US' });
+ // Identity, not equality: once a choice has been persisted there is
+ // nothing to rewrite, and a copy that dropped anything off the request
+ // would be a cost paid on every page load for no benefit.
+ expect(withResolvedUiLocale(original)).toBe(original);
+ });
+
+ it('does not persist what it resolved — pre-auth there is no choice yet', () => {
+ // An anonymous visitor's language is re-read from Accept-Language every
+ // request rather than frozen into a cookie they never chose. Asserted
+ // because the alternative looks like a harmless optimisation: stamping
+ // here would outrank the browser on the NEXT request and a visitor who
+ // switched their browser language would be stuck.
+ const first = withResolvedUiLocale(req({ 'Accept-Language': 'es-419' }));
+ expect(readUiLocaleCookie(first.headers.get('Cookie'))).toBe('es-419');
+ const second = withResolvedUiLocale(req({ 'Accept-Language': 'en-US' }));
+ expect(readUiLocaleCookie(second.headers.get('Cookie'))).toBe('en');
+ });
+
+ it('rewrites a stale tag the cookie contract cannot serve', () => {
+ // 'en-US' is what the settings picker stores; Paraglide has no such
+ // locale, so an unrewritten cookie falls back to baseLocale by accident
+ // rather than by decision.
+ const out = withResolvedUiLocale(req({ Cookie: 'PARAGLIDE_LOCALE=en-US' }));
+ expect(readUiLocaleCookie(out.headers.get('Cookie'))).toBe('en');
+ });
+
+ it('preserves the session cookie while rewriting the locale one', () => {
+ const out = withResolvedUiLocale(req({
+ Cookie: '__Host-inspector_token=abc.def',
+ 'Accept-Language': 'es-MX',
+ }));
+ expect(out.headers.get('Cookie')).toContain('__Host-inspector_token=abc.def');
+ expect(readUiLocaleCookie(out.headers.get('Cookie'))).toBe('es-419');
+ });
+
+ it('preserves the method and URL it was handed', () => {
+ const post = new Request('https://example.test/inspections', {
+ method: 'POST', body: 'x', headers: { 'Accept-Language': 'es-419' },
+ });
+ const out = withResolvedUiLocale(post);
+ expect(out.method).toBe('POST');
+ expect(out.url).toBe('https://example.test/inspections');
+ });
+});
+
+describe('uiLocaleStampFor (the database half)', () => {
+ const req = (headers: Record) =>
+ new Request('https://example.test/inspections', { headers });
+ const NO_PREFERENCE = { userLocale: null, tenantDefault: null };
+
+ it('corrects a cookie that disagrees with the stored preference', () => {
+ // The whole point of #269: a viewer who set Spanish in Settings before
+ // this shipped must get Spanish without touching the new switcher.
+ const stamp = uiLocaleStampFor(
+ req({ Cookie: 'PARAGLIDE_LOCALE=en', 'Accept-Language': 'en-US' }),
+ { userLocale: 'es-419', tenantDefault: 'en-US' },
+ );
+ expect(stamp).not.toBeNull();
+ expect(readUiLocaleCookie(stamp!.split(';')[0])).toBe('es-419');
+ });
+
+ it('stamps nothing once the cookie already agrees — the common case', () => {
+ expect(uiLocaleStampFor(
+ req({ Cookie: 'PARAGLIDE_LOCALE=es-419' }),
+ { userLocale: 'es-419', tenantDefault: 'en-US' },
+ )).toBeNull();
+ });
+
+ it('cannot oscillate: what it writes is what the seam then resolves', () => {
+ // Feed the stamp's own output back through the request-borne resolver
+ // and then through the stamp again. A second stamp here would mean the
+ // two halves disagree and every page load would rewrite the cookie.
+ const stored = { userLocale: null, tenantDefault: 'en-US' };
+ const first = uiLocaleStampFor(
+ req({ 'Accept-Language': 'es-419,es;q=0.9' }), stored,
+ );
+ expect(first).not.toBeNull();
+ const settled = req({
+ Cookie: first!.split(';')[0],
+ 'Accept-Language': 'es-419,es;q=0.9',
+ });
+ expect(uiLocaleStampFor(settled, stored)).toBeNull();
+ expect(withResolvedUiLocale(settled)).toBe(settled);
+ });
+
+ it('corrects the stale tag the settings picker actually writes', () => {
+ // users.locale is 'en-US'/'es-419' (app/lib/locales.ts LOCALE_OPTIONS),
+ // never 'en'. Stamping it verbatim would put a tag in the cookie that
+ // Paraglide has no locale for.
+ const stamp = uiLocaleStampFor(req({}), { userLocale: 'en-US', tenantDefault: null });
+ expect(readUiLocaleCookie(stamp!.split(';')[0])).toBe('en');
+ });
+
+ it('falls to the browser when nothing at all is stored', () => {
+ const stamp = uiLocaleStampFor(
+ req({ 'Accept-Language': 'es-MX,es;q=0.9' }), NO_PREFERENCE,
+ );
+ expect(readUiLocaleCookie(stamp!.split(';')[0])).toBe('es-419');
+ });
+});
+
+describe('uiLocaleSetCookie', () => {
+ it('round-trips through the reader and lasts beyond the session', () => {
+ const value = uiLocaleSetCookie('es-419');
+ expect(readUiLocaleCookie(value.split(';')[0])).toBe('es-419');
+ expect(value).toContain('Path=/');
+ expect(value).toContain('Max-Age=31536000');
+ // Readable by the client switcher, which writes the same cookie.
+ expect(value).not.toContain('HttpOnly');
+ });
+});
diff --git a/tests/unit/idempotency/email-send.spec.ts b/tests/unit/idempotency/email-send.spec.ts
new file mode 100644
index 000000000..feeefa641
--- /dev/null
+++ b/tests/unit/idempotency/email-send.spec.ts
@@ -0,0 +1,112 @@
+/**
+ * M1 — outbound email is the first consumer of the idempotency store.
+ *
+ * A duplicate send cannot be recalled, so "the provider was called once" is
+ * asserted directly against a counting provider rather than inferred from a
+ * return value. Metering is asserted alongside it: a replayed send that still
+ * bills is the same bug wearing a different hat.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { createTestDb, setupSchema } from '../db';
+import { EmailService } from '../../../server/services/email.service';
+import { buildEmailDedupe } from '../../../server/lib/email/dedupe';
+import { idempotencyKeys } from '../../../server/lib/db/schema';
+
+let db: ReturnType['db'];
+
+function countingProvider() {
+ const state = { calls: 0 };
+ return {
+ state,
+ provider: {
+ async sendEmail() {
+ state.calls++;
+ return { ok: true as const };
+ },
+ },
+ };
+}
+
+function buildService(tenantId: string) {
+ const { state, provider } = countingProvider();
+ const metered = { calls: 0 };
+ const service = new EmailService(
+ 'test-api-key',
+ 'from@example.com',
+ 'TestApp',
+ undefined,
+ undefined,
+ { record: async () => { metered.calls++; } },
+ provider as never,
+ undefined,
+ undefined,
+ undefined,
+ buildEmailDedupe(db as never, tenantId),
+ );
+ return { service, sent: state, metered };
+}
+
+const send = (service: EmailService, key: string, subject = 'Report ready') =>
+ service.sendEmail(['client@example.com'], subject, 'hi
', undefined, { idempotencyKey: key });
+
+describe('email send idempotency', () => {
+ beforeEach(async () => {
+ const t = createTestDb();
+ await setupSchema(t.sqlite);
+ db = t.db;
+ });
+
+ it('the same email sent twice under one key reaches the provider ONCE', async () => {
+ const { service, sent } = buildService('t1');
+ await send(service, 'k1');
+ await send(service, 'k1');
+ expect(sent.calls).toBe(1);
+ });
+
+ it('does not meter the replayed send', async () => {
+ const { service, metered } = buildService('t1');
+ await send(service, 'k1');
+ await send(service, 'k1');
+ expect(metered.calls).toBe(1);
+ });
+
+ it('sends again when the key is new', async () => {
+ const { service, sent } = buildService('t1');
+ await send(service, 'k1');
+ await send(service, 'k2');
+ expect(sent.calls).toBe(2);
+ });
+
+ it('scopes the key to the tenant — the same key for two tenants sends twice', async () => {
+ const a = buildService('t1');
+ const b = buildService('t2');
+ await send(a.service, 'shared-key');
+ await send(b.service, 'shared-key');
+ expect(a.sent.calls).toBe(1);
+ expect(b.sent.calls).toBe(1);
+
+ const rows = await db.select().from(idempotencyKeys);
+ expect(rows).toHaveLength(2);
+ expect(rows.map(r => r.tenantId).sort()).toEqual(['t1', 't2']);
+ });
+
+ it('releases the key when delivery fails, so the retry actually sends', async () => {
+ let calls = 0;
+ const failing = {
+ async sendEmail() {
+ calls++;
+ return calls === 1 ? { ok: false as const, error: 'boom' } : { ok: true as const };
+ },
+ };
+ const service = new EmailService(
+ 'test-api-key', 'from@example.com', 'TestApp',
+ undefined, undefined, undefined,
+ failing as never,
+ undefined, undefined, undefined,
+ buildEmailDedupe(db as never, 't1'),
+ );
+ await expect(send(service, 'k1')).rejects.toThrow();
+ await send(service, 'k1');
+ expect(calls).toBe(2);
+ });
+});
diff --git a/tests/unit/idempotency/fingerprint.spec.ts b/tests/unit/idempotency/fingerprint.spec.ts
new file mode 100644
index 000000000..06140ccb7
--- /dev/null
+++ b/tests/unit/idempotency/fingerprint.spec.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import { canonicalize, fingerprint } from '../../../server/lib/idempotency/fingerprint';
+
+describe('canonicalize', () => {
+ it('orders keys so payload order cannot change the fingerprint', () => {
+ expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 }));
+ });
+
+ it('recurses into nested objects and arrays', () => {
+ expect(canonicalize({ x: [{ b: 1, a: 2 }] })).toBe(canonicalize({ x: [{ a: 2, b: 1 }] }));
+ });
+
+ it('does NOT treat array order as insignificant — [1,2] is not [2,1]', () => {
+ expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1]));
+ });
+});
+
+describe('fingerprint', () => {
+ it('differs when the body differs', async () => {
+ const a = await fingerprint('POST', '/api/inspections', { address: 'A' });
+ const b = await fingerprint('POST', '/api/inspections', { address: 'B' });
+ expect(a).not.toBe(b);
+ });
+
+ it('differs when the path differs but the body matches', async () => {
+ const a = await fingerprint('POST', '/api/inspections', { x: 1 });
+ const b = await fingerprint('POST', '/api/reports', { x: 1 });
+ expect(a).not.toBe(b);
+ });
+
+ it('is stable across calls', async () => {
+ const body = { address: '123 Main', date: '2026-08-05' };
+ expect(await fingerprint('POST', '/p', body)).toBe(await fingerprint('POST', '/p', body));
+ });
+});
diff --git a/tests/unit/idempotency/middleware.spec.ts b/tests/unit/idempotency/middleware.spec.ts
new file mode 100644
index 000000000..4d12cac15
--- /dev/null
+++ b/tests/unit/idempotency/middleware.spec.ts
@@ -0,0 +1,131 @@
+/**
+ * One test per row of the middleware's behaviour table (plan Task 3).
+ *
+ * The handler increments a counter, so "it ran twice" is directly observable
+ * rather than inferred from a response body.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { Hono } from 'hono';
+import { createTestDb, setupSchema } from '../db';
+import { idempotencyMiddleware } from '../../../server/lib/middleware/idempotency';
+import { claimKey } from '../../../server/lib/idempotency/store';
+import { fingerprint } from '../../../server/lib/idempotency/fingerprint';
+
+let db: ReturnType['db'];
+let ran = 0;
+
+const BODY = { address: '123 Main' };
+
+type Handler = () => { status: number; body: unknown } | Promise<{ status: number; body: unknown }>;
+
+function buildApp(handler: Handler, tenantId = 't1') {
+ const app = new Hono();
+ app.use('*', async (c, next) => {
+ c.set('tenantId', tenantId);
+ await next();
+ });
+ app.use('*', idempotencyMiddleware({ getDb: () => db as never }));
+ app.post('/thing', async (c) => {
+ const out = await handler();
+ return c.json(out.body as Record, out.status as 200);
+ });
+ return app;
+}
+
+function post(app: Hono, key?: string, body: unknown = BODY) {
+ return app.request('/thing', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ ...(key ? { 'Idempotency-Key': key } : {}),
+ },
+ body: JSON.stringify(body),
+ });
+}
+
+const ok = () => { ran++; return { status: 200, body: { id: 'abc' } }; };
+
+describe('idempotencyMiddleware', () => {
+ beforeEach(async () => {
+ const t = createTestDb();
+ await setupSchema(t.sqlite);
+ db = t.db;
+ ran = 0;
+ });
+
+ it('passes through untouched when there is no Idempotency-Key header', async () => {
+ const app = buildApp(ok);
+ expect((await post(app)).status).toBe(200);
+ expect((await post(app)).status).toBe(200);
+ expect(ran).toBe(2);
+ });
+
+ it('runs the handler on a claim and returns its response', async () => {
+ const app = buildApp(ok);
+ const res = await post(app, 'k1');
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ id: 'abc' });
+ expect(res.headers.get('Idempotency-Replayed')).toBeNull();
+ expect(ran).toBe(1);
+ });
+
+ it('replays the stored response without running the handler again', async () => {
+ const app = buildApp(ok);
+ const first = await post(app, 'k1');
+ const second = await post(app, 'k1');
+ expect(ran).toBe(1);
+ expect(second.status).toBe(first.status);
+ expect(await second.json()).toEqual({ id: 'abc' });
+ expect(second.headers.get('Idempotency-Replayed')).toBe('true');
+ });
+
+ it('releases the key when the handler fails, so a corrected retry is not locked out', async () => {
+ const app = buildApp(() => { ran++; return { status: 400, body: { error: 'nope' } }; });
+ expect((await post(app, 'k1')).status).toBe(400);
+ expect((await post(app, 'k1')).status).toBe(400);
+ expect(ran).toBe(2);
+ });
+
+ it('answers 409 while the first request is still in flight', async () => {
+ const fp = await fingerprint('POST', '/thing', BODY);
+ await claimKey(db as never, { tenantId: 't1', key: 'k1', fingerprint: fp, ttlMs: 86_400_000 });
+ const app = buildApp(ok);
+ const res = await post(app, 'k1');
+ expect(res.status).toBe(409);
+ expect(ran).toBe(0);
+ });
+
+ it('answers 422 IDEMPOTENCY_KEY_REUSED when the same key carries a different payload', async () => {
+ const app = buildApp(ok);
+ await post(app, 'k1', { address: 'A' });
+ const res = await post(app, 'k1', { address: 'CORRECTED' });
+ expect(res.status).toBe(422);
+ expect((await res.json() as { error: { code: string } }).error.code).toBe('IDEMPOTENCY_KEY_REUSED');
+ expect(ran).toBe(1);
+ });
+
+ it('scopes the key to the tenant — the same key under another tenant is a different key', async () => {
+ expect((await post(buildApp(ok, 't1'), 'shared')).status).toBe(200);
+ expect((await post(buildApp(ok, 't2'), 'shared')).status).toBe(200);
+ expect(ran).toBe(2);
+ });
+
+ it('two simultaneous requests with one key run the handler ONCE', async () => {
+ // The handler is parked on a gate so the second request genuinely
+ // overlaps the first. Firing both with Promise.all against the
+ // synchronous test DB does NOT overlap — the first request completes
+ // inside the second's microtask gap and the second gets a replay, so
+ // the in-flight branch would never be exercised.
+ let release!: () => void;
+ const gate = new Promise((resolve) => { release = resolve; });
+ const app = buildApp(async () => { ran++; await gate; return { status: 200, body: { id: 'abc' } }; });
+
+ const first = post(app, 'same-key');
+ const second = await post(app, 'same-key');
+ release();
+ const firstRes = await first;
+
+ expect(ran).toBe(1);
+ expect([firstRes.status, second.status].sort()).toEqual([200, 409]);
+ });
+});
diff --git a/tests/unit/idempotency/store.spec.ts b/tests/unit/idempotency/store.spec.ts
new file mode 100644
index 000000000..b3853517e
--- /dev/null
+++ b/tests/unit/idempotency/store.spec.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { createTestDb, setupSchema } from '../db';
+import { claimKey, completeKey } from '../../../server/lib/idempotency/store';
+
+const BASE = { tenantId: 't1', key: 'k1', fingerprint: 'fp1', ttlMs: 86_400_000 };
+let db: ReturnType['db'];
+
+describe('idempotency store', () => {
+ beforeEach(async () => {
+ const t = createTestDb();
+ await setupSchema(t.sqlite);
+ db = t.db;
+ });
+
+ it('first claim wins', async () => {
+ expect(await claimKey(db as never, BASE)).toBe('claimed');
+ });
+
+ it('a second claim while in flight does NOT execute — it reports in_flight', async () => {
+ await claimKey(db as never, BASE);
+ expect(await claimKey(db as never, BASE)).toEqual({ state: 'in_flight' });
+ });
+
+ it('after completion the stored response is replayed verbatim', async () => {
+ await claimKey(db as never, BASE);
+ await completeKey(db as never, { tenantId: 't1', key: 'k1', status: 201, body: '{"id":"abc"}' });
+ expect(await claimKey(db as never, BASE)).toEqual({ state: 'done', status: 201, body: '{"id":"abc"}' });
+ });
+
+ it('same key + different fingerprint is a mismatch, never a replay', async () => {
+ await claimKey(db as never, BASE);
+ await completeKey(db as never, { tenantId: 't1', key: 'k1', status: 201, body: '{}' });
+ expect(await claimKey(db as never, { ...BASE, fingerprint: 'DIFFERENT' }))
+ .toEqual({ state: 'fingerprint_mismatch' });
+ });
+
+ it('the same key under a different tenant is a different key', async () => {
+ await claimKey(db as never, BASE);
+ expect(await claimKey(db as never, { ...BASE, tenantId: 't2' })).toBe('claimed');
+ });
+});
diff --git a/tests/unit/inspections/inspection-create-results-row.spec.ts b/tests/unit/inspections/inspection-create-results-row.spec.ts
index 18f43f4a4..c3be8dc6c 100644
--- a/tests/unit/inspections/inspection-create-results-row.spec.ts
+++ b/tests/unit/inspections/inspection-create-results-row.spec.ts
@@ -73,6 +73,67 @@ describe('createInspection — results row', () => {
expect(Object.keys(data)).toHaveLength(0);
});
+ it('binds the row to the primary report, not to the inspection alone', async () => {
+ // One order can now deliver several documents, and `inspection_results`
+ // is per REPORT — `uq_results_report` replaced `uq_results_inspection`.
+ // But that index is unique on a NULLABLE column, so a row written
+ // without `report_id` is accepted, and any number of them are: nothing
+ // errors, and every per-report read either matches no document or
+ // matches a sibling's. Creation is the only place the binding can be
+ // guaranteed rather than guessed at.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await inspectionSvc.createInspection(TENANT, { propertyAddress: '5 Findings Way' } as any);
+
+ const inspection = await testDb.select().from(schema.inspections).get();
+ const report = await testDb
+ .select()
+ .from(schema.reports)
+ .where(eq(schema.reports.inspectionId, inspection!.id))
+ .get();
+ const results = await testDb
+ .select()
+ .from(schema.inspectionResults)
+ .where(eq(schema.inspectionResults.inspectionId, inspection!.id))
+ .get();
+
+ expect(report, 'the order was born without a primary report').toBeTruthy();
+ expect(
+ results!.reportId,
+ 'the results row carries no report_id — the document belongs to no report',
+ ).toBe(report!.id);
+ });
+
+ it('binds a re-inspection results row to the re-inspection own primary report', async () => {
+ // The second creation path, and the one that looks most like the first
+ // — which is exactly why it is the one that gets fixed in only one
+ // place. A re-inspection is its own order with its own primary report.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const baseline = await inspectionSvc.createInspection(TENANT, { propertyAddress: '6 Findings Way' } as any);
+ // `createReinspection` refuses an unpublished baseline, and it reads the
+ // latest published snapshot rather than the live row.
+ await testDb.insert(schema.reportVersions).values({
+ id: 'ver-baseline-1', tenantId: TENANT, inspectionId: baseline.id, versionNumber: 1,
+ snapshotJson: JSON.stringify({ inspection: {}, data: {}, units: [] }),
+ publishedAt: new Date(), publishedBy: 'tester', createdAt: new Date(),
+ } as never);
+
+ const reinspection = await inspectionSvc.createReinspection(TENANT, baseline.id, { selectedItemIds: [] });
+
+ const report = await testDb
+ .select()
+ .from(schema.reports)
+ .where(eq(schema.reports.inspectionId, reinspection.id))
+ .get();
+ const results = await testDb
+ .select()
+ .from(schema.inspectionResults)
+ .where(eq(schema.inspectionResults.inspectionId, reinspection.id))
+ .get();
+
+ expect(report).toBeTruthy();
+ expect(results!.reportId).toBe(report!.id);
+ });
+
it('gives each inspection its own row', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await inspectionSvc.createInspection(TENANT, { propertyAddress: '3 Findings Way' } as any);
diff --git a/tests/unit/inspections/offline-sync-per-report.spec.ts b/tests/unit/inspections/offline-sync-per-report.spec.ts
new file mode 100644
index 000000000..1762a541f
--- /dev/null
+++ b/tests/unit/inspections/offline-sync-per-report.spec.ts
@@ -0,0 +1,160 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { OpenAPIHono } from '@hono/zod-openapi';
+import type { Context } from 'hono';
+import { and, eq } from 'drizzle-orm';
+import { createTestDb, setupSchema } from '../db';
+import { tenants, inspections, reports, inspectionResults } from '../../../server/lib/db/schema';
+import type { HonoConfig } from '../../../server/types/hono';
+import { AppError } from '../../../server/lib/errors';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import * as schema from '../../../server/lib/db/schema';
+
+/**
+ * Field sync is per REPORT.
+ *
+ * `uq_results_report` is unique on `report_id`: an order carries one results row
+ * per deliverable, and the standard report and the sewer report each own their
+ * own findings. "The inspection's results" therefore stopped naming a row —
+ * a select on `inspection_id` alone returns whichever the scan reaches first.
+ *
+ * These two routes are what the FIELD uses (a signature captured on site, a
+ * photo deleted from a phone), they are addressed by inspection, and the
+ * offline client holds no report id — so they must resolve the order's primary
+ * report themselves. Each case below is seeded with the ANCILLARY row inserted
+ * first, so a route that still matches on inspection id picks the wrong
+ * document and the test fails rather than passing by luck of row order.
+ */
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+
+// Imported AFTER the mock above.
+// eslint-disable-next-line import/order
+import syncRoutes from '../../../server/api/inspection-sync';
+
+const TENANT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+const INSP_ID = 'insp-offline-1';
+const PRIMARY_REPORT = 'rep-primary';
+const SEWER_REPORT = 'rep-sewer';
+/** The schema requires a real-sized data URL (min 100 chars), not a token blob. */
+const SIGNATURE = 'data:image/png;base64,' + 'A'.repeat(200);
+const FAKE_ENV = { DB: {} } as HonoConfig['Bindings'];
+
+function buildApp(db: BetterSQLite3Database) {
+ const app = new OpenAPIHono();
+ app.onError((err: unknown, c: Context) => {
+ if (err instanceof AppError) {
+ return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status as 500);
+ }
+ return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500);
+ });
+ app.use('*', async (c, next) => {
+ c.set('tenantId', TENANT_ID);
+ c.set('userRole', 'inspector');
+ c.set('user', { sub: 'u-1', role: 'inspector', tenantId: TENANT_ID });
+ c.set('services', {} as HonoConfig['Variables']['services']);
+ await next();
+ });
+ app.route('/', syncRoutes);
+ (mockDrizzle as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue(db);
+ return app;
+}
+
+const photoRow = (label: string) => ({ 'item-1': { photos: [{ key: `${label}-a.jpg` }, { key: `${label}-b.jpg` }] } });
+
+describe('offline field sync writes to the primary report, not to whichever results row comes first', () => {
+ let db: BetterSQLite3Database;
+ let sqlite: { close: () => void };
+
+ beforeEach(async () => {
+ const setup = createTestDb();
+ db = setup.db as BetterSQLite3Database;
+ sqlite = setup.sqlite;
+ await setupSchema(sqlite);
+ (mockDrizzle as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue(db);
+
+ await db.insert(tenants).values({
+ id: TENANT_ID, name: 'Acme Inspections', slug: 'acme-test',
+ tier: 'free', status: 'active', maxUsers: 5,
+ deploymentMode: 'shared', createdAt: new Date(),
+ } as never);
+ await db.insert(inspections).values({
+ id: INSP_ID, tenantId: TENANT_ID, propertyAddress: '1 Main',
+ status: 'in_progress', date: '2026-08-04', createdAt: new Date(),
+ } as never);
+ // Ancillary FIRST, so an inspection-keyed scan reaches the wrong one.
+ await db.insert(reports).values([
+ { id: SEWER_REPORT, tenantId: TENANT_ID, inspectionId: INSP_ID, kind: 'ancillary', title: 'Sewer scope', status: 'in_progress', createdAt: new Date(1) },
+ { id: PRIMARY_REPORT, tenantId: TENANT_ID, inspectionId: INSP_ID, kind: 'primary', title: 'Home inspection', status: 'in_progress', createdAt: new Date(2) },
+ ] as never);
+ });
+
+ afterEach(() => sqlite.close());
+
+ const seedResults = async () => {
+ await db.insert(inspectionResults).values([
+ { id: 'res-sewer', tenantId: TENANT_ID, inspectionId: INSP_ID, reportId: SEWER_REPORT, data: photoRow('sewer'), lastSyncedAt: new Date(1) },
+ { id: 'res-primary', tenantId: TENANT_ID, inspectionId: INSP_ID, reportId: PRIMARY_REPORT, data: photoRow('primary'), lastSyncedAt: new Date(2) },
+ ] as never);
+ };
+
+ const readRow = async (id: string) =>
+ db.select().from(inspectionResults).where(eq(inspectionResults.id, id)).get();
+
+ it('records an inspector signature on the primary report only', async () => {
+ await seedResults();
+ const res = await buildApp(db).request(`/${INSP_ID}/inspector-signature`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ signatureBase64: SIGNATURE, signedAt: 1_754_000_000_000 }),
+ }, FAKE_ENV);
+ expect(res.status).toBe(200);
+
+ const primary = await readRow('res-primary');
+ const sewer = await readRow('res-sewer');
+ expect((primary?.data as Record)['_inspector_signature']).toBeTruthy();
+ expect((sewer?.data as Record)['_inspector_signature']).toBeUndefined();
+ });
+
+ it('deletes a photo from the primary report, leaving the sewer report whole', async () => {
+ await seedResults();
+ const res = await buildApp(db).request(`/${INSP_ID}/items/item-1/photos/0`, { method: 'DELETE' }, FAKE_ENV);
+ expect(res.status).toBe(200);
+ const body = await res.json() as { data: { deletedKey: string } };
+ expect(body.data.deletedKey).toBe('primary-a.jpg');
+
+ const primary = (await readRow('res-primary'))?.data as Record;
+ const sewer = (await readRow('res-sewer'))?.data as Record;
+ expect(primary['item-1'].photos.map((p) => p.key)).toEqual(['primary-b.jpg']);
+ expect(sewer['item-1'].photos.map((p) => p.key)).toEqual(['sewer-a.jpg', 'sewer-b.jpg']);
+ });
+
+ it('binds a results row it has to create to the primary report', async () => {
+ // No results rows yet — the first thing to reach the server is the
+ // signature. The row it creates used to carry a NULL report_id, which
+ // the unique index permits and every report-scoped read ignores.
+ const res = await buildApp(db).request(`/${INSP_ID}/inspector-signature`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ signatureBase64: SIGNATURE, signedAt: 1_754_000_000_000 }),
+ }, FAKE_ENV);
+ expect(res.status).toBe(200);
+
+ const rows = await db.select().from(inspectionResults)
+ .where(and(eq(inspectionResults.tenantId, TENANT_ID), eq(inspectionResults.inspectionId, INSP_ID))).all();
+ expect(rows).toHaveLength(1);
+ expect(rows[0].reportId).toBe(PRIMARY_REPORT);
+ });
+
+ it('refuses an order with no primary report rather than writing an unowned row', async () => {
+ await db.delete(reports).where(eq(reports.kind, 'primary'));
+ const res = await buildApp(db).request(`/${INSP_ID}/inspector-signature`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ signatureBase64: SIGNATURE, signedAt: 1_754_000_000_000 }),
+ }, FAKE_ENV);
+ expect(res.status).toBe(404);
+ const rows = await db.select().from(inspectionResults).all();
+ expect(rows).toHaveLength(0);
+ });
+});
diff --git a/tests/unit/inspections/publish-notification-coalescing.spec.ts b/tests/unit/inspections/publish-notification-coalescing.spec.ts
new file mode 100644
index 000000000..4a3813cdd
--- /dev/null
+++ b/tests/unit/inspections/publish-notification-coalescing.spec.ts
@@ -0,0 +1,189 @@
+/**
+ * One order, several reports — but not several "your report is ready" emails.
+ *
+ * Two things had to change together here, and each is invisible on its own. The
+ * `report.published` dedup key used to name only the INSPECTION, so the radon
+ * report's first publish looked like a retry of the standard report's and was
+ * silently dropped — for ever, not for a window. Making the key per-report fixes
+ * that and immediately creates the opposite problem: two documents finished in
+ * one sitting now cost the client two emails. The coalescing window is what
+ * separates "same delivery" from "genuinely later".
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { eq } from 'drizzle-orm';
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
+import * as schema from '../../../server/lib/db/schema';
+import { createTestDb, setupSchema } from '../db';
+import {
+ REPORT_NOTIFY_COALESCE_WINDOW_MS,
+ shouldCoalesceNotification,
+} from '../../../server/lib/inspection/report-notifications';
+
+vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() }));
+import { drizzle as mockDrizzle } from 'drizzle-orm/d1';
+import { InspectionService } from '../../../server/services/inspection.service';
+import { AutomationService } from '../../../server/services/automation.service';
+import { PeopleService } from '../../../server/services/people.service';
+import { ScopedDB } from '../../../server/lib/db/scoped';
+import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles';
+
+const TENANT = '00000000-0000-0000-0000-0000000000c0';
+const INSPECTION = 'insp-coalesce';
+const PRIMARY = 'rpt-primary';
+const SEWER = 'rpt-sewer';
+const RADON = 'rpt-radon';
+
+const MINUTE = 60_000;
+const DAY = 24 * 60 * MINUTE;
+const T0 = Date.parse('2026-08-03T15:00:00.000Z');
+
+let db: BetterSQLite3Database;
+let inspections: InspectionService;
+
+const publishOptions = (reportId: string) => ({
+ theme: 'modern', notifyClient: true, notifyAgent: true,
+ requireSignature: false, requirePayment: false, reportId,
+});
+
+async function seedReport(id: string, kind: 'primary' | 'ancillary', title: string, sortOrder: number) {
+ await db.insert(schema.reports).values({
+ id, tenantId: TENANT, inspectionId: INSPECTION, kind,
+ inspectionServiceId: null, templateId: null, title,
+ status: 'in_progress', createdAt: new Date(T0), sortOrder,
+ } as never);
+}
+
+async function notificationCount(): Promise {
+ const rows = await db.select().from(schema.automationLogs)
+ .where(eq(schema.automationLogs.inspectionId, INSPECTION)).all();
+ return rows.length;
+}
+
+async function notifiedAt(reportId: string): Promise {
+ const row = await db.select({ notifiedAt: schema.reports.notifiedAt })
+ .from(schema.reports).where(eq(schema.reports.id, reportId)).get();
+ return row?.notifiedAt ?? null;
+}
+
+beforeEach(async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(T0));
+
+ const fx = createTestDb();
+ db = fx.db;
+ await setupSchema(fx.sqlite);
+ (mockDrizzle as unknown as ReturnType