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(