mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The AI settings page posted to /api/consent/ai/web on every mount and the server minted an ai_full receipt for it, so an account that had agreed to nothing sent its health data to the operator's provider from background jobs. Native builds synced a receipt the same way on every launch. - Both grant routes now require `confirm: true`; anything else writes nothing and answers 400 consent.ai.confirmRequired. - Remove the mount-time heal. A consent card names the operator's service and the data that would go there, with one Agree button, in Settings > AI and on the Insights overview while AI is closed by the missing consent alone. - Publish the standing consent as `ai.consent` on /api/auth/me (status, since, recipients); the settings card renders active-since and the withdraw control from it. - Existing receipts stay valid. - Guard that no client grants outside a click handler, route tests for the confirmation, and an integration test that a fresh operator-key account gets no background AI text until it agrees.
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
/**
|
|
* What happens to stored AI text and stored Coach rows, against the real
|
|
* server, on an account of its own (`E2E_AI_OPTIONAL`).
|
|
*
|
|
* J5 — consent withdrawn. The account has a local model (an address nothing
|
|
* listens on: presence is all the capability reads, nothing here calls
|
|
* it), a daily briefing a model wrote earlier, and an AI consent. The
|
|
* briefing is on the overview; the consent is withdrawn in Settings, AI
|
|
* through the real control; the briefing is gone, because the server
|
|
* deleted the notes written under that consent.
|
|
* J4 — Coach hidden. With Hide Coach on, Settings, Coach is out of reach, so
|
|
* Settings, AI lists what the Coach stored; one stored conversation is
|
|
* deleted from there and is gone from the database.
|
|
*
|
|
* Both journeys move the same account's state, so they run serially, in one
|
|
* project (see `playwright.config.ts`).
|
|
*/
|
|
import type { Page } from "@playwright/test";
|
|
|
|
import { AI_OPTIONAL_STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import {
|
|
coachConversationCount,
|
|
resetAiOptionalAccount,
|
|
seedHiddenCoachWithConversation,
|
|
seedStoredBriefing,
|
|
} from "./setup/ai-optional-fixture";
|
|
import { expect, test } from "./setup/test";
|
|
import { revealDeferredSectionsSettled } from "./utils/deferred-sections";
|
|
|
|
test.use({ storageState: AI_OPTIONAL_STORAGE_STATE_PATH });
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test.beforeEach(async () => {
|
|
await resetAiOptionalAccount();
|
|
});
|
|
test.afterAll(async () => {
|
|
await resetAiOptionalAccount();
|
|
});
|
|
|
|
/**
|
|
* The overview with every section mounted. Sections past the first three
|
|
* render as an empty sentinel until they near the viewport, so a count of
|
|
* zero taken before they mount would pass whatever they paint.
|
|
*/
|
|
async function openOverview(page: Page): Promise<void> {
|
|
await page.goto("/insights");
|
|
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({
|
|
timeout: 20_000,
|
|
});
|
|
await page.waitForLoadState("networkidle");
|
|
await revealDeferredSectionsSettled(page);
|
|
}
|
|
|
|
test("J5: withdrawing AI consent deletes the notes written under it", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const marker = `Stored briefing ${Date.now()}`;
|
|
await seedStoredBriefing(marker);
|
|
|
|
// Positive control: the stored briefing is what the overview shows.
|
|
await openOverview(page);
|
|
await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(1);
|
|
await expect(page.getByText(marker).first()).toBeVisible();
|
|
|
|
// Withdraw through the real control. The confirm names what goes and what
|
|
// stays before anything happens.
|
|
await page.goto("/settings/ai");
|
|
await page.locator('[data-slot="ai-consent-withdraw"]').click();
|
|
const withdrawn = page.waitForResponse(
|
|
(res) =>
|
|
res.url().includes("/api/consent/ai/latest") &&
|
|
res.request().method() === "DELETE",
|
|
);
|
|
await page.locator('[data-slot="ai-consent-withdraw-confirm"]').click();
|
|
expect((await withdrawn).status()).toBe(200);
|
|
// On a local model nothing needs the consent, so once withdrawn there is
|
|
// nothing to withdraw and nothing to ask.
|
|
await expect(page.locator('[data-slot="ai-consent-withdraw"]')).toHaveCount(
|
|
0,
|
|
);
|
|
|
|
await openOverview(page);
|
|
await expect(page.getByText(marker)).toHaveCount(0);
|
|
});
|
|
|
|
test("J4: the Coach memory stays readable and deletable with the Coach hidden", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const title = `Stored conversation ${Date.now()}`;
|
|
await seedHiddenCoachWithConversation(title);
|
|
|
|
// Hide Coach takes the Coach page, its launcher and every per-card
|
|
// hand-off away...
|
|
await openOverview(page);
|
|
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
|
|
await expect(page.locator('[data-slot="ask-coach-action"]')).toHaveCount(0);
|
|
await expect(page.locator('[data-slot="ask-coach-icon"]')).toHaveCount(0);
|
|
|
|
// ...and Settings, AI lists what the Coach stored.
|
|
await page.goto("/settings/ai");
|
|
const card = page.locator(
|
|
'[data-testid="settings-coach-conversations-card"]',
|
|
);
|
|
await expect(card).toBeVisible({ timeout: 20_000 });
|
|
const row = card
|
|
.locator('[data-testid="settings-coach-conversation"]')
|
|
.filter({ hasText: title });
|
|
await expect(row).toHaveCount(1);
|
|
|
|
// Readable: the row opens its transcript in place.
|
|
await row.locator('[data-slot="settings-coach-conversation-open"]').click();
|
|
await expect(
|
|
row.locator('[data-slot="settings-coach-conversation-open"]'),
|
|
).toHaveAttribute("aria-expanded", "true");
|
|
|
|
// Deletable: the row goes at once, and the delete reaches the server once
|
|
// the undo window closes.
|
|
const deleted = page.waitForResponse(
|
|
(res) =>
|
|
/\/api\/insights\/chat\/[^/]+$/.test(new URL(res.url()).pathname) &&
|
|
res.request().method() === "DELETE",
|
|
{ timeout: 30_000 },
|
|
);
|
|
await row.locator('[data-slot="settings-coach-conversation-delete"]').click();
|
|
await expect(row).toHaveCount(0);
|
|
expect((await deleted).ok()).toBe(true);
|
|
expect(await coachConversationCount()).toBe(0);
|
|
});
|