diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index d90b43bab..74a19557b 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -21,6 +21,9 @@ vi.mock("@/lib/db", () => ({ workoutRoute: { createMany: vi.fn(), }, + user: { + findUnique: vi.fn(), + }, $transaction: vi.fn(async (fn: unknown) => { // Same shape as the real Prisma client — invoke the supplied // callback with the mocked client itself so `tx.workout.createMany` @@ -120,6 +123,12 @@ beforeEach(() => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 }); vi.mocked(prisma.workout.findFirst).mockResolvedValue(null); vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 }); + // v1.4.43 W9 — default to no per-user source-priority override so + // the write-time picker walks the canonical default ladder. Tests + // that exercise a custom ladder override this in-line. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); }); describe("POST /api/workouts/batch — auth + size + rate-limit", () => { @@ -377,6 +386,69 @@ describe("POST /api/workouts/batch — nested route attachment", () => { }); }); +describe("POST /api/workouts/batch — user source-priority (v1.4.43 W9)", () => { + it("looks up the user's source-priority blob exactly once per batch", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-prio-1"), + validWorkout("uuid-prio-2", { + startedAt: "2026-05-14T08:30:00.000Z", + endedAt: "2026-05-14T09:15:00.000Z", + }), + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user-1" }, + select: { sourcePriorityJson: true }, + }); + }); + + it("threads a custom MANUAL>APPLE_HEALTH ladder so the user's preferred row survives the write-time dedup", async () => { + // User has flipped the default in Settings. Two rows for the same + // logical run inside the 90 s write-time window — MANUAL must + // win because the user told us so. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: { + steps: ["MANUAL", "APPLE_HEALTH", "WITHINGS"], + }, + } as never); + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-apple", { source: "APPLE_HEALTH" }), + validWorkout("uuid-manual", { + source: "MANUAL", + startedAt: "2026-05-14T06:31:00.000Z", + endedAt: "2026-05-14T07:16:00.000Z", + }), + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inserted: number; + duplicates: number; + entries: Array<{ index: number; status: string }>; + }; + }; + // One survivor (MANUAL), one dropped twin (APPLE_HEALTH). + expect(body.data.inserted).toBe(1); + expect(body.data.duplicates).toBe(1); + // The Apple row at index 0 is the duplicate; the Manual row at + // index 1 is the survivor. + expect(body.data.entries[0]?.status).toBe("duplicate"); + expect(body.data.entries[1]?.status).toBe("inserted"); + }); +}); + describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", () => { it("enqueues PR detection after a successful batch with silent=false", async () => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 3fe1a47c6..29d13dea4 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -119,6 +119,19 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch)); async function postBatch(request: NextRequest): Promise { const { user } = await requireAuth(); + // v1.4.43 W9 — resolve the per-user source-priority blob once at + // the start of the handler so the write-time canonical-row picker + // walks the SAME ladder the read-time picker walks. Without this + // lookup a user who customised Settings → Sources (e.g. MANUAL > + // APPLE_HEALTH) would see their preferred row dropped at write- + // time before the read-time picker ever ran. One indexed + // findUnique per batch — well under the cost of letting the + // duplicate persist and carrying it forward on every read. + const userPriority = await prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }); + // Content-Length pre-flight. The body cap exists to bound the // request-pipeline cost BEFORE Node has to read the full payload — // a misbehaving client shipping a 50 MB body shouldn't tie up the @@ -287,6 +300,7 @@ async function postBatch(request: NextRequest): Promise { // falls through to input order via the `index` field below. index: p.index, })), + userPriority?.sourcePriorityJson ?? null, ); const survivingIndices = new Set(canonicalPrepared.map((r) => r.index ?? -1)); const droppedByWriteDedup: number[] = [];