diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index a186b6996..6516ea389 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -20,15 +20,19 @@ import AxeBuilder from "@axe-core/playwright"; import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { E2E_VAULT, VAULT_STORAGE_STATE_PATH } from "./setup/global-setup"; import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, seedNamespaceDocs, - KNIE_EPISODE_ID, - MRT_DOC_ID, + vaultFixtureIds, } from "./setup/vault-fixture"; +// The vault on an account of its own (`E2E_VAULT`): its layout is account +// state, and the month-grid tests read the default. +const { knieEpisodeId: KNIE_EPISODE_ID, mrtDocId: MRT_DOC_ID } = + vaultFixtureIds(E2E_VAULT.username); + const DEFAULT_MAX_FILE_BYTES = 26_214_400; /** Scroll the shell's scroll container until `locator` is visible (the @@ -145,10 +149,10 @@ test.describe("document vault", () => { await serveAiBlock(page, aiBlockAvailable()); }); - test.use({ storageState: STORAGE_STATE_PATH }); + test.use({ storageState: VAULT_STORAGE_STATE_PATH }); test.beforeAll(async () => { - await ensureVaultFixture(); + await ensureVaultFixture(E2E_VAULT.username); }); // ── v1.42 — the archive as a month grid of preview tiles ────────────── @@ -532,7 +536,7 @@ test.describe("document vault", () => { test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => { test.skip(isMobile, "range selection is a desktop pointer flow"); test.setTimeout(90_000); - await seedNamespaceDocs("bulkfifty", 50); + await seedNamespaceDocs("bulkfifty", 50, E2E_VAULT.username); // Shared filing date → the namespace orders by id desc: "050" first, // "001" last. Anchor on the first card, shift-click the last. @@ -568,7 +572,7 @@ test.describe("document vault", () => { isMobile, }) => { test.skip(isMobile, "bulk selection is a desktop pointer flow"); - await seedNamespaceDocs("bulkpf", 5); + await seedNamespaceDocs("bulkpf", 5, E2E_VAULT.username); await page.goto("/documents?q=bulkpf"); for (let i = 1; i <= 5; i++) { @@ -603,7 +607,7 @@ test.describe("document vault", () => { }) => { test.skip(isMobile, "range selection is a desktop pointer flow"); test.setTimeout(120_000); - await seedNamespaceDocs("bulkchunk", 101); + await seedNamespaceDocs("bulkchunk", 101, E2E_VAULT.username); // Newest-first by id tiebreak: "101" renders first, "001" last. Walk // the infinite query to the end (pages of 50) so the range can span diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 493b36f80..5a749b7fc 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -251,6 +251,41 @@ export const TIMELINE_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateTimeline.json", ); +/** + * The vault specs' own accounts (`documents.spec.ts`, and the documents + * journeys of `v142-qol.spec.ts`). + * + * The vault's layout (cards or a list, months stacked or side by side) is a + * per-account preference. On the shared account the layout journey turned it + * while the month-grid tests read the default, so either could fail depending + * on which ran first. Each file seeds its own vault into its own account + * (`ensureVaultFixture(username)`). + */ +export const E2E_VAULT = { + email: "e2e-vault@healthlog.test", + username: "e2e-vault", + password: "Vt4!Hq8mZr2wKx6N", + // The upload-cap test sets the instance's cap through the admin console. + role: "ADMIN", +} as const; + +export const VAULT_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateVault.json", +); + +export const E2E_VAULT_LAYOUT = { + email: "e2e-vault-layout@healthlog.test", + username: "e2e-vault-layout", + password: "Vl7!Bn3qXw9pRt5M", + role: "USER", +} as const; + +export const VAULT_LAYOUT_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateVaultLayout.json", +); + /** * The day-doors journey's own account (`day-doors.spec.ts`). * @@ -991,6 +1026,32 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The vault specs' accounts — see `E2E_VAULT`. The vault fixture turns + // the documents module on and seeds the documents. + for (const account of [E2E_VAULT, E2E_VAULT_LAYOUT]) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, $6, $5, $5, $5, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, + [ + cuid(), + account.username, + account.email, + await hashPassword(account.password), + now, + account.role, + ], + ); + } + // The day-doors journey's account. Every module the journey reaches is // on; its record is written after the login capture. await pool.query( @@ -1365,6 +1426,28 @@ export default async function globalSetup(config: FullConfig): Promise { // surfaces' own buckets are cleared; nothing else in the table is touched. await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); + // Every other bucket the suite can fill, for the same reason. A bucket is + // keyed on the account it meters (`:`, sometimes with a + // further segment) or, on an anonymous surface, on the caller's address, + // and several of them outlive a run by up to an hour: the check-in + // ceiling, the per-account write bucket, the export and import buckets. + // A second run on the same database then met the first run's counts. + // Cleared are the buckets of the fixture accounts (every one of them is + // an `@healthlog.test` address or an `e2e-` record) and those of the + // loopback addresses the suite calls from; a developer's own accounts + // and their buckets are left alone. + await pool.query( + `DELETE FROM rate_limits r + USING users u + WHERE (u.email LIKE '%@healthlog.test' OR u.username LIKE 'e2e-%') + AND position(u.id IN r.key) > 0`, + ); + await pool.query( + `DELETE FROM rate_limits + WHERE key LIKE '%:127.0.0.1' OR key LIKE '%:::1' + OR key LIKE '%:::ffff:127.0.0.1' OR key LIKE '%:unknown'`, + ); + // One marker weight per record. Re-seeded by delete-then-insert so a // re-run cannot accumulate duplicates and so an edited constant takes // effect rather than sitting beside the old value. @@ -1690,6 +1773,10 @@ export default async function globalSetup(config: FullConfig): Promise { // The timeline journey's jar — see `E2E_TIMELINE`. await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH); + // The vault specs' jars — see `E2E_VAULT`. + await capture(E2E_VAULT, VAULT_STORAGE_STATE_PATH); + await capture(E2E_VAULT_LAYOUT, VAULT_LAYOUT_STORAGE_STATE_PATH); + // The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`. await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH); await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH); diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts index 94edbdd02..0d2b8b236 100644 --- a/e2e/setup/vault-fixture.ts +++ b/e2e/setup/vault-fixture.ts @@ -188,6 +188,39 @@ function jpegWithGps(marker: string): Buffer { export const KNIE_EPISODE_ID = "e2evaultknie000000000001"; export const MRT_DOC_ID = "e2evaultmrt0000000000001"; + +/** + * The vault corpus's ids for one account. The shared account keeps the ids + * above; an account of its own gets the same corpus under ids of the same + * length with its own five-letter tag in place of `vault`, so two accounts' + * vaults never claim one row. Only `ensureVaultFixture` and + * `seedNamespaceDocs` take an account. + */ +const VAULT_TAGS: Record = { + "e2e-vault": "vspec", + "e2e-vault-layout": "vqolx", +}; + +function vaultTag(username: string): string { + if (username === E2E_USER.username) return "vault"; + const tag = VAULT_TAGS[username]; + if (!tag) throw new Error(`[vault-fixture] no id tag for ${username}`); + return tag; +} + +function inVault(id: string, username: string): string { + return id.replace(/^e2evault/, `e2e${vaultTag(username)}`); +} + +export function vaultFixtureIds(username: string): { + knieEpisodeId: string; + mrtDocId: string; +} { + return { + knieEpisodeId: inVault(KNIE_EPISODE_ID, username), + mrtDocId: inVault(MRT_DOC_ID, username), + }; +} /** A stored PDF the AI-assist specs drive (mocked provider); title/kind are * bland so an applied suggestion is visibly different. */ export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001"; @@ -268,10 +301,10 @@ async function withVaultSeedLock( } } -async function getUserId(pool: pg.Pool): Promise { +async function getUserId(pool: pg.Pool, username: string): Promise { const res = await pool.query<{ id: string }>( "SELECT id FROM users WHERE username = $1", - [E2E_USER.username], + [username], ); const id = res.rows[0]?.id; if (!id) { @@ -344,7 +377,7 @@ export async function resetEncounters(): Promise { if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); const pool = new pg.Pool({ connectionString: url }); try { - const userId = await getUserId(pool); + const userId = await getUserId(pool, E2E_USER.username); await pool.query( "DELETE FROM encounter_document_links WHERE user_id = $1", [userId], @@ -360,13 +393,15 @@ export async function resetEncounters(): Promise { * corpus. Safe to call from every spec's beforeAll — every statement * upserts. */ -export async function ensureVaultFixture(): Promise { +export async function ensureVaultFixture( + username: string = E2E_USER.username, +): Promise { const url = process.env.DATABASE_URL; if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); const pool = new pg.Pool({ connectionString: url }); try { await withVaultSeedLock(pool, async () => { - const userId = await getUserId(pool); + const userId = await getUserId(pool, username); // Opt-in module: merge, never overwrite other module preferences. await pool.query( @@ -385,7 +420,7 @@ export async function ensureVaultFixture(): Promise { VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`, - [KNIE_EPISODE_ID, userId], + [inVault(KNIE_EPISODE_ID, username), userId], ); // 60 background documents spread over 36 months (newest ~current). @@ -393,7 +428,10 @@ export async function ensureVaultFixture(): Promise { for (let i = 0; i < 60; i++) { const monthsBack = Math.floor((i * 36) / 60); const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25))); - const id = `e2evaultdoc${String(i).padStart(12, "0")}`; + const id = inVault( + `e2evaultdoc${String(i).padStart(12, "0")}`, + username, + ); docs.push({ id, kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length], @@ -406,17 +444,17 @@ export async function ensureVaultFixture(): Promise { } // The MRT report + two adjacent images, same date, all linked to Knie. docs.push({ - id: MRT_DOC_ID, + id: inVault(MRT_DOC_ID, username), kind: "IMAGING", title: "MRT Knie", filename: "mrt-knie.pdf", mimeType: "application/pdf", documentDate: MRT_DOC_DATE, - bytes: tinyPdf(MRT_DOC_ID), + bytes: tinyPdf(inVault(MRT_DOC_ID, username)), }); for (const n of [1, 2]) { docs.push({ - id: `e2evaultimg000000000000${n}`, + id: inVault(`e2evaultimg000000000000${n}`, username), kind: "IMAGING", title: `MRT Aufnahme ${n}`, filename: `mrt-aufnahme-${n}.png`, @@ -432,14 +470,19 @@ export async function ensureVaultFixture(): Promise { MRT_DOC_ID, "e2evaultimg0000000000001", "e2evaultimg0000000000002", - ]; + ].map((id) => inVault(id, username)); for (const [i, docId] of linked.entries()) { await pool.query( `INSERT INTO document_condition_links (id, document_id, episode_id, user_id, created_at) VALUES ($1, $2, $3, $4, NOW()) ON CONFLICT (document_id, episode_id) DO NOTHING`, - [`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId], + [ + inVault(`e2evaultlink00000000000${i + 1}`, username), + docId, + inVault(KNIE_EPISODE_ID, username), + userId, + ], ); } }); @@ -462,7 +505,7 @@ export async function ensureVaultAiFixture(): Promise { const pool = new pg.Pool({ connectionString: url }); try { await withVaultSeedLock(pool, async () => { - const userId = await getUserId(pool); + const userId = await getUserId(pool, E2E_USER.username); await upsertDocs(pool, userId, [ { @@ -522,12 +565,13 @@ export async function ensureVaultAiFixture(): Promise { export async function seedNamespaceDocs( prefix: string, count: number, + username: string = E2E_USER.username, ): Promise { const url = process.env.DATABASE_URL; if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); const pool = new pg.Pool({ connectionString: url }); try { - const userId = await getUserId(pool); + const userId = await getUserId(pool, username); const docs: DocRow[] = []; for (let i = 0; i < count; i++) { const id = `e2e${prefix}${String(i).padStart(8, "0")}`; @@ -569,7 +613,7 @@ export async function ensureProcedureFixture(): Promise { await seedNamespaceDocs(PROCEDURE_DOC_PREFIX, 3); const pool = new pg.Pool({ connectionString: url }); try { - const userId = await getUserId(pool); + const userId = await getUserId(pool, E2E_USER.username); const procedures = [ { id: PROCEDURE_KNEE_ID, @@ -636,7 +680,7 @@ export async function ensureShareDocFixture(): Promise { if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); const pool = new pg.Pool({ connectionString: url }); try { - const userId = await getUserId(pool); + const userId = await getUserId(pool, E2E_USER.username); await pool.query( `UPDATE users SET module_preferences_json = diff --git a/e2e/v142-qol.spec.ts b/e2e/v142-qol.spec.ts index c4bee695c..3b89f22c1 100644 --- a/e2e/v142-qol.spec.ts +++ b/e2e/v142-qol.spec.ts @@ -16,7 +16,11 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + E2E_VAULT_LAYOUT, + STORAGE_STATE_PATH, + VAULT_LAYOUT_STORAGE_STATE_PATH, +} from "./setup/global-setup"; import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture"; import { mockPopulatedInsights } from "./utils/mock-populated-insights"; @@ -36,17 +40,18 @@ async function putDocumentsLayout( const resetDocumentsLayout = (page: Page) => putDocumentsLayout(page, "stacked"); -/** - * The account is shared with `documents.spec.ts`, which reads the default - * (flowing) arrangement, so the journey hands the account back on the - * default rather than on the state it started from. - */ +/** And leaves its account on the default, as a new account would read it. */ const restoreDefaultDocumentsLayout = (page: Page) => putDocumentsLayout(page, "flow"); +// The vault's layout is account state, and this journey turns it, so these +// tests run on a vault account of their own (`E2E_VAULT_LAYOUT`); the month +// grid tests in `documents.spec.ts` read the default on theirs. test.describe("documents", () => { + test.use({ storageState: VAULT_LAYOUT_STORAGE_STATE_PATH }); + test.beforeAll(async () => { - await ensureVaultFixture(); + await ensureVaultFixture(E2E_VAULT_LAYOUT.username); }); test("the display choice behind the wrench persists across visits", async ({ @@ -71,11 +76,15 @@ test.describe("documents", () => { settings.locator('[data-slot="module-view-list"]'), ).toHaveAttribute("aria-pressed", "true"); const flow = settings.locator('[data-slot="documents-arrangement-flow"]'); - // Wait for the write to land, so the reload below reads it back. + // Wait for THIS write to land, so the reload below reads it back. The + // view switch above writes too, and its answer can arrive after this + // wait starts; matching on the arrangement keeps the two apart. const saved = page.waitForResponse( (r) => r.url().includes("/api/documents/inbound/layout") && - r.request().method() === "PUT", + r.request().method() === "PUT" && + (r.request().postDataJSON() as { arrangement?: string } | null) + ?.arrangement === "flow", ); await flow.click(); expect((await saved).status()).toBe(200); @@ -111,7 +120,7 @@ test.describe("documents", () => { isMobile, }) => { test.skip(isMobile, "selection by checkbox is a pointer flow"); - await seedNamespaceDocs("qoldel", 2); + await seedNamespaceDocs("qoldel", 2, E2E_VAULT_LAYOUT.username); await page.goto("/documents?q=qoldel"); const box = page.getByRole("checkbox", { name: "Select qoldel 002" }); await expect(box).toBeVisible(); @@ -155,7 +164,7 @@ test.describe("documents", () => { isMobile, }) => { test.skip(isMobile, "keyboard flow"); - await seedNamespaceDocs("qolkey", 1); + await seedNamespaceDocs("qolkey", 1, E2E_VAULT_LAYOUT.username); await page.goto("/documents?q=qolkey"); const open = page.getByRole("button", { name: "Open qolkey 001" }); await expect(open).toBeVisible(); @@ -173,7 +182,7 @@ test.describe("documents", () => { isMobile, }) => { test.skip(isMobile, "selection by checkbox is a pointer flow"); - await seedNamespaceDocs("qolclear", 2); + await seedNamespaceDocs("qolclear", 2, E2E_VAULT_LAYOUT.username); await page.goto("/documents?q=qolclear"); await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();