test(e2e): give the vault specs their own accounts, and clear every bucket at setup

The vault's layout is account state. documents.spec read the default
while the layout journey in v142-qol turned it on the shared account,
so which one failed depended on the order they ran in. Each now runs on
a vault account of its own; the vault fixture seeds the same corpus
under ids tagged per account, so two vaults never claim one row.

Global setup clears every rate-limit bucket keyed on a fixture account
or on the loopback addresses the suite calls from, not only the login
and share buckets: the check-in, write, export and import buckets
outlive a run by up to an hour, and a second run on the same database
met the first run's counts. A developer's own accounts are left alone.
The layout journey waits for its own write rather than whichever layout
write answers first.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 18:29:54 +02:00
1 parent 3b6e0bf0fb
commit 5273a8d899
4 files changed
+180 -36

No files matched your search

+12 -8
View File
@@ -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
+87
View File
@@ -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<void> {
],
);
// 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<void> {
// 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 (`<surface>:<id>`, 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<void> {
// 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);
+60 -16
View File
@@ -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<string, string> = {
"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<string> {
async function getUserId(pool: pg.Pool, username: string): Promise<string> {
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<void> {
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<void> {
* corpus. Safe to call from every spec's beforeAll — every statement
* upserts.
*/
export async function ensureVaultFixture(): Promise<void> {
export async function ensureVaultFixture(
username: string = E2E_USER.username,
): Promise<void> {
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<void> {
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<void> {
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<void> {
}
// 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<void> {
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<void> {
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<void> {
export async function seedNamespaceDocs(
prefix: string,
count: number,
username: string = E2E_USER.username,
): Promise<void> {
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<void> {
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<void> {
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 =
+21 -12
View File
@@ -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();