mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Deleting a conversation hides the row and sends the DELETE only after the six-second undo window. The pending delete lived in a timer alone, so a reload, a closed tab or an installed app sent to the background inside the window dropped it without a request, and the conversation came back. When the window did run out, the hide filter was released while the paginated history still held the row, so it showed again until the refetch landed. Every surface that deletes a conversation (the Coach panel, the conversations page, the settings memory card) shares one hook, and the fix lives there: - a scheduled delete commits early when the page is hidden, on pagehide and on unmount, and the request is sent with keepalive - a committed row leaves every list cache, head list and every history search variant, before anything else changes, and stays hidden - deletes not yet confirmed are kept in sessionStorage; the next page hides them and sends them again, so its list read cannot bring the row back before the old page's request lands; a 404 counts as gone - a failed delete restores the lists and says so instead of the row quietly returning - Undo after the commit reports false, so the rail does not reopen a thread that is gone
189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
import { expect, test } from "./setup/test";
|
|
import type { Route } from "@playwright/test";
|
|
import { randomBytes } from "node:crypto";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { hash } from "@node-rs/argon2";
|
|
import pg from "pg";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
|
|
|
const KEEP_ID = "conversation-delete-keep";
|
|
const DROP_ID = "conversation-delete-drop";
|
|
|
|
function row(id: string, title: string) {
|
|
return {
|
|
id,
|
|
title,
|
|
createdAt: "2026-07-20T10:00:00.000Z",
|
|
updatedAt: "2026-07-20T10:00:00.000Z",
|
|
messageCount: 2,
|
|
fenced: false,
|
|
attachments: [],
|
|
documentTitle: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A deleted conversation must stay deleted. The delete waits out an undo
|
|
* window before it is sent; a reload inside that window used to drop it, and
|
|
* once the window ran out the row showed again until the refetch landed.
|
|
*/
|
|
test.describe("Coach conversation delete", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await serveAiBlock(page, aiBlockAvailable());
|
|
});
|
|
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
async function serveConversations(
|
|
page: import("@playwright/test").Page,
|
|
deleteDelayMs: number,
|
|
) {
|
|
const state = {
|
|
rows: [row(KEEP_ID, "Kept thread"), row(DROP_ID, "Dropped thread")],
|
|
deletes: [] as string[],
|
|
};
|
|
// Context-level, so a `keepalive` request sent while the page unloads is
|
|
// still answered here.
|
|
await page
|
|
.context()
|
|
.route(
|
|
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
|
|
async (route: Route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
if (request.method() === "DELETE") {
|
|
const id = decodeURIComponent(url.pathname.split("/").pop() ?? "");
|
|
state.deletes.push(id);
|
|
state.rows = state.rows.filter((r) => r.id !== id);
|
|
if (deleteDelayMs > 0) await delay(deleteDelayMs);
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data: { deleted: true }, error: null }),
|
|
});
|
|
}
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { conversations: state.rows, nextCursor: null },
|
|
error: null,
|
|
}),
|
|
});
|
|
},
|
|
);
|
|
return state;
|
|
}
|
|
|
|
test("the row does not come back when the window runs out", async ({
|
|
page,
|
|
}) => {
|
|
// A slow DELETE holds the refetch back, which is when the row used to
|
|
// reappear.
|
|
const state = await serveConversations(page, 2_000);
|
|
await page.goto("/coach/conversations", { waitUntil: "domcontentloaded" });
|
|
|
|
const items = page.locator('[data-slot="coach-conversations-item"]');
|
|
await expect(items).toHaveCount(2, { timeout: 10_000 });
|
|
await items
|
|
.filter({ hasText: "Dropped thread" })
|
|
.locator('[data-slot="coach-conversations-delete"]')
|
|
.click();
|
|
await expect(items).toHaveCount(1);
|
|
|
|
await expect
|
|
.poll(() => state.deletes, { timeout: 15_000 })
|
|
.toEqual([DROP_ID]);
|
|
// Sampled while the DELETE is still in flight.
|
|
for (let i = 0; i < 6; i += 1) {
|
|
expect(await items.count()).toBe(1);
|
|
await delay(250);
|
|
}
|
|
await expect(items).toContainText("Kept thread");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The real server and database, not a mock: the request that matters here is
|
|
* sent with `keepalive` while the page unloads, which Playwright's routing
|
|
* never sees. The account is the spec's own, so the conversation it seeds
|
|
* cannot surface in another spec's Coach.
|
|
*/
|
|
test.describe("Coach conversation delete against the database", () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
test("a reload inside the undo window still deletes", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const dbUrl = process.env.DATABASE_URL;
|
|
test.skip(!dbUrl, "DATABASE_URL is required to seed the conversation");
|
|
|
|
const suffix = `${testInfo.project.name}-${randomBytes(4).toString("hex")}`;
|
|
const username = `e2e-coach-delete-${suffix}`;
|
|
const password = "Cd5!Vq8nTz3wLp7K";
|
|
const userId = `e2e-coach-delete-user-${suffix}`;
|
|
const conversationId = `e2e-coach-delete-${suffix}`;
|
|
const pool = new pg.Pool({ connectionString: dbUrl });
|
|
try {
|
|
await pool.query(
|
|
`INSERT INTO users
|
|
(id, username, email, password_hash, role, created_at, updated_at,
|
|
onboarding_completed_at, onboarding_tour_completed)
|
|
VALUES ($1, $2, $3, $4, 'USER', now(), now(), now(), true)`,
|
|
[
|
|
userId,
|
|
username,
|
|
`${username}@healthlog.test`,
|
|
await hash(password, {
|
|
memoryCost: 19456,
|
|
timeCost: 2,
|
|
outputLen: 32,
|
|
parallelism: 1,
|
|
}),
|
|
],
|
|
);
|
|
// A legacy readable title keeps the seed free of the encryption key.
|
|
await pool.query(
|
|
`INSERT INTO coach_conversations (id, user_id, title, updated_at)
|
|
VALUES ($1, $2, 'Dropped thread', now())`,
|
|
[conversationId, userId],
|
|
);
|
|
|
|
const login = await page.request.post("/api/auth/login", {
|
|
data: { email: username, password },
|
|
});
|
|
expect(login.status()).toBe(200);
|
|
|
|
await page.goto("/coach/conversations", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
const items = page.locator('[data-slot="coach-conversations-item"]');
|
|
await expect(items).toHaveCount(1, { timeout: 15_000 });
|
|
await items.locator('[data-slot="coach-conversations-delete"]').click();
|
|
await expect(items).toHaveCount(0);
|
|
|
|
// Well inside the undo window.
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const { rowCount } = await pool.query(
|
|
"SELECT 1 FROM coach_conversations WHERE id = $1",
|
|
[conversationId],
|
|
);
|
|
return rowCount;
|
|
})
|
|
.toBe(0);
|
|
await expect(
|
|
page.locator('[data-slot="coach-conversations-empty"]'),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
await expect(items).toHaveCount(0);
|
|
} finally {
|
|
await pool.query("DELETE FROM users WHERE id = $1", [userId]);
|
|
await pool.end();
|
|
}
|
|
});
|
|
});
|