mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge work/side-effecting-gets
This commit is contained in:
commit
b40e9bc40f
28 files changed
+1479
-139
No files matched your search
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Structural guard: the three read routes that used to write must stay reads.
|
||||
*
|
||||
* `src/lib/api-handler.ts` states the invariant next to `READ_HTTP_METHODS` —
|
||||
* an MCP-audience token is admitted on GET/HEAD/OPTIONS on the REST surface
|
||||
* because those methods are assumed side-effect-free, and adding a
|
||||
* side-effecting GET silently widens that token's reach. These three routes
|
||||
* each broke it once. The runtime tests beside each route prove the specific
|
||||
* write is gone; this one fails on a NEW one.
|
||||
*
|
||||
* Deliberately scoped to these three files and to writes issued directly in
|
||||
* the handler. A repo-wide version is not written today because it cannot be
|
||||
* written without false positives: several read routes call helpers that
|
||||
* legitimately upsert a lazily-created row (`getOrCreateCycleProfile` behind
|
||||
* `requireCycleEnabled` is the clearest one), and a text matcher cannot tell
|
||||
* that apart from a real side effect. Widening this guard means resolving
|
||||
* those cases first, not loosening the pattern.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/** Mutating Prisma model methods, as they appear at a call site. */
|
||||
const PRISMA_WRITE =
|
||||
/\.(create|createMany|createManyAndReturn|update|updateMany|upsert|delete|deleteMany|executeRaw|executeRawUnsafe)\s*\(/;
|
||||
|
||||
/**
|
||||
* Write helpers these routes used to reach for. A helper hides its writes
|
||||
* from the pattern above, so each one that mattered here is named.
|
||||
*/
|
||||
const WRITE_HELPERS =
|
||||
/\b(persistPredictionCache|markSyncCheckpoint|auditLog\s*\()/;
|
||||
|
||||
const ROUTES = [
|
||||
"sync/state/route.ts",
|
||||
"gamification/achievements/route.ts",
|
||||
"cycle/calendar/route.ts",
|
||||
];
|
||||
|
||||
function sourceOf(relative: string): string {
|
||||
return readFileSync(join(__dirname, "..", relative), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip comments before matching. Every one of these files DESCRIBES the
|
||||
* write it no longer performs, and a guard that reads prose as code is a
|
||||
* guard that fires on its own documentation.
|
||||
*/
|
||||
function code(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
describe("read routes issue no writes", () => {
|
||||
it("the patterns actually match a write — otherwise every case below is vacuous", () => {
|
||||
expect(
|
||||
PRISMA_WRITE.test(`await prisma.user.update({ where: { id } });`),
|
||||
).toBe(true);
|
||||
expect(
|
||||
PRISMA_WRITE.test(`prisma.userAchievement.createMany({ data });`),
|
||||
).toBe(true);
|
||||
expect(WRITE_HELPERS.test(`void persistPredictionCache(userId, p);`)).toBe(
|
||||
true,
|
||||
);
|
||||
// And do NOT match the reads these routes are made of.
|
||||
expect(PRISMA_WRITE.test(`await prisma.measurement.findMany({});`)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(PRISMA_WRITE.test(`await prisma.moodEntry.count({});`)).toBe(false);
|
||||
});
|
||||
|
||||
it("scans all three routes and finds real source in each", () => {
|
||||
// An empty read set would pass every assertion below without looking at
|
||||
// anything.
|
||||
expect(ROUTES).toHaveLength(3);
|
||||
for (const route of ROUTES) {
|
||||
expect(code(sourceOf(route)).length).toBeGreaterThan(500);
|
||||
}
|
||||
});
|
||||
|
||||
for (const route of ROUTES) {
|
||||
it(`${route} calls no mutating Prisma method`, () => {
|
||||
expect(code(sourceOf(route))).not.toMatch(PRISMA_WRITE);
|
||||
});
|
||||
|
||||
it(`${route} calls no known write helper`, () => {
|
||||
expect(code(sourceOf(route))).not.toMatch(WRITE_HELPERS);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -22,6 +22,12 @@ vi.mock("@/lib/db", () => ({
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
// Present so a re-introduced forecast persist is VISIBLE here rather
|
||||
// than blowing up on an undefined model. `persistPredictionCache` calls
|
||||
// `findUnique` synchronously before its first await, so even the old
|
||||
// fire-and-forget `void persistPredictionCache(...)` lands on this mock
|
||||
// before the assertion runs.
|
||||
cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -34,10 +40,6 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() }));
|
||||
// The cache persist is fire-and-forget and would reach the database.
|
||||
vi.mock("@/lib/cycle/prediction-cache", () => ({
|
||||
persistPredictionCache: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -142,6 +144,22 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/calendar is a read", () => {
|
||||
it("persists no forecast — the reminder cron's row is the job's, not this route's", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
|
||||
|
||||
const { status, body } = await callCalendar();
|
||||
expect(status).toBe(200);
|
||||
// There IS a forecast to persist, so the absence of a write is a
|
||||
// decision rather than an empty case.
|
||||
expect((body.data as Record<string, unknown>).prediction).not.toBeNull();
|
||||
|
||||
expect(prisma.cyclePrediction.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.cyclePrediction.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/calendar carries the resolved verdict", () => {
|
||||
it("puts the whole verdict on the wire", async () => {
|
||||
// 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* (ios-contract §2.D).
|
||||
*
|
||||
* Calls the pure engine (`predictCycle` + per-day phase) to build
|
||||
* `{ profile, prediction, days }`. The synchronous engine call is cheap
|
||||
* (pure stats); the heavy part is the cache persist, which is debounced
|
||||
* stale-while-revalidate and fire-and-forget so it never blocks the GET.
|
||||
* `{ profile, prediction, days }`. The engine call is cheap (pure stats) and
|
||||
* the route writes nothing — the `CyclePrediction` row the reminder cron
|
||||
* reads is owned by the `cycle-prediction-refresh` job.
|
||||
* Fertile-window fields are goal-gated (surfaced for TRYING_TO_CONCEIVE and
|
||||
* AVOID_PREGNANCY — the latter with the stronger "not a contraceptive method"
|
||||
* disclaimer; suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF),
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
cycleDisclaimerKey,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
|
||||
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { BBT_WINDOW } from "@/lib/cycle/types";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
@@ -157,13 +156,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
goalAllowsFertile,
|
||||
);
|
||||
|
||||
// Persist the cache fire-and-forget (debounced) — never block the read.
|
||||
let generatedAt = new Date().toISOString();
|
||||
if (prediction) {
|
||||
const now = new Date();
|
||||
generatedAt = now.toISOString();
|
||||
void persistPredictionCache(user.id, prediction, now);
|
||||
}
|
||||
// v1.35.3 — this read persists nothing. It used to fire
|
||||
// `void persistPredictionCache(...)`: a write on a GET, and a discarded
|
||||
// promise on top, so a failed write left no trace at all. The forecast the
|
||||
// response carries is computed right here from the account's rows; the
|
||||
// `CyclePrediction` row the reminder cron scans is written by the
|
||||
// `cycle-prediction-refresh` job, which reaches every tracking account
|
||||
// rather than the ones that opened this page.
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
|
||||
@@ -217,14 +217,8 @@ describe("GET /api/gamification/achievements — per-module badge skipping", ()
|
||||
expect(badges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never persists an unlock for a disabled-module badge", async () => {
|
||||
// A mood badge would unlock at >=1 entry; with mood OFF the createMany
|
||||
// must carry no mood achievement row.
|
||||
await badgesFor({ mood: false });
|
||||
const createCalls = vi.mocked(prisma.userAchievement.createMany).mock.calls;
|
||||
for (const [arg] of createCalls) {
|
||||
const rows = (arg as { data: Array<{ achievementId: string }> }).data;
|
||||
expect(rows.some((r) => r.achievementId.includes("mood"))).toBe(false);
|
||||
}
|
||||
});
|
||||
// The "a disabled module never gets its badge pinned" invariant moved with
|
||||
// the write itself — see `src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts`.
|
||||
// That this route persists nothing at all is proved in
|
||||
// `writes-nothing.test.ts`, against a result that HAS a pending unlock.
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* `GET /api/gamification/achievements` is a read.
|
||||
*
|
||||
* It used to INSERT a `UserAchievement` row for every unlock it noticed,
|
||||
* which is the side-effecting GET the MCP-audience note in `api-handler.ts`
|
||||
* warns against. The evaluation still runs here; only the persistence moved
|
||||
* to the `achievement-unlock-sweep` job.
|
||||
*
|
||||
* The builder is faked so the result ALWAYS carries a pending unlock. That is
|
||||
* the point: an assertion that the route did not write is worth nothing
|
||||
* against a fixture that had nothing to write, and the fixture in
|
||||
* `module-gate.test.ts` turns out to be exactly that.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { userAchievement: { findMany: vi.fn(), createMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
resolveModuleMap: vi.fn(async () => ({})),
|
||||
MODULE_DISABLED_ERROR_CODE: "module.disabled",
|
||||
}));
|
||||
|
||||
const PENDING_UNLOCK = {
|
||||
achievementId: "mood-first-entry",
|
||||
unlockedAt: new Date("2026-03-02T08:00:00.000Z"),
|
||||
};
|
||||
|
||||
vi.mock("@/lib/gamification/achievements-result", () => ({
|
||||
buildAchievementsResult: vi.fn(async () => ({
|
||||
summary: { unlockedCount: 1, totalCount: 1 },
|
||||
achievements: [
|
||||
{
|
||||
id: "mood-first-entry",
|
||||
category: "mood",
|
||||
unlocked: true,
|
||||
completedAt: "2026-03-02T08:00:00.000Z",
|
||||
titleKey: "achievements.moodFirst.title",
|
||||
descriptionKey: "achievements.moodFirst.description",
|
||||
icon: "Smile",
|
||||
progressPercent: 100,
|
||||
points: 10,
|
||||
target: 1,
|
||||
current: 1,
|
||||
isHidden: false,
|
||||
},
|
||||
],
|
||||
metrics: {},
|
||||
pendingUnlocks: [PENDING_UNLOCK],
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { buildAchievementsResult } from "@/lib/gamification/achievements-result";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
async function callGet() {
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/gamification/achievements"),
|
||||
);
|
||||
return { status: res.status, body: JSON.parse(await res.text()) };
|
||||
}
|
||||
|
||||
describe("GET /api/gamification/achievements", () => {
|
||||
it("has a pending unlock to notice — otherwise the case below is empty", async () => {
|
||||
await callGet();
|
||||
const result = await vi.mocked(buildAchievementsResult).mock.results[0]!
|
||||
.value;
|
||||
expect(result.pendingUnlocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("persists nothing — a GET does not write", async () => {
|
||||
const { status } = await callGet();
|
||||
expect(status).toBe(200);
|
||||
expect(prisma.userAchievement.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still renders the unlocked badge, so the payload never depended on the row", async () => {
|
||||
const { body } = await callGet();
|
||||
const achievements = (body.data as { achievements: Array<unknown> })
|
||||
.achievements as Array<{ id: string; unlocked: boolean }>;
|
||||
expect(achievements).toHaveLength(1);
|
||||
expect(achievements[0]!.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("does not leak the internal pendingUnlocks carrier onto the wire", async () => {
|
||||
const { body } = await callGet();
|
||||
expect(body.data).not.toHaveProperty("pendingUnlocks");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -62,9 +61,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// iOS-format branch runs the locale-aware transform after the cache
|
||||
// read so the cache stays format-agnostic and the achievement-progress
|
||||
// dashboard duplicate (seen twice per dashboard mount in the v1.4.33
|
||||
// HAR) coalesces into one builder call. v1.18.0 B5 — unlock persistence
|
||||
// now happens in the handler after the cache read (idempotent), not as
|
||||
// a side effect inside the cached factory.
|
||||
// HAR) coalesces into one builder call.
|
||||
// v1.18.0 B5 — resolve the per-user module map once and pass it into the
|
||||
// builder so badge categories whose owning module is disabled (sleep
|
||||
// badges when sleep is off, mood badges when mood is off) are skipped
|
||||
@@ -76,8 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// 10-minute stale window; the app-wide `AchievementUnlockNotifier` polls
|
||||
// every 2 minutes, so a hard-TTL read always missed and re-paid the cold
|
||||
// build. SWR serves the prior payload instantly and warms one background
|
||||
// recompute. Persistence of `pendingUnlocks` stays OUTSIDE this read (see
|
||||
// below), so a stale-served body never skips an unlock write.
|
||||
// recompute.
|
||||
const result = await cachedSwr(
|
||||
caches.achievements as ServerCache<AchievementsResult>,
|
||||
user.id,
|
||||
@@ -85,25 +81,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate,
|
||||
);
|
||||
|
||||
// v1.18.0 B5 — persist newly unlocked achievements OUTSIDE the cached
|
||||
// factory. `createMany({ skipDuplicates: true })` is idempotent on the
|
||||
// `(userId, achievementId)` unique, so re-running it on a cache hit is
|
||||
// a no-op rather than a duplicate, and the write is never skipped just
|
||||
// because the read was served from cache.
|
||||
if (result.pendingUnlocks.length > 0) {
|
||||
await prisma.userAchievement.createMany({
|
||||
data: result.pendingUnlocks.map((u) => ({
|
||||
userId: user.id,
|
||||
achievementId: u.achievementId,
|
||||
unlockedAt: new Date(u.unlockedAt),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "gamification.achievements" },
|
||||
meta: { newUnlocks: result.pendingUnlocks.length },
|
||||
});
|
||||
}
|
||||
// v1.35.3 — the unlock rows are NOT written here. A GET must not have side
|
||||
// effects (`api-handler.ts`, the MCP-audience note above
|
||||
// `READ_HTTP_METHODS`), and this INSERT was one. The badge grid does not
|
||||
// need the rows: `unlocked` comes from the live metrics and the completion
|
||||
// date is derived from the account's own history, so the payload below is
|
||||
// identical whether or not a row exists. The rows pin that date against a
|
||||
// later data edit and carry it into the backup, which is durable work and
|
||||
// belongs on the `achievement-unlock-sweep` job — where it also covers the
|
||||
// accounts that never open this page.
|
||||
annotate({
|
||||
action: { name: "gamification.achievements" },
|
||||
meta: { pendingUnlocks: result.pendingUnlocks.length },
|
||||
});
|
||||
|
||||
// Strip the internal `pendingUnlocks` carrier; it never goes on the wire.
|
||||
const payload = {
|
||||
|
||||
@@ -156,6 +156,7 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
|
||||
// This batch declares no trigger, so the trigger field is nulled and the
|
||||
// background timestamp is not written — see the sync-trigger suite.
|
||||
data: {
|
||||
lastSyncedAt: expect.any(Date),
|
||||
healthKitLastSyncedAt: expect.any(Date),
|
||||
healthKitLastSyncTrigger: null,
|
||||
},
|
||||
@@ -170,7 +171,12 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
// The SyncMode checkpoint still advances — a MANUAL push is a client
|
||||
// sync — but none of the HealthKit-specific fields are touched.
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { lastSyncedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a hard reconciliation failure without checkpointing it", async () => {
|
||||
|
||||
@@ -643,9 +643,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
});
|
||||
|
||||
// This batch route is the native HealthKit ingestion boundary. Stamp only
|
||||
// after every attempted write has reached a durable, non-failed verdict;
|
||||
// MANUAL-only and skipped-only batches are not HealthKit syncs.
|
||||
// This batch route is the native ingestion boundary, so it is also where the
|
||||
// SyncMode checkpoint (`User.lastSyncedAt`, the same column
|
||||
// `markSyncCheckpoint` writes) is established — `GET /api/sync/state` only
|
||||
// reports it. The checkpoint covers MANUAL pushes too: the client synced
|
||||
// whatever it was holding. A batch carrying a hard failure leaves the
|
||||
// checkpoint where it was, so the undelivered window stays inside whatever
|
||||
// the client asks for next.
|
||||
//
|
||||
// The HealthKit fields ride the same update and keep their narrower
|
||||
// condition. Stamp only after every attempted write has reached a durable,
|
||||
// non-failed verdict; MANUAL-only and skipped-only batches are not
|
||||
// HealthKit syncs.
|
||||
//
|
||||
// The trigger the client declared is stamped alongside, and a background- or
|
||||
// push-triggered batch additionally dates `healthKitLastBackgroundSyncAt`.
|
||||
@@ -655,17 +664,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// trigger writes null over the previous value rather than leaving a stale one
|
||||
// standing: the field describes THIS sync, and an older build's silence is
|
||||
// not evidence about it.
|
||||
if (healthKitSyncSucceeded) {
|
||||
if (failedCount === 0) {
|
||||
const syncedAt = new Date();
|
||||
const deliveredWithoutTheApp =
|
||||
syncTrigger === "background" || syncTrigger === "push";
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
healthKitLastSyncedAt: syncedAt,
|
||||
healthKitLastSyncTrigger: syncTrigger ?? null,
|
||||
...(deliveredWithoutTheApp
|
||||
? { healthKitLastBackgroundSyncAt: syncedAt }
|
||||
lastSyncedAt: syncedAt,
|
||||
...(healthKitSyncSucceeded
|
||||
? {
|
||||
healthKitLastSyncedAt: syncedAt,
|
||||
healthKitLastSyncTrigger: syncTrigger ?? null,
|
||||
...(deliveredWithoutTheApp
|
||||
? { healthKitLastBackgroundSyncAt: syncedAt }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ vi.mock("@/lib/db", () => ({
|
||||
// v1.15.19 — resolver-null convergence probe before standalone insert.
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
// The bulk drain stamps the SyncMode checkpoint (`User.lastSyncedAt`) —
|
||||
// it is a client push, and the push is what the checkpoint describes.
|
||||
user: { update: vi.fn().mockResolvedValue({}) },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -1486,3 +1489,49 @@ describe("POST /api/medications/intake/bulk — identity stability floor", () =>
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// SyncMode checkpoint — the push owns `User.lastSyncedAt`
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* `GET /api/sync/state` reports the checkpoint and writes nothing, so the
|
||||
* ingest boundaries are the only writers. If this one stops stamping, the
|
||||
* checkpoint stays null forever and every paired client re-pairs full.
|
||||
*/
|
||||
describe("POST /api/medications/intake/bulk — sync checkpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1" },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
|
||||
id: "row-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("stamps lastSyncedAt when the drain lands", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:00.000Z",
|
||||
takenAt: "2026-06-15T05:02:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { lastSyncedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
@@ -932,6 +933,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
})();
|
||||
}
|
||||
|
||||
// The SyncMode checkpoint belongs to the push that moved the data, not to
|
||||
// the `/api/sync/state` handshake that reports it. This endpoint is one of
|
||||
// the three domains that handshake summarises, so an intake drain advances
|
||||
// it.
|
||||
await markSyncCheckpoint(user.id);
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
|
||||
@@ -29,6 +29,9 @@ const { moodEntryMock, txClient } = vi.hoisted(() => {
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: moodEntryMock,
|
||||
// The bulk drain stamps the SyncMode checkpoint (`User.lastSyncedAt`) —
|
||||
// it is a client push, and the push is what the checkpoint describes.
|
||||
user: { update: vi.fn().mockResolvedValue({}) },
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
@@ -688,3 +691,32 @@ describe("POST /api/mood-entries/bulk — upsert + tag links are atomic", () =>
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The SyncMode checkpoint (`User.lastSyncedAt`) belongs to the push that moved
|
||||
* the data. `GET /api/sync/state` reports it and writes nothing, so if this
|
||||
* boundary stops stamping, the checkpoint stays null forever and every paired
|
||||
* client re-pairs with a full backfill.
|
||||
*/
|
||||
describe("POST /api/mood-entries/bulk — sync checkpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
|
||||
id: "entry-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("stamps lastSyncedAt when the drain lands", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { lastSyncedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
|
||||
import {
|
||||
getScoreForMood,
|
||||
moodLevelEnum,
|
||||
@@ -493,6 +494,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// The SyncMode checkpoint belongs to the push that moved the data, not to
|
||||
// the `/api/sync/state` handshake that reports it. This endpoint is one of
|
||||
// the three domains that handshake summarises, so a mood drain advances it.
|
||||
await markSyncCheckpoint(user.id);
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* `GET /api/sync/state` is a read.
|
||||
*
|
||||
* It used to bump `User.lastSyncedAt` on every call, which is the exact
|
||||
* side-effecting GET the MCP-audience note in `api-handler.ts` warns against,
|
||||
* and it advanced the checkpoint past a window the client had not drained.
|
||||
* The checkpoint is now owned by the ingest boundaries; this route reports it.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findFirst: vi.fn(), count: vi.fn() },
|
||||
moodEntry: { findFirst: vi.fn(), count: vi.fn() },
|
||||
medicationIntakeEvent: { findFirst: vi.fn(), count: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const CHECKPOINT = new Date("2026-07-30T08:15:00.000Z");
|
||||
|
||||
type SyncStateBody = {
|
||||
lastSyncedAt: string | null;
|
||||
serverNow: string;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
async function callGet(): Promise<SyncStateBody> {
|
||||
const res = (await (
|
||||
GET as unknown as (req: unknown) => Promise<{ data: SyncStateBody }>
|
||||
)({})) as { data: SyncStateBody };
|
||||
return res.data;
|
||||
}
|
||||
|
||||
describe("GET /api/sync/state", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
lastSyncedAt: CHECKPOINT,
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("writes nothing — the handshake never touches the user row", async () => {
|
||||
await callGet();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports the stored checkpoint rather than the previous handshake", async () => {
|
||||
const body = await callGet();
|
||||
expect(body.lastSyncedAt).toBe(CHECKPOINT.toISOString());
|
||||
});
|
||||
|
||||
it("keeps reporting the same checkpoint across repeated calls", async () => {
|
||||
const first = await callGet();
|
||||
const second = await callGet();
|
||||
expect(second.lastSyncedAt).toBe(first.lastSyncedAt);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a never-synced account as null so the client re-pairs full", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
lastSyncedAt: null,
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
const body = await callGet();
|
||||
expect(body.lastSyncedAt).toBeNull();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,9 @@
|
||||
* {
|
||||
* userId,
|
||||
* timezone,
|
||||
* lastSyncedAt, — User.lastSyncedAt (ISO string or null)
|
||||
* lastSyncedAt, — User.lastSyncedAt (ISO string or null).
|
||||
* Set by the ingest boundaries, not here:
|
||||
* this route is a read and writes nothing.
|
||||
* serverNow, — `new Date().toISOString()` for clock skew
|
||||
* measurements: { lastUpdatedAt, liveCount, tombstonedCount },
|
||||
* mood: { lastUpdatedAt, liveCount, tombstonedCount },
|
||||
@@ -45,10 +47,10 @@ import {
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-domain aggregate reads + one user-side bump. Each runs against
|
||||
// indexed columns (userId, deleted_at / updated_at) so it stays
|
||||
// O(rows-for-user) and not O(rows-across-tenants). v1.7.0 adds the
|
||||
// mood + intake blocks now that both carry the sync columns.
|
||||
// Per-domain aggregate reads. Each runs against indexed columns (userId,
|
||||
// deleted_at / updated_at) so it stays O(rows-for-user) and not
|
||||
// O(rows-across-tenants). v1.7.0 adds the mood + intake blocks now that
|
||||
// both carry the sync columns.
|
||||
const [
|
||||
measurementLatest,
|
||||
measurementLive,
|
||||
@@ -96,16 +98,14 @@ export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
}),
|
||||
]);
|
||||
|
||||
// The handshake also bumps the user's lastSyncedAt — the call IS
|
||||
// the handshake. iOS reads the OLD lastSyncedAt from the response
|
||||
// and then trusts that subsequent server writes after the new
|
||||
// checkpoint will round-trip via the standard read paths.
|
||||
const previous = currentUser?.lastSyncedAt ?? null;
|
||||
const nextCheckpoint = new Date();
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastSyncedAt: nextCheckpoint },
|
||||
});
|
||||
// The handshake reports the checkpoint; it does not set it. The column is
|
||||
// stamped by the ingest boundaries that actually move data (see
|
||||
// `markSyncCheckpoint`), so `lastSyncedAt` answers "when did this account
|
||||
// last push a batch" rather than "when did a client last ask". NULL still
|
||||
// means never synced, and the value can now only trail the truth — the
|
||||
// handshake used to advance it past a window the client had not drained.
|
||||
const lastSyncedAt = currentUser?.lastSyncedAt ?? null;
|
||||
const serverNow = new Date();
|
||||
|
||||
annotate({
|
||||
action: { name: "sync.state" },
|
||||
@@ -114,15 +114,15 @@ export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
tombstonedCount: measurementTomb,
|
||||
moodLiveCount: moodLive,
|
||||
intakeLiveCount: intakeLive,
|
||||
lastSyncedAtBefore: previous?.toISOString() ?? null,
|
||||
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
userId: user.id,
|
||||
timezone: currentUser?.timezone ?? "Europe/Berlin",
|
||||
lastSyncedAt: previous?.toISOString() ?? null,
|
||||
serverNow: nextCheckpoint.toISOString(),
|
||||
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
|
||||
serverNow: serverNow.toISOString(),
|
||||
measurements: {
|
||||
lastUpdatedAt: measurementLatest?.updatedAt?.toISOString() ?? null,
|
||||
liveCount: measurementLive,
|
||||
|
||||
Reference in new issue
Block a user