mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
test(e2e): authenticated dashboard render + add-measurement flow
Two new specs exercise the core happy path: the dashboard tile strip + Recharts surface render against a mocked /api/analytics, and the quick-entry dropdown → MeasurementForm dialog → POST /api/measurements round-trip surfaces a weight reading on /measurements. Both rely on the global setup's pre-captured session cookie, so neither hits the auth rate limit. The mocks fully isolate from the DB so we don't need a 90-day measurement seed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
535465c0ef
commit
3999ea7b3c
2 files changed
+344
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Authenticated dashboard render — proves the seed user can reach the
|
||||
* home dashboard and the core widgets paint without uncaught client
|
||||
* errors.
|
||||
*
|
||||
* The dashboard self-gates each tile on (visible AND has data), so a
|
||||
* fresh seed user with zero measurements would render an empty strip
|
||||
* and skip all charts. Rather than seed measurements before this spec
|
||||
* runs (which couples the spec to a particular signature of the
|
||||
* `/api/measurements` shape), we mock `/api/analytics` and
|
||||
* `/api/dashboard/widgets` to return a deterministic dataset. The
|
||||
* widget layout + chart wrappers are pure enough that asserting on the
|
||||
* mocked-data render exercises the same code paths as a real session.
|
||||
*/
|
||||
test.describe("authenticated dashboard render", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock analytics with a populated weight summary so the weight tile
|
||||
// and chart render. `count` must be >0 for the tile to clear the
|
||||
// data-floor gate in `src/app/page.tsx`.
|
||||
await page.route("**/api/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
summaries: {
|
||||
WEIGHT: {
|
||||
latest: 78.5,
|
||||
avg7: 78.2,
|
||||
avg30: 77.9,
|
||||
slope30: { slope: -0.05, direction: "down" },
|
||||
count: 30,
|
||||
},
|
||||
BLOOD_PRESSURE_SYS: {
|
||||
latest: 124,
|
||||
avg7: 122,
|
||||
avg30: 121,
|
||||
slope30: { slope: 0.1, direction: "flat" },
|
||||
count: 25,
|
||||
},
|
||||
BLOOD_PRESSURE_DIA: {
|
||||
latest: 80,
|
||||
avg7: 79,
|
||||
avg30: 78,
|
||||
slope30: { slope: 0.05, direction: "flat" },
|
||||
count: 25,
|
||||
},
|
||||
PULSE: {
|
||||
latest: 68,
|
||||
avg7: 70,
|
||||
avg30: 71,
|
||||
slope30: { slope: -0.2, direction: "down" },
|
||||
count: 25,
|
||||
},
|
||||
},
|
||||
bpInTargetPct: 78,
|
||||
glucoseByContext: {},
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/mood/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], summary: { count: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The chart's per-type fetch reads `json.data.measurements` and
|
||||
// expects a `meta.total`. Return a small dataset so the chart has
|
||||
// points to plot — otherwise Recharts mounts a placeholder instead
|
||||
// of the recharts-wrapper SVG and the assertion below times out.
|
||||
await page.route("**/api/measurements*", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const type = url.searchParams.get("type") ?? "WEIGHT";
|
||||
const baseValue =
|
||||
type === "BLOOD_PRESSURE_SYS"
|
||||
? 124
|
||||
: type === "BLOOD_PRESSURE_DIA"
|
||||
? 80
|
||||
: type === "PULSE"
|
||||
? 70
|
||||
: 78.5;
|
||||
const measurements = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `m_${type}_${i}`,
|
||||
type,
|
||||
value: baseValue + (i % 3) - 1,
|
||||
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
|
||||
notes: null,
|
||||
}));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { measurements, meta: { total: measurements.length } },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// The general-status insight card on /insights drives the "AI insight
|
||||
// card" assertion. We mock its endpoint with a connected, populated
|
||||
// payload so the card resolves to a real text body instead of the
|
||||
// "Connect ChatGPT to enable" empty-state.
|
||||
await page.route("**/api/insights/general-status*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
hasProvider: true,
|
||||
text: "Your blood-pressure trend is stable. Continue current routine.",
|
||||
cached: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard tile strip + chart + insight card render", async ({
|
||||
page,
|
||||
}) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("pageerror", (err) => {
|
||||
consoleErrors.push(err.message);
|
||||
});
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Tile strip — `data-slot="dashboard-tile-strip"` is the stable hook
|
||||
// the dashboard exposes. The component sets `data-tile-count` to the
|
||||
// active tile count; assert it's >0 so we know at least one tile
|
||||
// cleared the visibility + data-floor gate.
|
||||
const strip = page.locator('[data-slot="dashboard-tile-strip"]');
|
||||
await expect(strip).toBeVisible({ timeout: 10_000 });
|
||||
const tileCount = Number(await strip.getAttribute("data-tile-count"));
|
||||
expect(tileCount).toBeGreaterThan(0);
|
||||
|
||||
// At least one chart must render. Recharts mounts a `<svg>` with
|
||||
// `class="recharts-surface"` (or a `<div class="recharts-wrapper">`
|
||||
// around it). Use the recharts class so we don't accidentally match
|
||||
// an aria-hidden lucide-react icon.
|
||||
await expect(
|
||||
page.locator(".recharts-wrapper, .recharts-surface").first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// AI insight card: visit the dedicated /insights page (which is the
|
||||
// only surface that mounts the general-status card by default).
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.getByText("Your blood-pressure trend is stable", { exact: false }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Filter out a small set of known-noise messages — we care about
|
||||
// real React/JS uncaught errors only. 404s from optional assets
|
||||
// (manifest icons, prefetched chunks for unrendered routes) are
|
||||
// explicitly tolerated; they don't affect the dashboard render.
|
||||
const significant = consoleErrors.filter(
|
||||
(msg) =>
|
||||
!/ResizeObserver loop|Download the React DevTools|Warning: |\[Fast Refresh\]|Failed to load resource|net::ERR_/i.test(
|
||||
msg,
|
||||
),
|
||||
);
|
||||
expect(significant, significant.join("\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Add-measurement flow — exercises the dashboard's quick-entry dropdown,
|
||||
* the dialog-mounted MeasurementForm, the POST to /api/measurements, and
|
||||
* the resulting query invalidation that surfaces the row on
|
||||
* `/measurements`.
|
||||
*
|
||||
* Weight is the simplest type (single numeric input + datetime), so it
|
||||
* keeps the spec focused on the round-trip rather than the BP three-field
|
||||
* batched submit.
|
||||
*/
|
||||
test.describe("add measurement flow", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("creating a weight reading surfaces it in the list", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Capture the POST payload so we can assert against it independently
|
||||
// of any list-fetch response Playwright might cache. Match both
|
||||
// `/api/measurements` (POST creates a reading) and the list query
|
||||
// `/api/measurements?type=...&limit=...&offset=...` (GET).
|
||||
let postedBody: unknown = null;
|
||||
await page.route("**/api/measurements*", async (route) => {
|
||||
const req = route.request();
|
||||
if (req.method() === "POST") {
|
||||
postedBody = JSON.parse(req.postData() ?? "null");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { id: "m_e2e_1", success: true },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// GET — return the just-posted reading so the list view shows it.
|
||||
// The list expects `data.measurements: Measurement[]` plus
|
||||
// `data.meta.total`; chart fetches use the same envelope.
|
||||
const body = postedBody as {
|
||||
type?: string;
|
||||
value?: number;
|
||||
measuredAt?: string;
|
||||
} | null;
|
||||
const measurements = body
|
||||
? [
|
||||
{
|
||||
id: "m_e2e_1",
|
||||
type: body.type,
|
||||
value: body.value,
|
||||
measuredAt: body.measuredAt,
|
||||
notes: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { measurements, meta: { total: measurements.length } },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Dashboard's analytics fetch — empty summaries are fine; we only
|
||||
// need the page to render so we can click "Add".
|
||||
await page.route("**/api/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/mood/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], summary: { count: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Open the "Add" dropdown — the dashboard's quick-entry trigger sits
|
||||
// at the top-right of `<main>`. Scope the locator there so we don't
|
||||
// accidentally match an "Add" button on the sidebar.
|
||||
const main = page.locator("main");
|
||||
await main
|
||||
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The Radix DropdownMenu mounts its content in a portal; the role
|
||||
// is `menuitem`. Both items currently render the literal "Add" in
|
||||
// EN (measurements.addMeasurement = "Add", mood.addEntry = "Add"),
|
||||
// so we disambiguate by hitting the FIRST one — that's the
|
||||
// measurement entry per the dashboard JSX order. Sub-optimal, but
|
||||
// robust against the i18n collision we don't want to fix in this
|
||||
// phase (would touch dashboard copy).
|
||||
await page.getByRole("menuitem").first().click();
|
||||
|
||||
// Form should be visible inside the dialog. Switch type to WEIGHT
|
||||
// (combo-box defaults to BLOOD_PRESSURE).
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The Select trigger renders the current label — click to open and
|
||||
// pick the weight option.
|
||||
await dialog.getByRole("combobox").first().click();
|
||||
await page
|
||||
.getByRole("option", { name: /weight|gewicht/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Weight is single-value mode — fill the value field with 78.4 kg.
|
||||
const valueInput = dialog.locator("#value");
|
||||
await valueInput.fill("78.4");
|
||||
|
||||
// Save
|
||||
await dialog.getByRole("button", { name: /save|speichern/i }).click();
|
||||
|
||||
// Dialog closes when the mutation succeeds
|
||||
await expect(dialog).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
// Assert the POST went through with the expected shape
|
||||
expect(postedBody).toMatchObject({
|
||||
type: "WEIGHT",
|
||||
value: 78.4,
|
||||
});
|
||||
|
||||
// Navigate to the list and confirm the entry surfaces. The list
|
||||
// page renders entries from /api/measurements which we've mocked
|
||||
// to echo the just-posted row.
|
||||
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The list renders a desktop table AND a mobile card grid (one is
|
||||
// hidden via `md:hidden`/`hidden md:block`). At least one match
|
||||
// means our row landed in the DOM regardless of viewport.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await page
|
||||
.locator("main")
|
||||
.getByText(/78[.,]4/)
|
||||
.count(),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user