mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
test(e2e): cover the admin carrier chip + CSV column and the GLP-1 tile tab/range strip
Mock /api/admin/audit-log so a known DACH carrier row paints the carrier chip and the CSV export carries the `carrier` column header. Mock /api/dashboard/glp1 so the secondary tile mounts; assert the tab strip + range strip render, range toggles flip the active state, and switching to the Weight tab keeps the chart pane visible.
This commit is contained in:
1 parent
07f6bfa5b7
commit
b2361e9ddc
2 files changed
+320
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.27 B3 — admin login-overview carrier chip + CSV column.
|
||||
*
|
||||
* Two contracts under test:
|
||||
*
|
||||
* 1. The carrier chip slot (`data-slot="login-overview-carrier"`)
|
||||
* renders beneath the auth-provider chip when the audit row
|
||||
* carries a GeoLite2-ASN organisation string. The component
|
||||
* branches on `entry.carrier`; null carriers (private/loopback
|
||||
* IPs, offline-miss rows) keep the original single-chip layout.
|
||||
*
|
||||
* 2. The CSV-export button emits a file whose first row carries
|
||||
* the translated `carrier` column header. The header sits
|
||||
* between `location` and `provider` (see the v1.4.27 B3 column-
|
||||
* order comment in `_shared.tsx`).
|
||||
*
|
||||
* The seed user has the ADMIN role (`e2e/setup/global-setup.ts` →
|
||||
* `E2E_USER.role = "ADMIN"`), so the admin route is reachable
|
||||
* directly.
|
||||
*/
|
||||
test.describe("v1.4.27 — admin login-overview", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
const FIXED_ROW = {
|
||||
id: "audit_e2e_1",
|
||||
action: "auth.login.success",
|
||||
ipAddress: "85.214.0.1",
|
||||
location: "Berlin, DE",
|
||||
asn: 3320,
|
||||
// The carrier short-label collapses Deutsche Telekom → "Telekom"
|
||||
// (see `carrierShortLabel` in `_shared.tsx`). The chip renders
|
||||
// the SHORT label; the CSV emits the raw value.
|
||||
carrier: "Deutsche Telekom AG",
|
||||
details: null,
|
||||
createdAt: new Date("2026-05-15T10:00:00Z").toISOString(),
|
||||
user: { id: "u_e2e", username: "e2e-tester" },
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/admin/audit-log?*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
entries: [FIXED_ROW],
|
||||
meta: { total: 1, limit: 50, offset: 0, page: 1, perPage: 50 },
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/admin/audit-log/actions", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { actions: ["auth.login.success", "auth.login.failed"] },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("the carrier chip renders beneath the provider chip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/admin/login-overview", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The table mounts the carrier slot for rows where `entry.carrier`
|
||||
// is non-null. We mocked one row that carries the Deutsche-Telekom
|
||||
// organisation string.
|
||||
const chip = page.locator('[data-slot="login-overview-carrier"]').first();
|
||||
await expect(chip).toBeVisible({ timeout: 10_000 });
|
||||
// The chip carries the short DACH label for known carriers.
|
||||
await expect(chip).toHaveText(/Telekom/);
|
||||
});
|
||||
|
||||
test("the CSV export emits a file with the carrier column header", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/admin/login-overview", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Wait for the table to render so the export button is enabled.
|
||||
await expect(
|
||||
page.locator('[data-slot="login-overview-carrier"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The CSV is generated client-side via a Blob + anchor click —
|
||||
// intercept the download event Playwright surfaces for that path.
|
||||
const exportButton = page.getByRole("button", { name: /export|csv/i });
|
||||
|
||||
const downloadPromise = page.waitForEvent("download", { timeout: 10_000 });
|
||||
await exportButton.click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const csv = Buffer.concat(chunks).toString("utf-8");
|
||||
|
||||
const headerLine = csv.split(/\r?\n/, 1)[0] ?? "";
|
||||
// The CSV header is locale-aware (admin.carrier). Test users get
|
||||
// the English admin locale by default — but if the key falls
|
||||
// through, the raw `admin.carrier` is still emitted. Accept both.
|
||||
expect(headerLine.toLowerCase()).toMatch(/carrier|admin\.carrier/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.27 B1 — GLP-1 secondary tile.
|
||||
*
|
||||
* The tile mounts on the dashboard when `/api/dashboard/glp1` returns
|
||||
* a payload with at least one active GLP-1 medication. The tile
|
||||
* carries:
|
||||
*
|
||||
* - Drug-line caption ("Mounjaro 7.5mg") + weight-delta caption
|
||||
* - Schedule pills (last + next injection)
|
||||
* - Chart pane with a two-tab segmented control (Drug-Level
|
||||
* default / Weight) and a 7d / 30d / 90d / All range strip
|
||||
*
|
||||
* The tab + range state lives on the tile so a parent re-render
|
||||
* doesn't reset the user's pick. The spec exercises:
|
||||
*
|
||||
* 1. Tile mounts with the documented data-slot.
|
||||
* 2. The Drug-Level tab is active on first paint.
|
||||
* 3. Each range button toggles the `data-active` attribute to
|
||||
* reflect the current selection.
|
||||
* 4. Switching to the Weight tab swaps the chart pane.
|
||||
*/
|
||||
test.describe("v1.4.27 — GLP-1 secondary tile", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Empty dashboard payload so the page renders fast — only the
|
||||
// GLP-1 tile actually exercises behaviour in this spec.
|
||||
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.route("**/api/measurements*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { measurements: [], meta: { total: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The load-bearing fixture. Shape mirrors `Glp1Payload` /
|
||||
// `Glp1MedicationPayload` in `src/components/dashboard/glp1-tile.tsx`.
|
||||
await page.route("**/api/dashboard/glp1", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
genericName: "tirzepatide",
|
||||
medicationId: "med_glp1_e2e_1",
|
||||
currentDose: {
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-15",
|
||||
weeksOnDose: 4,
|
||||
},
|
||||
doseHistory: [],
|
||||
lastInjection: {
|
||||
date: "2026-05-12",
|
||||
site: "abdomen",
|
||||
weeksAgo: 0,
|
||||
},
|
||||
nextInjection: { date: "2026-05-19", daysAway: 4 },
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - i * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
weight: 88 - i * 0.05,
|
||||
})),
|
||||
injectionDates: [
|
||||
"2026-04-21",
|
||||
"2026-04-28",
|
||||
"2026-05-05",
|
||||
"2026-05-12",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("tile mounts with the tab strip + range strip on Pixel 5", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile",
|
||||
"GLP-1 tile is dashboard-only; Pixel 5 is the load-bearing viewport",
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const tile = page.locator('[data-slot="glp1-tile"]');
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Tab strip carries the two tabs.
|
||||
await expect(
|
||||
tile.locator('[data-slot="glp1-tile-tab-level"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tile.locator('[data-slot="glp1-tile-tab-weight"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Range strip carries four range buttons.
|
||||
const rangeButtons = tile.locator(
|
||||
'[data-slot="glp1-tile-range-button"]',
|
||||
);
|
||||
await expect(rangeButtons).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("clicking a range button updates the active state", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile",
|
||||
"Pixel 5 is the load-bearing viewport for this tile",
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const tile = page.locator('[data-slot="glp1-tile"]');
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Default range = 30 points. Pin on data-active so we don't
|
||||
// need to read CSS class soup.
|
||||
const default30 = tile.locator(
|
||||
'[data-slot="glp1-tile-range-button"][data-points="30"]',
|
||||
);
|
||||
await expect(default30).toHaveAttribute("data-active", "true");
|
||||
|
||||
// Click the 7d range. After click the 7d button must be active
|
||||
// and the 30d button must drop back to inactive.
|
||||
const range7 = tile.locator(
|
||||
'[data-slot="glp1-tile-range-button"][data-points="7"]',
|
||||
);
|
||||
await range7.click();
|
||||
await expect(range7).toHaveAttribute("data-active", "true");
|
||||
await expect(default30).toHaveAttribute("data-active", "false");
|
||||
|
||||
// Click the 90d range. Same toggle behaviour.
|
||||
const range90 = tile.locator(
|
||||
'[data-slot="glp1-tile-range-button"][data-points="90"]',
|
||||
);
|
||||
await range90.click();
|
||||
await expect(range90).toHaveAttribute("data-active", "true");
|
||||
await expect(range7).toHaveAttribute("data-active", "false");
|
||||
});
|
||||
|
||||
test("switching to the Weight tab swaps the chart pane", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile",
|
||||
"Pixel 5 is the load-bearing viewport for this tile",
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const tile = page.locator('[data-slot="glp1-tile"]');
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Click the Weight tab. The level-unavailable / weight-unavailable
|
||||
// fallback slots only render when the corresponding pane has no
|
||||
// data; with the seeded weight series the chart should mount and
|
||||
// the unavailable fallback should NOT be visible.
|
||||
await tile.locator('[data-slot="glp1-tile-tab-weight"]').click();
|
||||
|
||||
// The drug-level chart pane is no longer the active branch.
|
||||
// Either the weight chart paints OR the fallback "no data" slot
|
||||
// appears — both prove the tab switch reached the chart pane.
|
||||
const chartPane = tile.locator('[data-slot="glp1-tile-chart"]');
|
||||
await expect(chartPane).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user