Files
HealthLog/tests/integration/sync-reset-after-restore.test.ts
T
Marc-André Bombeck eda87fd4a7 fix(sync): expire delta cursors issued before a restore or a data wipe
A restore writes rows back with their original updatedAt, behind every
paired client's watermarks, and removes rows without leaving tombstones.
A client that kept its cursor would miss both on its next incremental
pull. The same holds after the person deletes all their data.

`users.sync_reset_at` (migration 0363) records when the record was last
replaced: the restore stamps it as its last write and again right after
the commit, the per-account and the admin wipe stamp it inside their
transactions. Cursors now carry the time they were issued, and
/api/sync/changes answers its existing `cursorExpired` to a cursor issued
before the reset, or one without an issue time on an account that has
one. The comparison is on the issue time, because after a restore a
freshly drained cursor still carries old watermarks.
2026-09-27 13:46:38 +02:00

211 lines
7.0 KiB
TypeScript

/**
* A restore or a delete-all-data expires every delta cursor issued before it.
*
* A restore writes rows back with their original `updatedAt` and removes rows
* without leaving tombstones. A paired client that kept its cursor would, on
* its next incremental pull, miss both: the restored rows sit behind its
* watermarks and the removed ones never show up as deletions. So the restore
* stamps `User.syncResetAt`, and `/api/sync/changes` answers `cursorExpired`
* to any cursor issued before it. A cursor the feed hands out after the
* reset keeps working, although it may carry watermarks older than the reset
* (the restored rows'), which is why the comparison is on the cursor's issue
* time.
*
* Mutation checks: drop the `beforeReset` arm in the route and the first two
* tests go red on `cursorExpired`; compare the reset against the cursor's
* watermarks instead of its issue time and the cursor issued after the
* restore expires too.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
interface Page {
cursor: string | null;
hasMore: boolean;
cursorExpired: boolean;
changes: {
measurements: {
upserts: Array<{ id: string }>;
tombstones: Array<{ id: string }>;
};
};
}
async function pull(cursor?: string): Promise<Page> {
const { GET } = await import("@/app/api/sync/changes/route");
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
const res = await GET(
new NextRequest(`http://localhost/api/sync/changes${query}`),
);
expect(res.status).toBe(200);
return ((await res.json()) as { data: Page }).data;
}
/** Drain the feed from `cursor` to the end; the last page's cursor. */
async function drain(
cursor?: string,
): Promise<{ cursor: string; ids: string[] }> {
const ids: string[] = [];
let next = cursor;
for (;;) {
const page = await pull(next);
expect(page.cursorExpired).toBe(false);
ids.push(...page.changes.measurements.upserts.map((m) => m.id));
next = page.cursor!;
if (!page.hasMore) return { cursor: next, ids };
}
}
async function seedAccount() {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "sync-reset",
email: "sync-reset@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
});
cookieJar.set("healthlog_session", session.id);
const earlier = Date.now() - 3 * 86_400_000;
await prisma.measurement.createMany({
data: Array.from({ length: 5 }, (_, i) => ({
id: `m-${i}`,
userId: user.id,
type: "WEIGHT" as const,
value: 80 + i,
unit: "kg",
measuredAt: new Date(earlier + i * 60_000),
// Old row times, as a restored row carries.
createdAt: new Date(earlier),
updatedAt: new Date(earlier + i),
})),
});
return user;
}
async function restoreBackup(backupId: string) {
const { POST } = await import("./restore-job-driver");
const res = await POST(
new Request(`http://localhost/api/admin/backups/${backupId}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backupId }) },
);
expect(res.status).toBe(200);
}
describe("sync reset after a restore or a wipe", () => {
it("expires a cursor issued before a restore, and not one issued after it", async () => {
const user = await seedAccount();
const prisma = getPrismaClient();
const { id: backupId } = await storeBackupBlob(
prisma,
{ userId: user.id, type: "WEEKLY_AUTO" },
(write) =>
streamFullBackupJson(prisma, user.id, write, {
purpose: "disaster-recovery",
}),
);
// A client caught up, then a reading arrived and was synced, and then
// the account was restored to the backup, which removes that reading
// without a tombstone.
const before = await drain();
await prisma.measurement.create({
data: {
id: "m-after-backup",
userId: user.id,
type: "WEIGHT",
value: 90,
unit: "kg",
measuredAt: new Date(),
},
});
const caughtUp = await drain(before.cursor);
expect(caughtUp.ids).toEqual(["m-after-backup"]);
await restoreBackup(backupId);
expect(
await prisma.measurement.findUnique({ where: { id: "m-after-backup" } }),
).toBeNull();
// The old cursor would have reported nothing: the removal left no
// tombstone and the restored rows sit behind its watermarks.
const stale = await pull(caughtUp.cursor);
expect(stale.cursorExpired).toBe(true);
expect(stale.changes.measurements.upserts).toEqual([]);
// Re-initialised: the whole restored record, and the new cursor holds
// although every restored row is older than the reset.
const fresh = await drain();
expect(fresh.ids.sort()).toEqual(["m-0", "m-1", "m-2", "m-3", "m-4"]);
const next = await pull(fresh.cursor);
expect(next.cursorExpired).toBe(false);
});
it("expires a cursor issued before the person deleted all their data", async () => {
await seedAccount();
const { cursor } = await drain();
const { DELETE } = await import("@/app/api/settings/data/route");
const res = await DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
);
expect(res.status).toBe(200);
expect((await pull(cursor)).cursorExpired).toBe(true);
const fresh = await drain();
expect(fresh.ids).toEqual([]);
expect((await pull(fresh.cursor)).cursorExpired).toBe(false);
});
it("leaves an account that was never restored alone", async () => {
await seedAccount();
const { cursor } = await drain();
expect((await pull(cursor)).cursorExpired).toBe(false);
});
});