feat(workouts): wire batch route through user source priority

The batch handler now looks up `User.sourcePriorityJson` once at the
start of the request and threads it into the write-time canonical-row
picker. A user with a customised Settings → Sources ladder keeps
their preferred row across the ingest pipeline; the route picks up
the v1.4.43 helper's user-aware path with one indexed findUnique per
batch.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 21:46:12 +02:00
1 parent d1be9f0177
commit 0fbe68a313
2 files changed
+86

No files matched your search

@@ -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 });
+14
View File
@@ -119,6 +119,19 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
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<Response> {
// 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[] = [];