From eac40e79d6a6ffda811e0bf5f9892d2dc34277c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 09:58:50 +0200 Subject: [PATCH] fix(api): a bad dimension skips its own bulk entry, as the contract said The doc comment beside `a1`..`a5` promised that an out-of-range value marks THAT entry skipped and never the batch, while the fields sat in the batch schema and answered 422 for all five hundred. The words were right and the code was not, and a phone draining a year of local history lost the year to one row. The five now parse per entry, the same way the day context does; a bad row reports `invalid_dimensions` and the rest of the batch lands. The split buys the tolerance and costs a second copy of "0 to 10", so a guard offers the same value to both schemas and makes them agree at both ends of the scale and one step outside each. --- .../__tests__/bulk-dimension-bounds.test.ts | 92 +++++++++++++++++++ src/app/api/mood-entries/bulk/route.ts | 67 +++++++++++--- tests/integration/mood-context.test.ts | 49 ++++++++++ 3 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts diff --git a/src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts b/src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts new file mode 100644 index 000000000..0cc7bb6d7 --- /dev/null +++ b/src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts @@ -0,0 +1,92 @@ +/** + * The bulk path's per-entry dimension bound, pinned to the single-entry one. + * + * The bulk route validates `a1`..`a5` per entry rather than in the batch + * schema, because the endpoint's contract is that a bad row is skipped and the + * rest of the batch lands. That split buys the tolerance and costs a second + * copy of "0 to 10", and a second copy of a bound is a bound that drifts: the + * day somebody widens the scale on one path, the other silently keeps + * refusing values the product now accepts, and it does it 500 rows at a time. + * + * So the two are compared here by behaviour rather than by reading each + * other's source — the same value is offered to both schemas and they have to + * agree on it, at both ends of the scale and one step outside each. + * + * Mutation check: change the bulk route's `.max(10)` to `.max(9)` and the + * boundary row goes red naming 10; change the single-entry `levelADimension` + * bound instead and the same row goes red from the other side. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createMoodEntrySchema } from "@/lib/validations/mood"; + +const ROOT = process.cwd(); +const BULK_ROUTE = join( + ROOT, + "src", + "app", + "api", + "mood-entries", + "bulk", + "route.ts", +); + +/** + * The bulk schema is module-private, so it is read as source and its bound is + * extracted. A matcher that found nothing would prove nothing, which is why + * the extraction asserts its own match count before anything is compared. + */ +function bulkDimensionBound(): { min: number; max: number } { + const source = readFileSync(BULK_ROUTE, "utf8"); + const start = source.indexOf("const bulkDimensionsSchema = z.object({"); + expect( + start, + "bulkDimensionsSchema not found — the per-entry parse was renamed and this guard now proves nothing", + ).toBeGreaterThan(-1); + const body = source.slice(start, source.indexOf("\n});", start)); + const matches = [ + ...body.matchAll(/\.int\(\)\.min\((-?\d+)\)\.max\((\d+)\)/g), + ]; + expect( + matches.length, + "no bounded dimension fields found inside bulkDimensionsSchema", + ).toBe(5); + const bounds = new Set(matches.map((m) => `${m[1]}..${m[2]}`)); + expect( + [...bounds], + "the five bulk dimensions do not share one bound", + ).toHaveLength(1); + return { min: Number(matches[0][1]), max: Number(matches[0][2]) }; +} + +/** Does the single-entry create schema accept this `a1`? */ +function singleEntryAccepts(a1: number): boolean { + return createMoodEntrySchema.safeParse({ + mood: "OKAY", + moodLoggedAt: "2026-05-01T08:00:00.000Z", + a1, + }).success; +} + +describe("bulk dimension bounds agree with the single-entry schema", () => { + const bulk = bulkDimensionBound(); + + it("reads a plausible bound out of the bulk route", () => { + expect(bulk.max).toBeGreaterThan(bulk.min); + expect(Number.isInteger(bulk.min)).toBe(true); + expect(Number.isInteger(bulk.max)).toBe(true); + }); + + it("accepts the same values at both ends of the scale", () => { + expect(singleEntryAccepts(bulk.min)).toBe(true); + expect(singleEntryAccepts(bulk.max)).toBe(true); + }); + + it("refuses the same values one step outside it", () => { + expect(singleEntryAccepts(bulk.min - 1)).toBe(false); + expect(singleEntryAccepts(bulk.max + 1)).toBe(false); + }); +}); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index bbc16cd8d..00a50e75b 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -100,14 +100,21 @@ const bulkEntrySchema = z.object({ * v1.38 — the five level-A self-state values, each 0-10, each optional. * A batch that carries none still writes complete rows: the server derives * `a1` from the five-point label, so an older client build needs no change - * to stay correct. A value out of range marks THIS entry skipped and never - * the batch, which is what the per-entry parse below is for. + * to stay correct. + * + * `unknown` here and parsed with {@link bulkDimensionsSchema} PER ENTRY in + * the loop below, for the same reason `context` is: the doc comment beside + * this field used to promise that an out-of-range value marks THIS entry + * skipped while the field itself sat in the batch schema and 422'd all five + * hundred. The words were right and the code was not. A phone draining a + * year of local history must not lose the year to one row. + * @per-entry-parse: bulkDimensionsSchema, in the loop below */ - a1: z.number().int().min(0).max(10).nullable().optional(), - a2: z.number().int().min(0).max(10).nullable().optional(), - a3: z.number().int().min(0).max(10).nullable().optional(), - a4: z.number().int().min(0).max(10).nullable().optional(), - a5: z.number().int().min(0).max(10).nullable().optional(), + a1: z.unknown().optional(), + a2: z.unknown().optional(), + a3: z.unknown().optional(), + a4: z.unknown().optional(), + a5: z.unknown().optional(), /** * v1.38 — the day's context. Optional, and absent leaves a stored context * alone on a re-post, matching the single-entry path: a build with no @@ -139,6 +146,23 @@ const bulkEntrySchema = z.object({ externalId: z.string().min(1).max(120).optional(), }); +/** + * The five dimensions, validated for one entry. + * + * The bound is the scale itself, matching the single-entry schema exactly — + * one definition of "0 to 10" would be better still, but the single-entry + * schema states it as a shared const and this one has to answer per entry, so + * the two are pinned to each other by + * `src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts` instead. + */ +const bulkDimensionsSchema = z.object({ + a1: z.number().int().min(0).max(10).nullable().optional(), + a2: z.number().int().min(0).max(10).nullable().optional(), + a3: z.number().int().min(0).max(10).nullable().optional(), + a4: z.number().int().min(0).max(10).nullable().optional(), + a5: z.number().int().min(0).max(10).nullable().optional(), +}); + const bulkPayloadSchema = z.object({ entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH), }); @@ -321,6 +345,27 @@ async function postBulk(request: NextRequest): Promise { continue; } + // The five dimensions, parsed for THIS entry. Out of range skips this row + // and nothing else. + const parsedDimensions = bulkDimensionsSchema.safeParse({ + a1: entry.a1, + a2: entry.a2, + a3: entry.a3, + a4: entry.a4, + a5: entry.a5, + }); + if (!parsedDimensions.success) { + const reason = "invalid_dimensions"; + skipped.push({ index: i, reason }); + results.push({ + index: i, + status: "skipped", + reason, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); + continue; + } + const date = moodDateKey(entry.moodLoggedAt, tz); const score = getScoreForMood(entry.mood); // v1.37 / v1.38 — the same resolution the single-entry path uses: @@ -328,13 +373,7 @@ async function postBulk(request: NextRequest): Promise { // the other four present only when this entry carried them. An omitted // dimension is left alone rather than written as null, so a row that // already carries hand-set values keeps them across a re-import. - const levelA = levelAForWrite(entry.mood, { - a1: entry.a1, - a2: entry.a2, - a3: entry.a3, - a4: entry.a4, - a5: entry.a5, - }); + const levelA = levelAForWrite(entry.mood, parsedDimensions.data); try { // v1.12.1 — when the entry carries a source-stable `externalId`, diff --git a/tests/integration/mood-context.test.ts b/tests/integration/mood-context.test.ts index a05ea41d1..c5222f1db 100644 --- a/tests/integration/mood-context.test.ts +++ b/tests/integration/mood-context.test.ts @@ -394,6 +394,55 @@ describe("mood context write path (real Postgres)", () => { expect(entries[1].moodA1).toBe(9); }); + it("the bulk path skips an out-of-range dimension without failing the batch", async () => { + // The endpoint's own description promises that a bad value marks THAT + // entry skipped and never the batch. `a1`..`a5` sat in the batch schema + // and 422'd all of it, so the words and the behaviour disagreed. + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + postRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-25T08:00:00.000Z", + externalId: "ios-dim-1", + a3: 7, + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-25T12:00:00.000Z", + externalId: "ios-dim-2", + a1: 11, + }, + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-25T18:00:00.000Z", + externalId: "ios-dim-3", + }, + ], + }), + ); + // Not 422: one bad row must not cost the other two. + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inserted: number; + skipped: Array<{ index: number; reason: string }>; + }; + }; + expect(body.data.inserted).toBe(2); + expect(body.data.skipped).toEqual([ + { index: 1, reason: "invalid_dimensions" }, + ]); + + const entries = await getPrismaClient().moodEntry.findMany({ + orderBy: { moodLoggedAt: "asc" }, + }); + expect(entries).toHaveLength(2); + expect(entries[0].energyA3).toBe(7); + expect(entries[1].moodA1).toBe(9); + }); + it("the sync feed carries both levels under the keys the write path takes", async () => { const { POST } = await import("@/app/api/mood-entries/route"); await POST(