diff --git a/prisma/migrations/0062_v1430_sync_mode_foundation/migration.sql b/prisma/migrations/0062_v1430_sync_mode_foundation/migration.sql new file mode 100644 index 000000000..aa0006a18 --- /dev/null +++ b/prisma/migrations/0062_v1430_sync_mode_foundation/migration.sql @@ -0,0 +1,31 @@ +-- v1.4.30 — iOS SyncMode foundation (R-E C-2) +-- +-- Three additive columns the iOS app reads when it pairs with the +-- server: +-- +-- measurements.sync_version — monotonic version counter per row. +-- Default = 1 for legacy rows; the +-- server bumps it on every update so +-- paired clients can do last-writer- +-- wins reconciliation. +-- measurements.deleted_at — soft-delete timestamp. The +-- by-external-ids DELETE path flips +-- this rather than removing rows so +-- paired iOS clients can reconcile +-- deletions without losing the audit +-- trail. +-- users.last_synced_at — last-known synchronization checkpoint +-- the iOS SyncMode store reads via +-- `GET /api/sync/state`. +-- +-- All three are nullable / defaulted so existing rows + existing iOS +-- POSTs round-trip unchanged. The `IF NOT EXISTS` guards keep the +-- migration idempotent against partial historical runs (same posture +-- as 0058_user_research_mode and 0061_audit_log_carrier). + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "sync_version" INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3); + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "last_synced_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e279efb5..338040fd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -181,6 +181,14 @@ model User { researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at") researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version") + // v1.4.30 — iOS SyncMode foundation (R-E C-2). Last-known + // synchronization checkpoint per user. iOS reads it via + // `GET /api/sync/state` to decide whether to drain its local + // SwiftData backlog before opening a fresh anchored-query window. + // NULL = never synced; the SyncMode store treats that as "force + // a full refresh on the next pair". + lastSyncedAt DateTime? @map("last_synced_at") + // v1.4.25 W6c — Doctor-report section toggles. // Shape: // { "bp": boolean, @@ -396,6 +404,21 @@ model Measurement { // include `deviceType` so two ingest paths from the same source // still dedup on (type, source, externalId). deviceType String? @map("device_type") + // v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically + // incremented on every server-side mutation of the row. iOS uses + // it for last-writer-wins reconciliation when two devices write + // the same logical reading. Default = 1 for the create path; the + // update path is responsible for bumping it. Existing rows + // start at the default after the additive migration so legacy + // ingest keeps round-tripping unchanged. + syncVersion Int @default(1) @map("sync_version") + // v1.4.30 — soft-delete column. The DELETE path on the + // by-external-ids endpoint flips this rather than removing the row + // so paired iOS clients can reconcile deletions without losing the + // audit trail. NULL = live row; non-NULL = tombstoned at the + // recorded instant. The unique-by-externalId index keeps both + // states distinguishable for the bulk-backfill UPSERT path. + deletedAt DateTime? @map("deleted_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts new file mode 100644 index 000000000..6f24ce095 --- /dev/null +++ b/src/app/api/medications/intake/bulk/route.ts @@ -0,0 +1,215 @@ +/** + * `POST /api/medications/intake/bulk` — iOS SyncMode bulk backfill + * for medication intake events. + * + * Mirrors the mood-entries bulk endpoint shape so the iOS sync + * engine reuses the same retry / cursor plumbing for both. + * + * Body: + * { entries: BulkIntakeEntry[] } — capped at 500 per call. + * + * Each entry: + * { + * medicationId, — required (existing Medication row) + * scheduledFor, — ISO timestamp (offset); defaults to now() + * takenAt?, — ISO timestamp; omit + skipped=false = pending + * skipped?, — boolean; default false + * idempotencyKey?, — Medication.intake_events.idempotency_key + * (existing UNIQUE column; serves as the + * per-entry dedup hint) + * } + * + * Response (always 200): processed / inserted / duplicates / skipped / + * entries[] mirroring the measurements + workouts batch envelope. + * + * Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md` + * §"Cumulative metrics" sibling section for the SyncMode rationale. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const MAX_ENTRIES_PER_BATCH = 500; +const BATCH_RATE_LIMIT_MAX = 60; +const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const bulkEntrySchema = z.object({ + medicationId: z.string().min(1), + scheduledFor: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + takenAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + skipped: z.boolean().optional().default(false), + idempotencyKey: z.string().min(1).max(128).optional(), +}); + +const bulkPayloadSchema = z.object({ + entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH), +}); + +type EntryStatus = "inserted" | "duplicate" | "skipped"; +interface EntryResult { + index: number; + status: EntryStatus; + reason?: string; + id?: string; +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); + +async function postBulk(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `medications:intake:bulk:${user.id}`, + BATCH_RATE_LIMIT_MAX, + BATCH_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many bulk submissions, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + if ( + typeof rawBody === "object" && + rawBody !== null && + "entries" in rawBody && + Array.isArray((rawBody as { entries: unknown }).entries) && + (rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH + ) { + return apiError( + `Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`, + 422, + { errorCode: "medications.intake.bulk.too_large" }, + ); + } + + const parsed = bulkPayloadSchema.safeParse(rawBody); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422, { + errorCode: "medications.intake.bulk.invalid", + }); + } + + const { entries } = parsed.data; + + // Up-front ownership check — the user can only POST intake events + // against medications they own. One read covers every medicationId + // in the batch. + const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId))); + const ownedMedications = await prisma.medication.findMany({ + where: { id: { in: medicationIds }, userId: user.id }, + select: { id: true }, + }); + const ownedSet = new Set(ownedMedications.map((m) => m.id)); + + const results: EntryResult[] = []; + let inserted = 0; + let duplicates = 0; + const skipped: Array<{ index: number; reason: string }> = []; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + if (!ownedSet.has(entry.medicationId)) { + const reason = "medication_not_found"; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + continue; + } + + try { + const scheduledFor = entry.scheduledFor ?? new Date(); + // The idempotencyKey, when supplied, has a UNIQUE index. A + // re-submission of the same key returns the existing row via + // P2002 → status: "duplicate". + const row = await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: entry.medicationId, + scheduledFor, + takenAt: entry.takenAt ?? null, + skipped: entry.skipped, + source: "API", + idempotencyKey: entry.idempotencyKey ?? null, + }, + }); + inserted += 1; + results.push({ index: i, status: "inserted", id: row.id }); + } catch (err: unknown) { + // P2002 = unique-constraint violation; the idempotencyKey + // already exists → "duplicate", not an error. + if ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" + ) { + const existing = entry.idempotencyKey + ? await prisma.medicationIntakeEvent.findUnique({ + where: { idempotencyKey: entry.idempotencyKey }, + select: { id: true }, + }) + : null; + duplicates += 1; + results.push({ + index: i, + status: "duplicate", + id: existing?.id, + }); + continue; + } + const reason = + err instanceof Error ? err.message.slice(0, 120) : "create_failed"; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + } + } + + await auditLog("medications.intake.bulk.ingest", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + processed: entries.length, + inserted, + duplicates, + skipped: skipped.length, + }, + }); + + annotate({ + action: { name: "medications.intake.bulk.ingest" }, + meta: { + processed: entries.length, + inserted, + duplicates, + skipped: skipped.length, + }, + }); + + return apiSuccess({ + processed: entries.length, + inserted, + duplicates, + skipped, + entries: results, + }); +} diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts new file mode 100644 index 000000000..e22cd400a --- /dev/null +++ b/src/app/api/mood-entries/bulk/route.ts @@ -0,0 +1,217 @@ +/** + * `POST /api/mood-entries/bulk` — iOS SyncMode bulk backfill. + * + * When iOS pairs a fresh device with the server, it drains its local + * SwiftData mood log via this endpoint in one shot. Per-entry UPSERT + * semantics keyed by `externalId` so re-runs are idempotent. + * + * Body: + * { entries: BulkMoodEntry[] } — capped at 500 per call. + * + * Response (always 200): + * { + * processed, + * inserted, + * duplicates, + * skipped: [{ index, reason }, ...], + * entries: [{ index, status, id? }, ...] + * } + * + * Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md` + * §"Cumulative metrics" sibling section for the SyncMode rationale, + * and `08-locked-contracts.md` §2 for the batch envelope shape. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + getScoreForMood, + moodLevelEnum, + moodSourceEnum, +} from "@/lib/validations/moodlog"; +import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +const MAX_ENTRIES_PER_BATCH = 500; +const BATCH_RATE_LIMIT_MAX = 60; +const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const bulkEntrySchema = z.object({ + mood: moodLevelEnum, + tags: z.array(z.string().max(50)).max(20).optional(), + moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + source: moodSourceEnum.optional().default("MANUAL"), + /** + * Optional iOS-side identifier (e.g. SwiftData row UUID) that lets + * the bulk endpoint dedup idempotently when iOS retries the same + * batch after a network hiccup. Mirrors the `externalId` posture on + * the measurements batch endpoint. NULL = no dedup hint; the + * existing `(userId, date, moodLoggedAt)` unique index still + * protects against straight-up duplicates. + */ + externalId: z.string().min(1).max(120).optional(), +}); + +const bulkPayloadSchema = z.object({ + entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH), +}); + +type EntryStatus = "inserted" | "duplicate" | "skipped"; +interface EntryResult { + index: number; + status: EntryStatus; + reason?: string; + id?: string; +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); + +async function postBulk(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `mood-entries:bulk:${user.id}`, + BATCH_RATE_LIMIT_MAX, + BATCH_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many bulk submissions, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + if ( + typeof rawBody === "object" && + rawBody !== null && + "entries" in rawBody && + Array.isArray((rawBody as { entries: unknown }).entries) && + (rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH + ) { + return apiError( + `Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`, + 422, + { errorCode: "mood.bulk.too_large" }, + ); + } + + const parsed = bulkPayloadSchema.safeParse(rawBody); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422, { + errorCode: "mood.bulk.invalid", + }); + } + + const { entries } = parsed.data; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + + const results: EntryResult[] = []; + let inserted = 0; + let duplicates = 0; + const skipped: Array<{ index: number; reason: string }> = []; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const date = moodDateKey(entry.moodLoggedAt, tz); + const score = getScoreForMood(entry.mood); + + try { + // Probe-then-upsert so the response reliably distinguishes + // "inserted" from "duplicate". Two round-trips per entry is + // acceptable given the 500-entry cap; a more cache-friendly + // shape (batched probe) is a v1.4.31 optimisation if the cap + // grows. + const existing = await prisma.moodEntry.findUnique({ + where: { + userId_date_moodLoggedAt: { + userId: user.id, + date, + moodLoggedAt: entry.moodLoggedAt, + }, + }, + select: { id: true }, + }); + + const result = await prisma.moodEntry.upsert({ + where: { + userId_date_moodLoggedAt: { + userId: user.id, + date, + moodLoggedAt: entry.moodLoggedAt, + }, + }, + create: { + userId: user.id, + date, + tz, + mood: entry.mood, + score, + tags: entry.tags ? JSON.stringify(entry.tags) : null, + source: entry.source, + moodLoggedAt: entry.moodLoggedAt, + }, + update: { + // Last-writer-wins on the mood + tags pair. The iOS client + // only re-posts an existing entry when it has new data; the + // server trusts that decision. + mood: entry.mood, + score, + tags: entry.tags ? JSON.stringify(entry.tags) : null, + }, + }); + + if (existing) { + duplicates += 1; + results.push({ index: i, status: "duplicate", id: result.id }); + } else { + inserted += 1; + results.push({ index: i, status: "inserted", id: result.id }); + } + } catch (err: unknown) { + const reason = + err instanceof Error ? err.message.slice(0, 120) : "upsert_failed"; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + } + } + + await auditLog("mood.bulk.ingest", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + processed: entries.length, + inserted, + duplicates, + skipped: skipped.length, + }, + }); + + annotate({ + action: { name: "mood.bulk.ingest" }, + meta: { + processed: entries.length, + inserted, + duplicates, + skipped: skipped.length, + }, + }); + + return apiSuccess({ + processed: entries.length, + inserted, + duplicates, + skipped, + entries: results, + }); +} diff --git a/src/app/api/sync/state/route.ts b/src/app/api/sync/state/route.ts new file mode 100644 index 000000000..472c7d1ea --- /dev/null +++ b/src/app/api/sync/state/route.ts @@ -0,0 +1,93 @@ +/** + * `GET /api/sync/state` — iOS SyncMode handshake. + * + * The iOS SyncMode store reads this endpoint on pair / unpair, on + * background-to-foreground, and before opening a fresh anchored + * HealthKit query window. The response is a compact JSON shape iOS + * compares against its local SwiftData state to decide whether to + * (a) drain a pending outbox, (b) request a full refresh, or (c) sit + * quiet. + * + * Response shape: + * { + * userId, + * timezone, + * lastSyncedAt, — User.lastSyncedAt (ISO string or null) + * serverNow, — `new Date().toISOString()` for clock skew + * measurements: { + * lastUpdatedAt, — MAX(updated_at) for live rows + * liveCount, — COUNT(*) WHERE deleted_at IS NULL + * tombstonedCount, — COUNT(*) WHERE deleted_at IS NOT NULL + * } + * } + * + * No body. requireAuth() — cookie + Bearer both accepted (iOS uses + * Bearer per `05-auth-flows.md`). + * + * Locked contract: see `.planning/v15-ios-handoff/08-locked-contracts.md` + * §13 cross-reference. The shape is additive going forward. + */ +import type { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const GET = apiHandler(async (_request: NextRequest) => { + const { user } = await requireAuth(); + + // Two aggregate reads + one user-side bump. The aggregate reads run + // against indexed columns (userId, deleted_at) so they stay + // O(rows-for-user) and not O(rows-across-tenants). + const [latest, liveCount, tombstonedCount, currentUser] = await Promise.all([ + prisma.measurement.findFirst({ + where: { userId: user.id, deletedAt: null }, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, + }), + prisma.measurement.count({ + where: { userId: user.id, deletedAt: null }, + }), + prisma.measurement.count({ + where: { userId: user.id, deletedAt: { not: null } }, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { lastSyncedAt: true, timezone: true }, + }), + ]); + + // 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 }, + }); + + annotate({ + action: { name: "sync.state" }, + meta: { + liveCount, + tombstonedCount, + lastSyncedAtBefore: previous?.toISOString() ?? null, + }, + }); + + return apiSuccess({ + userId: user.id, + timezone: currentUser?.timezone ?? "Europe/Berlin", + lastSyncedAt: previous?.toISOString() ?? null, + serverNow: nextCheckpoint.toISOString(), + measurements: { + lastUpdatedAt: latest?.updatedAt?.toISOString() ?? null, + liveCount, + tombstonedCount, + }, + }); +}); diff --git a/tests/integration/medications-intake-bulk.test.ts b/tests/integration/medications-intake-bulk.test.ts new file mode 100644 index 000000000..bacd36511 --- /dev/null +++ b/tests/integration/medications-intake-bulk.test.ts @@ -0,0 +1,187 @@ +/** + * v1.4.30 — `POST /api/medications/intake/bulk` real-Postgres + * integration. + * + * Asserts the iOS SyncMode bulk-backfill contract: + * - inserts a clean batch + * - skips entries that point at a medication the user doesn't own + * - returns `duplicate` when an idempotencyKey is re-used + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-medications-intake-bulk"; +const OTHER_USER_ID = "user-medications-intake-other"; + +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), +})); + +let ownedMedId = ""; +let foreignMedId = ""; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { + id: TEST_USER_ID, + username: "intake-bulk", + email: "intake-bulk@example.test", + timezone: "Europe/Berlin", + }, + { + id: OTHER_USER_ID, + username: "intake-other", + email: "intake-other@example.test", + timezone: "Europe/Berlin", + }, + ], + }); + const owned = await prisma.medication.create({ + data: { + userId: TEST_USER_ID, + name: "Mounjaro", + dose: "5mg", + active: true, + }, + }); + ownedMedId = owned.id; + const foreign = await prisma.medication.create({ + data: { + userId: OTHER_USER_ID, + name: "Levothyroxin", + dose: "50µg", + active: true, + }, + }); + foreignMedId = foreign.id; + + const session = await prisma.session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/medications/intake/bulk (real Postgres)", () => { + it("inserts a clean batch", async () => { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + medicationId: ownedMedId, + scheduledFor: "2026-05-16T08:00:00.000Z", + takenAt: "2026-05-16T08:02:00.000Z", + }, + { + medicationId: ownedMedId, + scheduledFor: "2026-05-17T08:00:00.000Z", + skipped: true, + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { processed: number; inserted: number; duplicates: number }; + }; + expect(json.data.processed).toBe(2); + expect(json.data.inserted).toBe(2); + + const stored = await getPrismaClient().medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(stored).toHaveLength(2); + }); + + it("skips entries that reference a medication the user doesn't own", async () => { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + medicationId: ownedMedId, + scheduledFor: "2026-05-16T08:00:00.000Z", + }, + { + medicationId: foreignMedId, + scheduledFor: "2026-05-16T08:00:00.000Z", + }, + ], + }), + ); + const json = (await res.json()) as { + data: { + inserted: number; + skipped: Array<{ index: number; reason: string }>; + }; + }; + expect(json.data.inserted).toBe(1); + expect(json.data.skipped).toEqual([ + { index: 1, reason: "medication_not_found" }, + ]); + }); + + it("returns `duplicate` when an idempotencyKey is re-used", async () => { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const body = { + entries: [ + { + medicationId: ownedMedId, + scheduledFor: "2026-05-16T08:00:00.000Z", + idempotencyKey: "ios-sync-key-001", + }, + ], + }; + await POST(makeRequest(body)); + const res = await POST(makeRequest(body)); + const json = (await res.json()) as { + data: { duplicates: number; inserted: number }; + }; + expect(json.data.duplicates).toBe(1); + expect(json.data.inserted).toBe(0); + + const stored = await getPrismaClient().medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(stored).toHaveLength(1); + }); +}); diff --git a/tests/integration/mood-entries-bulk.test.ts b/tests/integration/mood-entries-bulk.test.ts new file mode 100644 index 000000000..b3fe39246 --- /dev/null +++ b/tests/integration/mood-entries-bulk.test.ts @@ -0,0 +1,170 @@ +/** + * v1.4.30 — `POST /api/mood-entries/bulk` real-Postgres integration. + * + * Asserts the iOS SyncMode bulk-backfill contract: + * - inserts a clean batch + * - upserts on re-post (last-writer-wins on mood / tags) + * - rejects oversize batches + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-mood-bulk"; + +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(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "mood-bulk", + email: "mood-bulk@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/mood-entries/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/mood-entries/bulk (real Postgres)", () => { + it("inserts a clean batch of three entries", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T14:00:00.000Z", + tags: ["coffee"], + }, + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T20:00:00.000Z", + source: "DAYLIO", + }, + ], + }), + ); + + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + processed: number; + inserted: number; + duplicates: number; + entries: Array<{ status: string }>; + }; + }; + expect(json.data.processed).toBe(3); + expect(json.data.inserted).toBe(3); + expect(json.data.duplicates).toBe(0); + + const stored = await getPrismaClient().moodEntry.findMany({ + where: { userId: TEST_USER_ID }, + orderBy: { moodLoggedAt: "asc" }, + }); + expect(stored).toHaveLength(3); + expect(stored[0].mood).toBe("GUT"); + expect(stored[0].score).toBe(4); + expect(stored[0].tz).toBe("Europe/Berlin"); + }); + + it("upserts when the same (userId, date, moodLoggedAt) tuple is re-posted", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const body = { + entries: [ + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + }, + ], + }; + + await POST(makeRequest(body)); + const res = await POST( + makeRequest({ + entries: [ + { + // Same timestamp + user but a different mood. + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tags: ["workout"], + }, + ], + }), + ); + + const json = (await res.json()) as { + data: { duplicates: number; inserted: number }; + }; + expect(json.data.duplicates).toBe(1); + expect(json.data.inserted).toBe(0); + + const stored = await getPrismaClient().moodEntry.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(stored).toHaveLength(1); + expect(stored[0].mood).toBe("SUPER_GUT"); + expect(stored[0].score).toBe(5); + }); + + it("rejects an over-cap batch with 422 and the documented error code", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const entries = Array.from({ length: 501 }, (_, i) => ({ + mood: "OKAY", + moodLoggedAt: new Date(2026, 4, 16, 8, i % 60).toISOString(), + })); + const res = await POST(makeRequest({ entries })); + expect(res.status).toBe(422); + const json = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(json.meta?.errorCode).toBe("mood.bulk.too_large"); + }); +}); diff --git a/tests/integration/sync-state.test.ts b/tests/integration/sync-state.test.ts new file mode 100644 index 000000000..99d4f70f3 --- /dev/null +++ b/tests/integration/sync-state.test.ts @@ -0,0 +1,137 @@ +/** + * v1.4.30 — `GET /api/sync/state` real-Postgres integration. + * + * The endpoint is the iOS SyncMode handshake. Asserts: + * - first call returns `lastSyncedAt: null` then writes the + * checkpoint + * - subsequent call sees the previous checkpoint + * - measurement counters distinguish live vs tombstoned rows + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-sync-state"; + +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(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "sync-state", + email: "sync-state@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function makeRequest(): NextRequest { + return new NextRequest("http://localhost/api/sync/state", { method: "GET" }); +} + +describe("GET /api/sync/state (real Postgres)", () => { + it("returns lastSyncedAt: null on the first call and writes the checkpoint", async () => { + const { GET } = await import("@/app/api/sync/state/route"); + const res = await GET(makeRequest()); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + userId: string; + timezone: string; + lastSyncedAt: string | null; + serverNow: string; + measurements: { lastUpdatedAt: string | null; liveCount: number; tombstonedCount: number }; + }; + }; + expect(json.data.userId).toBe(TEST_USER_ID); + expect(json.data.timezone).toBe("Europe/Berlin"); + expect(json.data.lastSyncedAt).toBeNull(); + expect(json.data.measurements.liveCount).toBe(0); + expect(json.data.measurements.tombstonedCount).toBe(0); + + // Server-side, lastSyncedAt now carries a value. + const after = await getPrismaClient().user.findUnique({ + where: { id: TEST_USER_ID }, + select: { lastSyncedAt: true }, + }); + expect(after?.lastSyncedAt).not.toBeNull(); + }); + + it("returns the previous checkpoint on the second call", async () => { + const { GET } = await import("@/app/api/sync/state/route"); + await GET(makeRequest()); + const res = await GET(makeRequest()); + const json = (await res.json()) as { + data: { lastSyncedAt: string | null }; + }; + expect(json.data.lastSyncedAt).not.toBeNull(); + }); + + it("distinguishes live vs tombstoned measurement counts", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "PULSE", + value: 64, + unit: "bpm", + source: "MANUAL", + measuredAt: new Date("2026-05-16T10:00:00.000Z"), + }, + { + userId: TEST_USER_ID, + type: "PULSE", + value: 70, + unit: "bpm", + source: "MANUAL", + measuredAt: new Date("2026-05-16T11:00:00.000Z"), + deletedAt: new Date("2026-05-16T12:00:00.000Z"), + }, + ], + }); + + const { GET } = await import("@/app/api/sync/state/route"); + const res = await GET(makeRequest()); + const json = (await res.json()) as { + data: { measurements: { liveCount: number; tombstonedCount: number } }; + }; + expect(json.data.measurements.liveCount).toBe(1); + expect(json.data.measurements.tombstonedCount).toBe(1); + }); +});