From f185855d76dfb4e96d0970e0b1c1f93a7d0b6cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:58:39 +0200 Subject: [PATCH] test(e2e): log a mood at a chosen time against the real server The mood timestamp spec answered the POST and the list read itself. It now runs on the mood journey's account: the instant typed into the form is the instant stored, and the entry is in the list, also after a reload. --- e2e/mood-timestamp.spec.ts | 98 +++++++++++++------ .../e2e-critical-no-api-stub-guard.test.ts | 2 +- 2 files changed, 67 insertions(+), 33 deletions(-) diff --git a/e2e/mood-timestamp.spec.ts b/e2e/mood-timestamp.spec.ts index f157aa674..92f0a43f9 100644 --- a/e2e/mood-timestamp.spec.ts +++ b/e2e/mood-timestamp.spec.ts @@ -1,39 +1,53 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + criticalAccount, + criticalStorageState, + resetCriticalAccount, +} from "./setup/critical-fixture"; + +/** + * Logging a mood through the form, against the real server: the time stays + * visible while the details are collapsed, the instant typed into it is the + * instant stored, and the entry is in the list afterwards. + * + * Until v1.46 this spec answered the POST and the list read itself, so the + * list was always empty and the POST always succeeded. The account is now the + * mood journey's own, one per project (`critical-fixture.ts`), emptied before + * the test; the empty state the form opens from is the account's real one. + */ + +/** The account's mood entries, as the list route answers them. */ +async function storedMoods( + page: Page, +): Promise> { + const res = await page.request.get("/api/mood-entries"); + expect(res.status()).toBe(200); + const { data } = (await res.json()) as { + data: { entries: Array<{ mood: string; moodLoggedAt: string }> }; + }; + return data.entries.map(({ mood, moodLoggedAt }) => ({ mood, moodLoggedAt })); +} test.describe("mood timestamp capture", () => { - test.use({ storageState: STORAGE_STATE_PATH }); + test.use({ storageState: criticalStorageState("mood") }); - test("keeps Time visible while details are collapsed and submits the selected instant", async ({ + test.beforeEach(async ({}, testInfo) => { + // Fixture hygiene: what a previous run or a retry left on the account. + await resetCriticalAccount(criticalAccount("mood", testInfo), [ + "DELETE FROM mood_entry_rollups WHERE user_id = $1", + "DELETE FROM mood_entries WHERE user_id = $1", + ]); + }); + + test("a mood logged at a chosen time is stored at that instant and listed @critical", async ({ page, }) => { - let submitted: Record | null = null; - - await page.route("**/api/mood-entries*", async (route) => { - if (route.request().method() === "POST") { - submitted = route.request().postDataJSON() as Record; - await route.fulfill({ - status: 201, - contentType: "application/json", - body: JSON.stringify({ data: { id: "created-mood" }, error: null }), - }); - return; - } - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } }, - error: null, - }), - }); - }); - await page.goto("/mood", { waitUntil: "domcontentloaded" }); - // The list is mocked empty, so its empty state carries the add action - // (the header drops its own while there is nothing to list). + // An empty list carries the add action in its empty state (the header + // drops its own while there is nothing to list). await page.getByRole("button", { name: "Log your first mood" }).click(); await page.getByRole("radio", { name: "Amazing" }).click(); @@ -53,12 +67,32 @@ test.describe("mood timestamp capture", () => { await timeInput.fill("09:45"); await timeInput.blur(); + const created = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/mood-entries" && + res.request().method() === "POST", + ); await page.getByRole("button", { name: "Save", exact: true }).click(); + expect((await created).status()).toBe(201); - await expect.poll(() => submitted).not.toBeNull(); - expect(submitted).toMatchObject({ - mood: "SUPER_GUT", - moodLoggedAt: "2026-01-15T08:45:00.000Z", + // 09:45 in Berlin in January is 08:45 UTC. + expect(await storedMoods(page)).toEqual([ + { mood: "SUPER_GUT", moodLoggedAt: "2026-01-15T08:45:00.000Z" }, + ]); + + // The list shows it, at either width (the table and the phone cards each + // carry the marker; one of them is on screen). + const rows = page.locator('[data-slot="mood-rows"]').filter({ + visible: true, }); + await expect(rows).toBeVisible(); + await expect(rows).toContainText("Amazing"); + await expect(rows).toContainText("09:45"); + + // And still after a reload, which reads the list from the server again + // rather than from the cache the mutation updated. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(rows).toContainText("Amazing"); + await expect(rows).toContainText("09:45"); }); }); diff --git a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts index 856884d96..c571d4bbd 100644 --- a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -49,7 +49,7 @@ const E2E = join(process.cwd(), "e2e"); * the set is the release gate, so a journey that loses its tag is a change * somebody should see here. */ -const CRITICAL_TEST_FLOOR = 11; +const CRITICAL_TEST_FLOOR = 12; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST =