mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
test(e2e): sweep every signed-in route for sideways scroll at 390px
The lab list panned sideways on a phone while the overflow guard was green, because /labs was not on its hand-kept list. The new sweep reads the routes from src/app: static routes are visited as is, every dynamic route needs a resolver to real paths, and routes that are not signed-in surfaces are listed with their reason. A route added without either fails the bookkeeping test by name. It runs on an account of its own, filled once from globalSetup with one or two of everything a route renders, in German, so the narrow-column and long-string cases are present. Two lab tests assert at the elements that broke: the marker name keeps a readable width, and the values list's actions stay on screen. The dialog spec gains a 360px width, where the labs sheet's three-button footer overflowed in English too.
This commit is contained in:
1 parent
537ac3c7a1
commit
567fd8d51c
4 files changed
+584
No files matched your search
@@ -97,6 +97,10 @@ const DIALOGS: DialogCase[] = [
|
||||
const VIEWPORTS = [
|
||||
{ label: "desktop 1280", width: 1280, height: 800 },
|
||||
{ label: "phone 390", width: 390, height: 844 },
|
||||
// The narrowest common Android width. The labs sheet's three-button footer
|
||||
// (Cancel, Save & add another, Save) fits 390 in English and pushed Cancel
|
||||
// off the left edge in German; 360 is where English hits the same wall.
|
||||
{ label: "phone 360", width: 360, height: 780 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import type { APIRequestContext } from "@playwright/test";
|
||||
|
||||
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
|
||||
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
settleForOverflowMeasurement,
|
||||
} from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* Every signed-in route, at an iPhone width, does not scroll sideways.
|
||||
*
|
||||
* `mobile-horizontal-overflow.spec.ts` sweeps five widths over four
|
||||
* content-heavy routes. This file is the breadth half: one width, every route.
|
||||
* It exists because the lab list panned sideways on a phone for weeks while
|
||||
* the guard was green — `/labs` was simply not on the list. A hand-kept list
|
||||
* goes stale the day a route is added, so the routes here are read from
|
||||
* `src/app` itself, and a route this file cannot open fails the run instead of
|
||||
* dropping out silently:
|
||||
*
|
||||
* - a static route is visited as is;
|
||||
* - a dynamic route needs an entry in `DYNAMIC_ROUTES` that turns its
|
||||
* segments into concrete paths (a settings slug, a seeded record's id);
|
||||
* - a route that cannot be swept signed in (auth, invitation, public
|
||||
* share-link pages) is listed in `NOT_SIGNED_IN` with its reason.
|
||||
*
|
||||
* The account is `E2E_MOBILE_ROUTES`, which `globalSetup` fills with one or
|
||||
* two of everything (`mobile-routes-fixture.ts`): an empty page fits any
|
||||
* viewport and would prove nothing. It renders in German, the locale with the
|
||||
* longest common strings, and at 390 px — the iPhone width the lab list broke
|
||||
* at.
|
||||
*/
|
||||
|
||||
const APP_DIR = join(process.cwd(), "src", "app");
|
||||
const WIDTH = 390;
|
||||
|
||||
/** Route patterns that are not signed-in surfaces, and why. */
|
||||
const NOT_SIGNED_IN: Record<string, string> = {
|
||||
"/auth/login": "the sign-in page; a session redirects away from it",
|
||||
"/auth/register": "registration; a session redirects away from it",
|
||||
"/invite/[token]":
|
||||
"an invitation landing page for somebody without an account",
|
||||
"/c/[token]": "the clinician share-link page, opened without a session",
|
||||
"/confirm-access": "the re-proof page, reached only from a stale session",
|
||||
"/enroll-mfa": "the forced second-factor enrolment of a policy-bound account",
|
||||
"/onboarding": "the first-run setup flow; covered by setup-flow-*.spec.ts",
|
||||
"/onboarding/[step]":
|
||||
"the first-run setup flow; covered by setup-flow-*.spec.ts",
|
||||
};
|
||||
|
||||
type Resolver = (api: APIRequestContext) => Promise<string[]>;
|
||||
|
||||
async function readData<T>(api: APIRequestContext, path: string): Promise<T> {
|
||||
const res = await api.get(path);
|
||||
expect(res.ok(), `${path} answered ${res.status()}`).toBe(true);
|
||||
return ((await res.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
const firstId = (ids: Array<string | null | undefined>, what: string) => {
|
||||
const id = ids.find((value): value is string => Boolean(value));
|
||||
if (!id)
|
||||
throw new Error(`the fixture holds no ${what} — globalSetup seeds one`);
|
||||
return id;
|
||||
};
|
||||
|
||||
/** Every dynamic route pattern under `src/app`, turned into real paths. */
|
||||
const DYNAMIC_ROUTES: Record<string, Resolver> = {
|
||||
"/settings/[section]": async () =>
|
||||
SETTINGS_SECTION_SLUGS.map((slug) => `/settings/${slug}`),
|
||||
"/admin/[section]": async () =>
|
||||
ADMIN_SECTION_SLUGS.map((slug) => `/admin/${slug}`),
|
||||
// `LAYOUT_GROUP_IDS` lives beside React components the test runner cannot
|
||||
// load; `settings/layout/[module]` 404s an unknown group, so a stale entry
|
||||
// here fails on the status check below rather than passing quietly.
|
||||
"/settings/layout/[module]": async () =>
|
||||
[
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
"vorsorge",
|
||||
].map((group) => `/settings/layout/${group}`),
|
||||
"/insights/scores/[metric]": async () =>
|
||||
["sleep", "readiness", "recovery", "stress", "strain"].map(
|
||||
(metric) => `/insights/scores/${metric}`,
|
||||
),
|
||||
"/insights/values/[type]": async () => [
|
||||
"/insights/values/WEIGHT",
|
||||
"/insights/values/BLOOD_PRESSURE_SYS",
|
||||
],
|
||||
"/labs/[biomarkerId]": async (api) => {
|
||||
const data = await readData<{
|
||||
results: Array<{ biomarkerId: string | null; analyte: string }>;
|
||||
}>(api, "/api/labs?limit=50");
|
||||
const id = firstId(
|
||||
data.results
|
||||
.filter((row) => row.analyte === MOBILE_ROUTES_ANALYTE)
|
||||
.map((row) => row.biomarkerId),
|
||||
"linked lab reading",
|
||||
);
|
||||
return [`/labs/${id}`];
|
||||
},
|
||||
"/labs/[biomarkerId]/values": async (api) =>
|
||||
(await DYNAMIC_ROUTES["/labs/[biomarkerId]"](api)).map(
|
||||
(path) => `${path}/values`,
|
||||
),
|
||||
"/medications/[id]": async (api) => {
|
||||
const meds = await readData<Array<{ id: string }>>(api, "/api/medications");
|
||||
return [
|
||||
`/medications/${firstId(
|
||||
meds.map((m) => m.id),
|
||||
"medication",
|
||||
)}`,
|
||||
];
|
||||
},
|
||||
"/medications/[id]/history": async (api) =>
|
||||
(await DYNAMIC_ROUTES["/medications/[id]"](api)).map(
|
||||
(path) => `${path}/history`,
|
||||
),
|
||||
"/illness/[id]": async (api) => {
|
||||
const episodes = await readData<Array<{ id: string }>>(
|
||||
api,
|
||||
"/api/illness/episodes",
|
||||
);
|
||||
return [
|
||||
`/illness/${firstId(
|
||||
episodes.map((e) => e.id),
|
||||
"illness episode",
|
||||
)}`,
|
||||
];
|
||||
},
|
||||
"/custom-metrics/[id]": async (api) => {
|
||||
const data = await readData<{ customMetrics: Array<{ id: string }> }>(
|
||||
api,
|
||||
"/api/custom-metrics",
|
||||
);
|
||||
const id = firstId(
|
||||
data.customMetrics.map((m) => m.id),
|
||||
"custom metric",
|
||||
);
|
||||
return [`/custom-metrics/${id}`];
|
||||
},
|
||||
"/custom-metrics/[id]/values": async (api) =>
|
||||
(await DYNAMIC_ROUTES["/custom-metrics/[id]"](api)).map(
|
||||
(path) => `${path}/values`,
|
||||
),
|
||||
"/insights/workouts/[id]": async (api) => {
|
||||
const data = await readData<{ workouts: Array<{ id: string }> }>(
|
||||
api,
|
||||
"/api/workouts?limit=1",
|
||||
);
|
||||
return [
|
||||
`/insights/workouts/${firstId(
|
||||
data.workouts.map((w) => w.id),
|
||||
"workout",
|
||||
)}`,
|
||||
];
|
||||
},
|
||||
// An ECG strip only arrives through a device sync; no route writes one. The
|
||||
// detail page is swept in its not-found state, which is still the frame,
|
||||
// header and back link a phone renders.
|
||||
"/insights/ecg/[id]": async () => ["/insights/ecg/unknown-recording"],
|
||||
};
|
||||
|
||||
/** Every `page.tsx` under `src/app`, as a route pattern (`/labs/[biomarkerId]`). */
|
||||
function discoverRoutePatterns(dir = APP_DIR): string[] {
|
||||
const patterns: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "api" || entry.startsWith("_") || entry === "__tests__")
|
||||
continue;
|
||||
patterns.push(...discoverRoutePatterns(full));
|
||||
} else if (entry === "page.tsx") {
|
||||
const rel = relative(APP_DIR, dir).split(sep).join("/");
|
||||
// Route groups `(name)` do not appear in the URL.
|
||||
const path = rel
|
||||
.split("/")
|
||||
.filter((segment) => segment && !/^\(.*\)$/.test(segment))
|
||||
.join("/");
|
||||
patterns.push(`/${path}`);
|
||||
}
|
||||
}
|
||||
return patterns.sort();
|
||||
}
|
||||
|
||||
const PATTERNS = discoverRoutePatterns();
|
||||
const isDynamic = (pattern: string) => pattern.includes("[");
|
||||
|
||||
test.describe("every signed-in route fits a phone", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ context }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "de", url: baseURL },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The bookkeeping the sweep depends on. A new dynamic route without a
|
||||
* resolver, a resolver for a route that no longer exists, or an exclusion
|
||||
* for a route that is gone each fail here by name.
|
||||
*/
|
||||
test("the swept routes are the routes the app has", () => {
|
||||
expect(PATTERNS.length, "no page.tsx found under src/app").toBeGreaterThan(
|
||||
50,
|
||||
);
|
||||
const unresolved = PATTERNS.filter(
|
||||
(p) => isDynamic(p) && !NOT_SIGNED_IN[p] && !DYNAMIC_ROUTES[p],
|
||||
);
|
||||
expect(
|
||||
unresolved,
|
||||
"dynamic route(s) with no entry in DYNAMIC_ROUTES or NOT_SIGNED_IN",
|
||||
).toEqual([]);
|
||||
const stale = [
|
||||
...Object.keys(DYNAMIC_ROUTES),
|
||||
...Object.keys(NOT_SIGNED_IN),
|
||||
].filter((p) => !PATTERNS.includes(p));
|
||||
expect(stale, "entries for routes that no longer exist").toEqual([]);
|
||||
});
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
if (NOT_SIGNED_IN[pattern]) continue;
|
||||
|
||||
test(`${pattern} does not scroll sideways at ${WIDTH}px`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.slow();
|
||||
const paths = isDynamic(pattern)
|
||||
? await DYNAMIC_ROUTES[pattern](page.request)
|
||||
: [pattern];
|
||||
expect(paths.length, `${pattern} resolved to no path`).toBeGreaterThan(0);
|
||||
|
||||
await page.setViewportSize({ width: WIDTH, height: 844 });
|
||||
for (const path of paths) {
|
||||
const response = await page.goto(path, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
expect(
|
||||
response?.status() ?? 0,
|
||||
`${path} answered ${response?.status()}`,
|
||||
).toBeLessThan(400);
|
||||
// A redirect away (a module switched off, a missing record) would
|
||||
// measure some other page and call it this one.
|
||||
expect(new URL(page.url()).pathname, `${path} redirected`).toBe(
|
||||
new URL(path, page.url()).pathname,
|
||||
);
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, `${path} @${WIDTH}px`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The lab surfaces, asserted at the elements that broke rather than only at
|
||||
* the page width. The list's name column collapsed to nothing on a phone
|
||||
* while the fixed columns beside it pushed the row off the card; the values
|
||||
* table put its edit and delete buttons in a sideways-scrolling strip.
|
||||
*/
|
||||
test.describe("the lab surfaces stay readable on a phone", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
await page.setViewportSize({ width: WIDTH, height: 844 });
|
||||
});
|
||||
|
||||
test("the lab list shows each marker's name", async ({ page }) => {
|
||||
await page.goto("/labs", { waitUntil: "domcontentloaded" });
|
||||
const names = page.locator('[data-slot="lab-list-analyte"]');
|
||||
await expect(names.first()).toBeVisible();
|
||||
const widths = await names.evaluateAll((nodes) =>
|
||||
nodes.map((node) => Math.round(node.getBoundingClientRect().width)),
|
||||
);
|
||||
expect(widths.length).toBeGreaterThan(1);
|
||||
// A readable name, not the 0 px column the fixed columns left it.
|
||||
for (const width of widths) expect(width).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
test("the values list keeps its actions on screen", async ({ page }) => {
|
||||
const [path] = await DYNAMIC_ROUTES["/labs/[biomarkerId]/values"](
|
||||
page.request,
|
||||
);
|
||||
await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
const list = page.locator('[data-slot="lab-history-mobile-list"]');
|
||||
await expect(list).toBeVisible();
|
||||
await expect(page.locator('[data-slot="lab-history-table"]')).toBeHidden();
|
||||
|
||||
const buttons = list.locator("button");
|
||||
expect(await buttons.count()).toBeGreaterThan(1);
|
||||
const outside = await buttons.evaluateAll(
|
||||
(nodes) =>
|
||||
nodes
|
||||
.map((node) => node.getBoundingClientRect())
|
||||
.filter((r) => r.left < 0 || r.right > window.innerWidth + 1).length,
|
||||
);
|
||||
expect(outside, "a reading's action sits off-screen").toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
} from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
|
||||
|
||||
// Resolve relative to the project root rather than __dirname/import.meta.url —
|
||||
// Playwright's TS loader runs files as CJS without `"type":"module"`, and
|
||||
// loading either dirname helper at module scope crashes the test runner.
|
||||
@@ -191,6 +193,27 @@ export const MODULES_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateModules.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The phone-width route sweep's own account (`mobile-route-overflow.spec.ts`).
|
||||
*
|
||||
* It holds one or two of everything a route can render
|
||||
* (`mobile-routes-fixture.ts`), because the sweep is only worth something
|
||||
* against populated pages, and writing that much into the shared account would
|
||||
* change what every other spec reads. An administrator, so the admin console
|
||||
* is swept too; `FEMALE`, so the cycle surfaces render without a switch.
|
||||
*/
|
||||
export const E2E_MOBILE_ROUTES = {
|
||||
email: "e2e-mobile-routes@healthlog.test",
|
||||
username: "e2e-mobile-routes",
|
||||
password: "Wd7!Kp3mTz9qRv2N",
|
||||
role: "ADMIN",
|
||||
} as const;
|
||||
|
||||
export const MOBILE_ROUTES_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateMobileRoutes.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The AI-optional journeys' own account (`ai-optional-*.spec.ts`).
|
||||
*
|
||||
@@ -823,6 +846,43 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The phone-width route sweep's account. Every opt-in module is on, so
|
||||
// the sweep reaches the glucose, documents, wellbeing, nutrients and
|
||||
// environment surfaces; its record is written after the login capture.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
height_cm, date_of_birth, gender,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true,
|
||||
$8::jsonb)
|
||||
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,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_MOBILE_ROUTES.username,
|
||||
E2E_MOBILE_ROUTES.email,
|
||||
await hashPassword(E2E_MOBILE_ROUTES.password),
|
||||
E2E_MOBILE_ROUTES.role,
|
||||
now,
|
||||
new Date("1988-04-12T00:00:00.000Z"),
|
||||
JSON.stringify({
|
||||
glucose: true,
|
||||
environment: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
// The AI-optional journeys' account. Its provider, stored briefing,
|
||||
// consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts`
|
||||
// before each journey.
|
||||
@@ -1441,6 +1501,11 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// The module-surfaces journey's jar — see `E2E_MODULES`.
|
||||
await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH);
|
||||
|
||||
// The phone-width route sweep's jar, then its record — see
|
||||
// `E2E_MOBILE_ROUTES`.
|
||||
await capture(E2E_MOBILE_ROUTES, MOBILE_ROUTES_STORAGE_STATE_PATH);
|
||||
await seedMobileRoutesRecord(baseURL, MOBILE_ROUTES_STORAGE_STATE_PATH);
|
||||
|
||||
// The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`.
|
||||
await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH);
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* The record `mobile-route-overflow.spec.ts` measures.
|
||||
*
|
||||
* A phone-width overflow guard that visits an empty page proves nothing: an
|
||||
* empty list fits any viewport, and the lab list that panned sideways on a
|
||||
* phone only did so once it held a reading. So the account gets one or two of
|
||||
* everything a signed-in route can render — readings with reference ranges, a
|
||||
* medication, a mood entry, a visit, a vaccination, an illness episode, a
|
||||
* custom metric, a workout, a document — and every dynamic route has a real id
|
||||
* to open.
|
||||
*
|
||||
* Written through the app's own routes, once, from `globalSetup`, so the
|
||||
* parallel workers of the spec never race each other to seed it. An account
|
||||
* that already holds a lab reading is taken as seeded, which keeps a local
|
||||
* re-run against a reused database from doubling every row.
|
||||
*/
|
||||
import { request as playwrightRequest } from "@playwright/test";
|
||||
|
||||
/** Long on purpose: the names are what a narrow column has to give way to. */
|
||||
export const MOBILE_ROUTES_ANALYTE = "Glomeruläre Filtrationsrate (eGFR)";
|
||||
|
||||
export async function seedMobileRoutesRecord(
|
||||
baseURL: string,
|
||||
storageStatePath: string,
|
||||
): Promise<void> {
|
||||
const ctx = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
storageState: storageStatePath,
|
||||
});
|
||||
try {
|
||||
// The document is the last thing written, so its presence means a
|
||||
// complete record. A run that died half-way is finished by the next one:
|
||||
// a row it already wrote answers 409 and is skipped.
|
||||
const existing = await ctx.get("/api/documents/inbound");
|
||||
const existingBody = (await existing.json()) as {
|
||||
data: { documents?: unknown[] } | unknown[] | null;
|
||||
};
|
||||
const documents = Array.isArray(existingBody.data)
|
||||
? existingBody.data
|
||||
: (existingBody.data?.documents ?? []);
|
||||
if (documents.length > 0) return;
|
||||
|
||||
const post = async (path: string, data: unknown): Promise<unknown> => {
|
||||
const res = await ctx.post(path, { data });
|
||||
if (res.status() === 409) return null;
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`[mobile-routes-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: unknown }).data;
|
||||
};
|
||||
const daysAgo = (days: number, hour = 8) => {
|
||||
const at = new Date(Date.now() - days * 86_400_000);
|
||||
at.setUTCHours(hour, 0, 0, 0);
|
||||
return at.toISOString();
|
||||
};
|
||||
|
||||
for (let day = 1; day <= 6; day += 1) {
|
||||
await post("/api/measurements", {
|
||||
type: "WEIGHT",
|
||||
value: 80 + day / 10,
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 120 + day,
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 78 + (day % 3),
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "PULSE",
|
||||
value: 60 + day,
|
||||
measuredAt: daysAgo(day, 7),
|
||||
});
|
||||
await post("/api/measurements", {
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 92 + day,
|
||||
glucoseContext: "FASTING",
|
||||
measuredAt: daysAgo(day, 6),
|
||||
});
|
||||
await post("/api/mood-entries", {
|
||||
mood: day % 2 ? "GUT" : "OKAY",
|
||||
moodLoggedAt: daysAgo(day, 20),
|
||||
});
|
||||
}
|
||||
|
||||
// Two readings of one marker, so the list row has a trend line and a
|
||||
// range bar, plus a qualitative one with neither.
|
||||
for (const [days, value] of [
|
||||
[40, 78],
|
||||
[5, 84],
|
||||
] as const) {
|
||||
await post("/api/labs", {
|
||||
analyte: MOBILE_ROUTES_ANALYTE,
|
||||
panel: "Nierenwerte und Elektrolyte",
|
||||
value,
|
||||
unit: "mL/min/1.73m²",
|
||||
referenceLow: 60,
|
||||
referenceHigh: 120,
|
||||
takenAt: daysAgo(days),
|
||||
});
|
||||
}
|
||||
await post("/api/labs", {
|
||||
analyte: "Hepatitis-B-Oberflächenantigen",
|
||||
valueText: "negativ",
|
||||
takenAt: daysAgo(5),
|
||||
});
|
||||
|
||||
await post("/api/medications", {
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
});
|
||||
|
||||
const practitioner = (await post("/api/practitioners", {
|
||||
name: "Gemeinschaftspraxis für Allgemeinmedizin",
|
||||
specialty: "Allgemeinmedizin und Innere Medizin",
|
||||
})) as { id: string } | null;
|
||||
await post("/api/encounters", {
|
||||
occurredAt: daysAgo(20),
|
||||
practitionerId: practitioner?.id ?? null,
|
||||
reason: "Routinekontrolle Blutdruck und Blutbild",
|
||||
});
|
||||
await post("/api/vaccinations", {
|
||||
occurredAt: daysAgo(300),
|
||||
antigenSlug: "tetanus",
|
||||
doseNumber: 1,
|
||||
lotNumber: "AB1234-XY",
|
||||
practitionerId: practitioner?.id ?? null,
|
||||
});
|
||||
|
||||
await post("/api/illness/episodes", {
|
||||
label: "Erkältung mit Husten",
|
||||
type: "INFECTION",
|
||||
onsetAt: daysAgo(12),
|
||||
resolvedAt: daysAgo(6),
|
||||
});
|
||||
|
||||
const metric = (await post("/api/custom-metrics", {
|
||||
name: "Augeninnendruck rechts",
|
||||
unit: "mmHg",
|
||||
targetLow: 10,
|
||||
targetHigh: 21,
|
||||
decimals: 0,
|
||||
})) as { id: string } | null;
|
||||
const metricId =
|
||||
metric?.id ??
|
||||
(
|
||||
(await (await ctx.get("/api/custom-metrics")).json()) as {
|
||||
data: { customMetrics: Array<{ id: string }> };
|
||||
}
|
||||
).data.customMetrics[0]?.id;
|
||||
await post(`/api/custom-metrics/${metricId}/entries`, {
|
||||
value: 16,
|
||||
measuredAt: daysAgo(3),
|
||||
});
|
||||
|
||||
await post("/api/workouts/batch", {
|
||||
workouts: [
|
||||
{
|
||||
sportType: "running",
|
||||
startedAt: daysAgo(2, 6),
|
||||
endedAt: new Date(
|
||||
new Date(daysAgo(2, 6)).getTime() + 45 * 60_000,
|
||||
).toISOString(),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "e2e-mobile-routes-run",
|
||||
activeEnergyKcal: 420,
|
||||
distanceM: 6500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const pdf =
|
||||
"%PDF-1.4\n%e2e-mobile-routes\n" +
|
||||
"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n" +
|
||||
"xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\n%%EOF\n";
|
||||
const doc = await ctx.post("/api/documents/inbound", {
|
||||
multipart: {
|
||||
file: {
|
||||
name: "laborbefund-hausarzt-mit-langem-dateinamen.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(pdf),
|
||||
},
|
||||
title: "Laborbefund Hausarzt mit einem sehr langen Titel",
|
||||
kind: "LAB_RESULT",
|
||||
},
|
||||
});
|
||||
if (!doc.ok()) {
|
||||
throw new Error(
|
||||
`[mobile-routes-fixture] /api/documents/inbound answered ${doc.status()}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user