mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Mirrors the Google sleep suites name for name: the mapper keeps its externalIds across a re-score that re-classifies and renumbers segments, the sweep stays bounded to the night's window and never touches the fresh keep-set, a null window issues no delete at all, and a cleanup failure never fails the sync. The end-to-end fixture runs one night stored under the old keys through `syncUserSleep` and pins the ordering the heal depends on — sweep first, then the rescue re-keying the tombstoned rows in place, no parallel insert — plus a second pass that writes nothing at all. Also covers the repair's verdict gates (bounded 365-day window, marker stamped only on a clean run, watermark untouched, dead token and reauth park returning without a stamp), the unchanged-value skip including the re-classified segment it must not swallow, and both Retry-After paths.
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
/**
|
|
* Pins the tombstone-resurrect contract of `upsertFitbitMeasurements`.
|
|
*
|
|
* The measurements unique index on `(userId, type, source, externalId)` is
|
|
* FULL — it covers soft-deleted rows — so a tombstoned row permanently owns
|
|
* its key. The probe must therefore match tombstoned rows too and route them
|
|
* into the UPDATE branch with `deletedAt: null`: treating them as absent plans
|
|
* an insert that `skipDuplicates` drops SILENTLY against the tombstone's key,
|
|
* wedging the key forever (the same wedge shipped live on the Google
|
|
* transport).
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { findManyMock, createManyMock, updateMock, addWarningMock } = vi.hoisted(
|
|
() => ({
|
|
findManyMock: vi.fn(async () => [] as unknown[]),
|
|
createManyMock: vi.fn<
|
|
() => Promise<
|
|
Array<{ id: string; type: "DAILY_STEPS"; measuredAt: Date }>
|
|
>
|
|
>(async () => []),
|
|
updateMock: vi.fn(async () => ({})),
|
|
addWarningMock: vi.fn(),
|
|
}),
|
|
);
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
measurement: {
|
|
findMany: findManyMock,
|
|
createManyAndReturn: createManyMock,
|
|
update: updateMock,
|
|
},
|
|
},
|
|
}));
|
|
vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn(), decrypt: vi.fn() }));
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
getEvent: () => ({ addWarning: addWarningMock }),
|
|
annotate: () => {},
|
|
}));
|
|
vi.mock("@/lib/integrations/status", () => ({
|
|
isReauthRequired: vi.fn(async () => false),
|
|
recordSyncFailure: vi.fn(async () => {}),
|
|
recordSyncSuccess: vi.fn(async () => {}),
|
|
}));
|
|
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
|
collapseToTypeDayKeys: vi.fn(() => []),
|
|
recomputeBucketsForMeasurement: vi.fn(async () => {}),
|
|
recomputeUserRollups: vi.fn(async () => {}),
|
|
}));
|
|
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
|
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
|
|
}));
|
|
vi.mock("@/lib/integrations/oauth-refresh", () => ({
|
|
persistRotatedToken: vi.fn(async () => {}),
|
|
}));
|
|
vi.mock("../credentials", () => ({
|
|
getUserFitbitCredentials: vi.fn(async () => null),
|
|
}));
|
|
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
|
|
|
|
import { upsertFitbitMeasurements } from "../sync-core";
|
|
|
|
const STEPS_READING = {
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8123,
|
|
unit: "steps",
|
|
measuredAt: new Date("2026-07-08T00:00:00.000Z"),
|
|
externalId: "stats:steps:2026-07-08",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
findManyMock.mockReset().mockResolvedValue([]);
|
|
createManyMock.mockReset().mockResolvedValue([]);
|
|
updateMock.mockReset().mockResolvedValue({});
|
|
addWarningMock.mockReset();
|
|
});
|
|
|
|
describe("upsertFitbitMeasurements — tombstones resurrect", () => {
|
|
it("probes WITHOUT a deletedAt filter (a tombstoned row must be matched)", async () => {
|
|
await upsertFitbitMeasurements("user-1", [STEPS_READING], {
|
|
deferRollup: true,
|
|
});
|
|
const arg = (findManyMock.mock.calls[0] as unknown[])[0] as {
|
|
where: Record<string, unknown>;
|
|
};
|
|
expect(arg.where).toEqual({
|
|
userId: "user-1",
|
|
source: "FITBIT",
|
|
externalId: { in: ["stats:steps:2026-07-08"] },
|
|
});
|
|
expect(arg.where).not.toHaveProperty("deletedAt");
|
|
});
|
|
|
|
it("routes a matched (tombstoned) row into an update that clears deletedAt", async () => {
|
|
findManyMock.mockResolvedValue([
|
|
{
|
|
id: "row-1",
|
|
type: "ACTIVITY_STEPS",
|
|
externalId: "stats:steps:2026-07-08",
|
|
value: 8123,
|
|
unit: "steps",
|
|
measuredAt: new Date("2026-07-08T00:00:00.000Z"),
|
|
sleepStage: null,
|
|
deletedAt: new Date("2026-07-09T00:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[STEPS_READING],
|
|
{ deferRollup: true },
|
|
);
|
|
|
|
// No insert is planned for an owned key — the update resurrects in place.
|
|
expect(createManyMock).not.toHaveBeenCalled();
|
|
expect(updateMock).toHaveBeenCalledTimes(1);
|
|
const upd = (updateMock.mock.calls[0] as unknown[])[0] as {
|
|
where: { id: string };
|
|
data: Record<string, unknown>;
|
|
};
|
|
expect(upd.where).toEqual({ id: "row-1" });
|
|
expect(upd.data.deletedAt).toBeNull();
|
|
expect(upd.data.value).toBe(8123);
|
|
expect(upd.data.syncVersion).toEqual({ increment: 1 });
|
|
expect(imported).toBe(1);
|
|
});
|
|
|
|
it("still creates a genuinely fresh key", async () => {
|
|
createManyMock.mockResolvedValue([
|
|
{ id: "inserted-1", type: "DAILY_STEPS", measuredAt: new Date() },
|
|
]);
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[STEPS_READING],
|
|
{ deferRollup: true },
|
|
);
|
|
expect(updateMock).not.toHaveBeenCalled();
|
|
expect(createManyMock).toHaveBeenCalledTimes(1);
|
|
expect(imported).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("upsertFitbitMeasurements — no-op overwrite skip", () => {
|
|
it("skips the update entirely for an identical LIVE row (no syncVersion churn)", async () => {
|
|
findManyMock.mockResolvedValue([
|
|
{
|
|
id: "row-1",
|
|
type: "ACTIVITY_STEPS",
|
|
externalId: "stats:steps:2026-07-08",
|
|
value: 8123,
|
|
unit: "steps",
|
|
measuredAt: new Date("2026-07-08T00:00:00.000Z"),
|
|
sleepStage: null,
|
|
deletedAt: null,
|
|
},
|
|
]);
|
|
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[STEPS_READING],
|
|
{ deferRollup: true },
|
|
);
|
|
|
|
// The 24 h overlap re-fetches this row hourly; an unchanged live row must
|
|
// not be rewritten (the unconditional syncVersion bump churned the DB and
|
|
// every iOS delta pull).
|
|
expect(updateMock).not.toHaveBeenCalled();
|
|
expect(createManyMock).not.toHaveBeenCalled();
|
|
expect(imported).toBe(0);
|
|
});
|
|
|
|
it("still writes when any payload field differs on a live row", async () => {
|
|
findManyMock.mockResolvedValue([
|
|
{
|
|
id: "row-1",
|
|
type: "ACTIVITY_STEPS",
|
|
externalId: "stats:steps:2026-07-08",
|
|
value: 7999, // stale daily total — Fitbit re-rolled the day upward
|
|
unit: "steps",
|
|
measuredAt: new Date("2026-07-08T00:00:00.000Z"),
|
|
sleepStage: null,
|
|
deletedAt: null,
|
|
},
|
|
]);
|
|
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[STEPS_READING],
|
|
{ deferRollup: true },
|
|
);
|
|
|
|
expect(updateMock).toHaveBeenCalledTimes(1);
|
|
expect(imported).toBe(1);
|
|
});
|
|
|
|
it("writes when only the sleep stage differs (a re-classified segment)", async () => {
|
|
// The stable segment key deliberately drops the stage, so a re-label
|
|
// arrives on the SAME externalId — the skip must not swallow it.
|
|
findManyMock.mockResolvedValue([
|
|
{
|
|
id: "row-1",
|
|
type: "SLEEP_DURATION",
|
|
externalId: "777:sleep:2026-07-08T05:45:00.000Z",
|
|
value: 45,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-07-08T06:30:00.000Z"),
|
|
sleepStage: "CORE",
|
|
deletedAt: null,
|
|
},
|
|
]);
|
|
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 45,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-07-08T06:30:00.000Z"),
|
|
externalId: "777:sleep:2026-07-08T05:45:00.000Z",
|
|
sleepStage: "DEEP" as const,
|
|
},
|
|
],
|
|
{ deferRollup: true },
|
|
);
|
|
|
|
expect(updateMock).toHaveBeenCalledTimes(1);
|
|
const upd = (updateMock.mock.calls[0] as unknown[])[0] as {
|
|
data: Record<string, unknown>;
|
|
};
|
|
expect(upd.data.sleepStage).toBe("DEEP");
|
|
expect(imported).toBe(1);
|
|
});
|
|
|
|
it("an identical TOMBSTONED row ALWAYS updates — the write is the resurrection", async () => {
|
|
findManyMock.mockResolvedValue([
|
|
{
|
|
id: "row-1",
|
|
type: "ACTIVITY_STEPS",
|
|
externalId: "stats:steps:2026-07-08",
|
|
value: 8123,
|
|
unit: "steps",
|
|
measuredAt: new Date("2026-07-08T00:00:00.000Z"),
|
|
sleepStage: null,
|
|
deletedAt: new Date("2026-07-09T00:00:00.000Z"),
|
|
},
|
|
]);
|
|
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[STEPS_READING],
|
|
{ deferRollup: true },
|
|
);
|
|
|
|
expect(updateMock).toHaveBeenCalledTimes(1);
|
|
const upd = (updateMock.mock.calls[0] as unknown[])[0] as {
|
|
data: Record<string, unknown>;
|
|
};
|
|
expect(upd.data.deletedAt).toBeNull();
|
|
expect(imported).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("upsertFitbitMeasurements — natural-key migration rescue", () => {
|
|
const SLEEP_READING = {
|
|
type: "SLEEP_DURATION",
|
|
value: 45,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-07-08T06:30:00.000Z"),
|
|
externalId: "log-42:sleep:2026-07-08T05:45:00.000Z",
|
|
sleepStage: "DEEP" as const,
|
|
};
|
|
|
|
it("re-keys a tombstoned natural-key twin instead of dropping the insert", async () => {
|
|
// externalId probe: no match (the key FORMAT changed); natural-key probe:
|
|
// the old-key row (tombstoned by the sweep) occupies the same
|
|
// (type, measuredAt, sleepStage) slot — without the rescue the insert
|
|
// would silently die on the 0055 unique via skipDuplicates.
|
|
findManyMock
|
|
.mockResolvedValueOnce([]) // externalId probe
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: "old-row",
|
|
type: "SLEEP_DURATION",
|
|
measuredAt: new Date("2026-07-08T06:30:00.000Z"),
|
|
sleepStage: "DEEP",
|
|
},
|
|
]); // natural-key rescue probe
|
|
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[SLEEP_READING],
|
|
{ deferRollup: true },
|
|
);
|
|
|
|
// The rescue probe must see tombstoned rows too: no deletedAt filter.
|
|
const probe = (findManyMock.mock.calls[1] as unknown[])[0] as {
|
|
where: Record<string, unknown>;
|
|
};
|
|
expect(probe.where).toEqual({
|
|
userId: "user-1",
|
|
source: "FITBIT",
|
|
type: { in: ["SLEEP_DURATION"] },
|
|
measuredAt: { in: [new Date("2026-07-08T06:30:00.000Z")] },
|
|
});
|
|
expect(probe.where).not.toHaveProperty("deletedAt");
|
|
|
|
expect(createManyMock).not.toHaveBeenCalled();
|
|
expect(updateMock).toHaveBeenCalledTimes(1);
|
|
const upd = (updateMock.mock.calls[0] as unknown[])[0] as {
|
|
where: { id: string };
|
|
data: Record<string, unknown>;
|
|
};
|
|
expect(upd.where).toEqual({ id: "old-row" });
|
|
expect(upd.data.externalId).toBe("log-42:sleep:2026-07-08T05:45:00.000Z");
|
|
expect(upd.data.deletedAt).toBeNull();
|
|
expect(upd.data.value).toBe(45);
|
|
expect(imported).toBe(1);
|
|
});
|
|
|
|
it("re-keys a LIVE natural-key twin in place (update, never a duplicate insert)", async () => {
|
|
findManyMock
|
|
.mockResolvedValueOnce([]) // externalId probe
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: "live-row",
|
|
type: "ACTIVITY_STEPS",
|
|
measuredAt: new Date("2026-07-08T00:00:00.000Z"),
|
|
sleepStage: null,
|
|
},
|
|
]); // natural-key rescue probe
|
|
|
|
const reading = { ...STEPS_READING, externalId: "log-99:steps:2026-07-08" };
|
|
const { imported } = await upsertFitbitMeasurements("user-1", [reading], {
|
|
deferRollup: true,
|
|
});
|
|
|
|
expect(createManyMock).not.toHaveBeenCalled();
|
|
expect(updateMock).toHaveBeenCalledTimes(1);
|
|
const upd = (updateMock.mock.calls[0] as unknown[])[0] as {
|
|
where: { id: string };
|
|
data: Record<string, unknown>;
|
|
};
|
|
expect(upd.where).toEqual({ id: "live-row" });
|
|
expect(upd.data.externalId).toBe("log-99:steps:2026-07-08");
|
|
expect(upd.data.deletedAt).toBeNull();
|
|
expect(imported).toBe(1);
|
|
});
|
|
|
|
it("creates normally when no natural-key twin exists", async () => {
|
|
findManyMock.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
|
createManyMock.mockResolvedValue([
|
|
{ id: "inserted-1", type: "DAILY_STEPS", measuredAt: new Date() },
|
|
]);
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[STEPS_READING],
|
|
{ deferRollup: true },
|
|
);
|
|
expect(updateMock).not.toHaveBeenCalled();
|
|
expect(createManyMock).toHaveBeenCalledTimes(1);
|
|
expect(imported).toBe(1);
|
|
});
|
|
|
|
it("a rescue-probe failure warns and still attempts the insert (never throws)", async () => {
|
|
// The ledger note that fails the cycle's verdict is pinned at the
|
|
// `syncUserFitbit` level (sync-user-fitbit.test.ts) — the ambient
|
|
// hard-fail scope only exists inside the orchestrator.
|
|
findManyMock
|
|
.mockResolvedValueOnce([]) // externalId probe
|
|
.mockRejectedValueOnce(new Error("db down")); // natural-key rescue probe
|
|
createManyMock.mockResolvedValue([
|
|
{ id: "inserted-1", type: "DAILY_STEPS", measuredAt: new Date() },
|
|
]);
|
|
|
|
const { imported } = await upsertFitbitMeasurements(
|
|
"user-1",
|
|
[STEPS_READING],
|
|
{ deferRollup: true },
|
|
);
|
|
|
|
expect(addWarningMock).toHaveBeenCalledWith(
|
|
expect.stringContaining("natural-key rescue probe failed"),
|
|
);
|
|
// The planned create still goes to createMany — skipDuplicates absorbs a
|
|
// twin collision and the held watermark retries the rescue next tick.
|
|
expect(createManyMock).toHaveBeenCalledTimes(1);
|
|
expect(imported).toBe(1);
|
|
});
|
|
});
|