mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge main — the test-infrastructure fixes from the shared suite
Brings in the truncate retry, the socket fix for the record-fence spec that failed this branch's second e2e shard, and the corrected absence assertions.
This commit is contained in:
commit
7436a0fcba
18 files changed
+3439
-3081
No files matched your search
+3128
-3012
File diff suppressed because it is too large.
Load diff
+21
-21
@@ -9329,14 +9329,14 @@ paths:
|
||||
tags:
|
||||
- Insights
|
||||
summary: List the caller's Coach goal / if-then plans
|
||||
description: v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each
|
||||
decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional
|
||||
target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass
|
||||
`?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active +
|
||||
review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted
|
||||
returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled
|
||||
surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body.
|
||||
Undecryptable rows are omitted rather than failing the read.
|
||||
description: v1.21.3 — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on
|
||||
the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach
|
||||
extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to
|
||||
filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past =
|
||||
met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the
|
||||
non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s.
|
||||
Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are
|
||||
omitted rather than failing the read.
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
@@ -9384,11 +9384,11 @@ paths:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Confirm or update a Coach plan's lifecycle
|
||||
description: v1.21.3 (B1) — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and
|
||||
optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted
|
||||
free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown
|
||||
/ already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts.
|
||||
Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.
|
||||
description: v1.21.3 — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set
|
||||
/ clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so
|
||||
a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown /
|
||||
already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated.
|
||||
Auth via cookie or Bearer; the owner is narrowed from the session.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -9428,9 +9428,9 @@ paths:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Soft-delete one Coach plan
|
||||
description: "v1.21.3 (B1) — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted
|
||||
id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another
|
||||
account. Coach-gated. Auth via cookie or Bearer."
|
||||
description: "v1.21.3 — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is
|
||||
an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account.
|
||||
Coach-gated. Auth via cookie or Bearer."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -9543,7 +9543,7 @@ paths:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Create a Coach reminder manually
|
||||
description: v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed
|
||||
description: v1.22 — create a reminder from the ledger. `note` is the only writable content; `when` is the closed
|
||||
grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The
|
||||
per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is
|
||||
narrowed from the session.
|
||||
@@ -9580,8 +9580,8 @@ paths:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Confirm or update a Coach reminder's lifecycle
|
||||
description: v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the
|
||||
closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown /
|
||||
description: v1.22 — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed
|
||||
`when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown /
|
||||
already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie
|
||||
or Bearer.
|
||||
parameters:
|
||||
@@ -9623,8 +9623,8 @@ paths:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Soft-delete one Coach reminder
|
||||
description: "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted
|
||||
id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer."
|
||||
description: "v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id
|
||||
is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
|
||||
@@ -491,12 +491,16 @@ test.describe("account sharing", () => {
|
||||
// than on a wall of refusals: the revoke cleared their acting session in
|
||||
// the same transaction that stamped the row.
|
||||
await page.goto("/measurements");
|
||||
// Their own record, back. This goes first: the protected shell holds
|
||||
// every child — nav, banner, page body — behind its hydration gate until
|
||||
// `/api/auth/me` resolves, so "no banner" straight after a navigation is
|
||||
// satisfied by the page still loading and would stay green if the revoke
|
||||
// had left the acting session standing. The delegate's own marker only
|
||||
// exists once the record really resolved to theirs.
|
||||
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toHaveCount(0);
|
||||
// Their own record, back — which is also the proof that the page did not
|
||||
// simply fail to load.
|
||||
await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("the ended grant stays on the record as history", async () => {
|
||||
|
||||
@@ -193,6 +193,21 @@ test.describe("delegated writes", () => {
|
||||
// And the row it created is not theirs to change. Absent, not disabled:
|
||||
// a `toBeDisabled()` assertion here would pass against the exact design
|
||||
// this release exists to avoid.
|
||||
//
|
||||
// The list has to have painted the reading first. Both layouts stamp
|
||||
// `measurement-row` and only the mounted one exists, so this holds at
|
||||
// either viewport and says the rows really arrived — without it every
|
||||
// count below is a statement about an empty or still-loading list.
|
||||
const rows = page.getByTestId("measurement-row");
|
||||
await expect(rows.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The selection column is the gate. `canManage` is false below MANAGE, so
|
||||
// no row carries a checkbox — which is what makes the bulk bar
|
||||
// unreachable rather than merely unrendered. The bar on its own proved
|
||||
// nothing here: it renders on `count > 0`, and with nothing selected an
|
||||
// owner meets no bar either, so that assertion could not fail for the
|
||||
// reason this comment gives.
|
||||
await expect(rows.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="selection-action-bar"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
@@ -112,10 +112,26 @@ async function enterRecord(page: Page, username: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the record and prove the own-record shell is back.
|
||||
*
|
||||
* Leaving replaces the document, and the protected shell renders its
|
||||
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
|
||||
* resolves. "The banner is gone" is true of that gate as well, so on its own
|
||||
* it is satisfied by the document on its way out and is not a wait at all.
|
||||
* The top bar only exists past the gate, so it is the anchor the absence
|
||||
* hangs on.
|
||||
*/
|
||||
async function leaveRecord(page: Page): Promise<void> {
|
||||
await withDocumentReplacement(page, () =>
|
||||
page.locator('[data-slot="shared-record-banner-exit"]').click(),
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
{ timeout: 30_000 },
|
||||
|
||||
@@ -373,6 +373,15 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// The sheet shell paints skeletons while the detail query is pending, so
|
||||
// its own visibility says nothing about the body having rendered. The
|
||||
// footer download action belongs to the resolved document, so waiting on
|
||||
// it is what keeps the two absences below from passing against a
|
||||
// skeleton that never had the line to begin with.
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-download"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-detail-ai-read"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
@@ -311,10 +311,15 @@ test.describe("document vault", () => {
|
||||
await expect(detail).not.toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
// A reload replaces the document, and the protected shell holds every
|
||||
// child until `/api/auth/me` resolves, so "the sheet is not visible"
|
||||
// right here would be a statement about the hold, not about the handoff
|
||||
// having been consumed. The list row is the first thing that exists only
|
||||
// once the page really rendered, so it goes first.
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await expect(detail).not.toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
|
||||
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await openButton(page, "MRT Knie").click();
|
||||
await expect(detail).toBeVisible();
|
||||
await detail.locator('[data-slot="document-chat-open"]').click();
|
||||
|
||||
@@ -114,25 +114,23 @@ test.describe("add measurement flow", () => {
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
// Past the route-level skeleton first. `networkidle` says nothing about
|
||||
// whether the suspended segment resolved, and every locator below lives
|
||||
// inside the content that replaces it.
|
||||
await expect(page.locator('[data-slot="dashboard-loading"]')).toHaveCount(
|
||||
0,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
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 openMenu(
|
||||
page,
|
||||
main
|
||||
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
|
||||
.first(),
|
||||
);
|
||||
const addTrigger = main
|
||||
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
|
||||
.first();
|
||||
// Wait for the trigger itself rather than for the route-level skeleton to
|
||||
// go. The skeleton is also absent while the protected shell holds the
|
||||
// whole page behind its hydration gate, so its disappearance is not a
|
||||
// statement about the suspended segment having resolved; the control the
|
||||
// next line clicks is. `networkidle` says nothing about either.
|
||||
await expect(addTrigger).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await openMenu(page, addTrigger);
|
||||
|
||||
// v1.5 phase-5: the menu items now have distinct labels — the
|
||||
// measurement entry says "Measurement" / "Messung" instead of "Add",
|
||||
|
||||
@@ -51,6 +51,14 @@ test.describe("medication wizard — one-shot", () => {
|
||||
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
|
||||
);
|
||||
|
||||
// The replay this guards against can only happen once the client shell is
|
||||
// live and the URL effect has run, and until `/api/auth/me` resolves the
|
||||
// protected shell paints nothing at all — so the absence has to hang on
|
||||
// the hydrated page. The hero title is the medications page's own
|
||||
// heading, and it renders only in the settled branch.
|
||||
await expect(page.locator('[data-tour-id="medications-hero"]')).toBeVisible(
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="medication-wizard-dialog"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
@@ -204,9 +204,10 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
|
||||
// mean something: a note that was never rendered is also absent at the
|
||||
// end, and a day that was already final also reads final at the end.
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
// No wait on the skeleton being gone: it is also gone while the protected
|
||||
// shell holds the whole page behind its hydration gate, so it resolves
|
||||
// before the digest has rendered anything. The hero count below is the
|
||||
// wait, and it is a wait on the content itself.
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
// Counts rather than visibility, matching the AFTER block: the two are
|
||||
// then the same assertion with the expected number flipped, and neither
|
||||
|
||||
@@ -433,8 +433,16 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
|
||||
return res.status;
|
||||
});
|
||||
expect(left).toBe(200);
|
||||
const me = await context.request.get("/api/auth/me");
|
||||
const now = (await me.json()).data.recordSession as {
|
||||
// Read back through the page for the same reason the switch above goes
|
||||
// that way. This was the last call in the file still using the shared
|
||||
// API context, and it duly failed with `read ECONNRESET` on all three
|
||||
// attempts once the pooled socket had gone stale. The browser opens its
|
||||
// own connection, and a GET for the caller's own session is outside the
|
||||
// app either way.
|
||||
const now = (await page.evaluate(async () => {
|
||||
const res = await fetch("/api/auth/me");
|
||||
return (await res.json()).data.recordSession;
|
||||
})) as {
|
||||
epoch: number;
|
||||
scope: string | null;
|
||||
};
|
||||
|
||||
@@ -200,10 +200,52 @@ async function openProfileRecord(page: Page) {
|
||||
await expect(page).toHaveURL(/\/profile$/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an act that swaps the document out, and wait until it has.
|
||||
*
|
||||
* Exiting a record is a full navigation. A token stamped on the window before
|
||||
* the click is gone the moment the new document exists, which is the only
|
||||
* signal that the old one is not still the page under test.
|
||||
*/
|
||||
async function withDocumentReplacement(
|
||||
page: Page,
|
||||
act: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(window as Window & { __hlNavToken?: true }).__hlNavToken = true;
|
||||
});
|
||||
await act();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window as Window & { __hlNavToken?: true }).__hlNavToken === undefined,
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the record and prove the own-record shell is back.
|
||||
*
|
||||
* Leaving replaces the document, and the protected shell renders its
|
||||
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
|
||||
* resolves. "The banner is gone" is true of that gate as well, so on its own
|
||||
* it is satisfied by the document on its way out and is not a wait at all.
|
||||
* The top bar only exists past the gate, so it is the anchor the absence
|
||||
* hangs on.
|
||||
*/
|
||||
async function leaveRecord(page: Page) {
|
||||
await page.locator('[data-slot="shared-record-banner-exit"]').click();
|
||||
await withDocumentReplacement(page, () =>
|
||||
page.locator('[data-slot="shared-record-banner-exit"]').click(),
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -145,13 +145,26 @@ async function openRecord(page: Page, username: string) {
|
||||
return { accountId, accessLevel, recordKind };
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the record and prove the own-record shell is back.
|
||||
*
|
||||
* Leaving replaces the document, and the protected shell renders its
|
||||
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
|
||||
* resolves. "The banner is gone" is true of that gate as well, so on its own
|
||||
* it is satisfied by the document on its way out and is not a wait at all.
|
||||
* The top bar only exists past the gate, so it is the anchor the absence
|
||||
* hangs on.
|
||||
*/
|
||||
async function leaveRecord(page: Page) {
|
||||
// Leaving is the same act pointed the other way, and it has the same gap.
|
||||
// The banner being gone is satisfied by the document that is on its way out,
|
||||
// so on its own it is not a wait at all.
|
||||
await withDocumentReplacement(page, () =>
|
||||
page.locator('[data-slot="shared-record-banner-exit"]').click(),
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
{ timeout: 30_000 },
|
||||
@@ -432,6 +445,19 @@ test.describe.serial("scoped sharing browser journeys", () => {
|
||||
await expect(banner).toContainText(record.username);
|
||||
|
||||
await page.goto("/measurements");
|
||||
// Two gates stand between the navigation and the page: the shell's own
|
||||
// hold on `/api/auth/me`, and the route's own `PageAuthGate`. Both paint
|
||||
// a page with no header action on it, so the absence has to hang on the
|
||||
// header the button would have sat in. The hero title renders in the same
|
||||
// `PageHeader` whose `actions` slot the button fills, and only in the
|
||||
// settled branch, so it says the header really rendered without the
|
||||
// button rather than not having rendered yet.
|
||||
await expect(banner).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.locator('[data-tour-id="measurements-hero"]'),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0);
|
||||
|
||||
await page.goto("/settings/account");
|
||||
@@ -569,8 +595,13 @@ test.describe.serial("scoped sharing browser journeys", () => {
|
||||
data?: { recordId?: string };
|
||||
};
|
||||
expect(integrationPayload.data?.recordId).toBe(accountId);
|
||||
// The loading branch of this card emits the same family attribute with
|
||||
// `aria-busy="true"` on it, and a skeleton holds zero controls — so the
|
||||
// read-only claim below passes against a card that never arrived. The
|
||||
// settled card is the one that labels itself by its own title id, so the
|
||||
// locator names that instead of the family alone.
|
||||
const integrations = page.locator(
|
||||
'[data-record-settings-family="integrations"]',
|
||||
'[data-record-settings-family="integrations"][aria-labelledby="managed-integration-status-title"]',
|
||||
);
|
||||
await expect(integrations).toBeVisible();
|
||||
await expect(integrations).toHaveAttribute("data-record-id", accountId);
|
||||
|
||||
@@ -68,10 +68,26 @@ async function openRecord(page: Page, username: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the record and prove the own-record shell is back.
|
||||
*
|
||||
* Leaving replaces the document, and the protected shell renders its
|
||||
* hydration gate — no nav, no banner, no page body — until `/api/auth/me`
|
||||
* resolves. "The banner is gone" is true of that gate as well, so on its own
|
||||
* it is satisfied by the document on its way out and is not a wait at all.
|
||||
* The top bar only exists past the gate, so it is the anchor the absence
|
||||
* hangs on.
|
||||
*/
|
||||
async function leaveRecord(page: Page) {
|
||||
await withDocumentReplacement(page, () =>
|
||||
page.locator('[data-slot="shared-record-banner-exit"]').click(),
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="record-scope-hydration-gate"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
{ timeout: 30_000 },
|
||||
|
||||
@@ -136,19 +136,22 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// CCH-04 — the per-metric inline launch icon is retired; no header
|
||||
// launcher renders on the sub-page.
|
||||
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// The FAB is the permanent launcher; with no unread nudge it
|
||||
// The FAB is the permanent launcher, and it is also the proof that the
|
||||
// shell got past its hydration gate: nothing — header, FAB, page body —
|
||||
// exists until `/api/auth/me` resolves, so an absence asserted above
|
||||
// this line is a statement about the gate. With no unread nudge the FAB
|
||||
// renders without the unread dot.
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// The per-metric inline launch icon is retired; no header launcher
|
||||
// renders on the sub-page.
|
||||
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
|
||||
@@ -161,15 +164,18 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Same order as the mobile branch, for the same reason: the FAB is the
|
||||
// marker that the shell finished hydrating, so the retired header
|
||||
// launcher is claimed absent only after something has rendered.
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The integration suite wipes the database between files with one
|
||||
* `TRUNCATE`, which needs the strictest lock on every table at once. The
|
||||
* server writes in thirty-nine places without awaiting the result, so one of
|
||||
* those can land after the test that caused it has returned and take the
|
||||
* truncate down with a deadlock. Postgres picks a victim; ours was an
|
||||
* unrelated file, twice in two days, green on the rerun both times.
|
||||
*
|
||||
* The helper retries that one error now. These cases hold it to exactly that:
|
||||
* it gives up on anything else immediately, and it does not retry forever.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { truncateAllTables } from "../../tests/integration/setup";
|
||||
|
||||
function deadlock(): Error & { code: string } {
|
||||
const error = new Error("deadlock detected") as Error & { code: string };
|
||||
error.code = "40P01";
|
||||
return error;
|
||||
}
|
||||
|
||||
function fakeClient(executeRaw: ReturnType<typeof vi.fn>) {
|
||||
const empty = vi.fn().mockResolvedValue([]);
|
||||
return {
|
||||
$executeRawUnsafe: executeRaw,
|
||||
moodTag: { findMany: empty, createMany: vi.fn() },
|
||||
moodTagCategory: { findMany: empty, createMany: vi.fn() },
|
||||
} as unknown as Parameters<typeof truncateAllTables>[0];
|
||||
}
|
||||
|
||||
describe("truncateAllTables", () => {
|
||||
it("retries a deadlocked truncate and succeeds once the lock clears", async () => {
|
||||
const executeRaw = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(deadlock())
|
||||
.mockRejectedValueOnce(deadlock())
|
||||
.mockResolvedValue(0);
|
||||
|
||||
await truncateAllTables(fakeClient(executeRaw));
|
||||
|
||||
expect(executeRaw).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("gives up on an error that is not a deadlock, on the first attempt", async () => {
|
||||
const other = new Error("permission denied") as Error & { code: string };
|
||||
other.code = "42501";
|
||||
const executeRaw = vi.fn().mockRejectedValue(other);
|
||||
|
||||
await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow(
|
||||
"permission denied",
|
||||
);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops retrying a deadlock that never clears", async () => {
|
||||
const executeRaw = vi.fn().mockRejectedValue(deadlock());
|
||||
|
||||
await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow(
|
||||
"deadlock detected",
|
||||
);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -1604,7 +1604,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "List the caller's Coach goal / if-then plans",
|
||||
description:
|
||||
'v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.',
|
||||
'v1.21.3 — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.',
|
||||
parameters: [
|
||||
{
|
||||
name: "status",
|
||||
@@ -1645,7 +1645,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Confirm or update a Coach plan's lifecycle",
|
||||
description:
|
||||
"v1.21.3 (B1) — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
|
||||
"v1.21.3 — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -1688,7 +1688,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Soft-delete one Coach plan",
|
||||
description:
|
||||
"v1.21.3 (B1) — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
|
||||
"v1.21.3 — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -1760,7 +1760,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Create a Coach reminder manually",
|
||||
description:
|
||||
"v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
|
||||
"v1.22 — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -1796,7 +1796,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Confirm or update a Coach reminder's lifecycle",
|
||||
description:
|
||||
"v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer.",
|
||||
"v1.22 — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -1839,7 +1839,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Soft-delete one Coach reminder",
|
||||
description:
|
||||
"v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.",
|
||||
"v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
|
||||
@@ -101,9 +101,29 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
}
|
||||
|
||||
const quoted = tables.map((t) => `"${t}"`).join(", ");
|
||||
await client.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`,
|
||||
);
|
||||
// A truncate takes the strictest lock there is on every table at once, so it
|
||||
// loses to anything still holding a row lock. Thirty-nine places in the
|
||||
// server write without awaiting the result (a last-used stamp, a delivery
|
||||
// record, an audit row), and one of those can land after the test that
|
||||
// triggered it has returned. Postgres then reports a deadlock and kills the
|
||||
// truncate, which fails an unrelated file and passes on the next run.
|
||||
// Retrying is the honest answer: a deadlock means somebody else got there
|
||||
// first, and by the next attempt the stray write has finished. Only 40P01 is
|
||||
// retried, so a lock held for good still fails as it should.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await client.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`,
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
const deadlock =
|
||||
code === "40P01" || /deadlock detected/i.test(String(error));
|
||||
if (!deadlock || attempt >= 4) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
// Categories first — the tag rows FK into them.
|
||||
if (categorySnapshot.length > 0) {
|
||||
|
||||
Reference in new issue
Block a user