diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fcadfa331..d52c8ad1c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20721,9 +20721,11 @@ components: minLength: 1 maxLength: 80 severity: - type: integer - minimum: 1 - maximum: 4 + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" required: - key note: @@ -20859,9 +20861,11 @@ components: minLength: 1 maxLength: 80 severity: - type: integer - minimum: 1 - maximum: 4 + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" required: - key note: @@ -20975,9 +20979,11 @@ components: minLength: 1 maxLength: 80 severity: - type: integer - minimum: 1 - maximum: 4 + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" required: - key note: diff --git a/src/lib/validations/__tests__/cycle-day-log-symptoms.test.ts b/src/lib/validations/__tests__/cycle-day-log-symptoms.test.ts new file mode 100644 index 000000000..021c808db --- /dev/null +++ b/src/lib/validations/__tests__/cycle-day-log-symptoms.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + cycleDayLogInputSchema, + cycleDayLogPatchSchema, +} from "@/lib/validations/cycle"; + +/** + * A symptom picked without an intensity is the ordinary way to log one. + * + * The sheet holds the picker as key to intensity and sends `severity: null` + * for every symptom the user did not rate, which is most of them. The schema + * took a number or nothing at all, so those entries came back 422 and the + * sheet showed "Could not save. Try again." with no way to tell why. The link + * row has always stored an unrated symptom as a plain presence; only the + * gate at the door disagreed, and nothing tested it. + */ +const BASE_INPUT = { + date: "2026-09-17", + loggedAt: new Date().toISOString(), +} as const; + +describe("cycle day-log symptoms accept an unrated selection", () => { + for (const [name, schema] of [ + ["create", cycleDayLogInputSchema], + ["patch", cycleDayLogPatchSchema], + ] as const) { + it(`${name}: takes a symptom with no intensity at all`, () => { + const parsed = schema.safeParse({ + ...BASE_INPUT, + symptoms: [{ key: "cramps" }], + }); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it(`${name}: takes an explicit null intensity, which is what the sheet sends`, () => { + const parsed = schema.safeParse({ + ...BASE_INPUT, + symptoms: [ + { key: "cramps", severity: null }, + { key: "back_pain", severity: null }, + ], + }); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it(`${name}: still takes a rated symptom`, () => { + const parsed = schema.safeParse({ + ...BASE_INPUT, + symptoms: [{ key: "cramps", severity: 3 }], + }); + expect(parsed.success).toBe(true); + }); + + it(`${name}: still refuses an intensity outside 1 to 4`, () => { + for (const severity of [0, 5, 2.5]) { + const parsed = schema.safeParse({ + ...BASE_INPUT, + symptoms: [{ key: "cramps", severity }], + }); + expect(parsed.success, `severity ${severity}`).toBe(false); + } + }); + } +}); diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index dad5f0ce3..86fbd8cab 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -131,7 +131,12 @@ export const cycleDayLogInputSchema = z.object({ .array( z.object({ key: z.string().min(1).max(80), - severity: z.number().int().min(1).max(4).optional(), + // NULL is the ordinary case, not an edge: picking a symptom without + // tapping an intensity is how most entries are logged, and the link + // row stores that as a plain presence. Rejecting it refused every + // unrated symptom with a 422 the sheet could only show as + // "Could not save". + severity: z.number().int().min(1).max(4).nullable().optional(), }), ) .max(40) @@ -181,7 +186,12 @@ export const cycleDayLogPatchSchema = z.object({ .array( z.object({ key: z.string().min(1).max(80), - severity: z.number().int().min(1).max(4).optional(), + // NULL is the ordinary case, not an edge: picking a symptom without + // tapping an intensity is how most entries are logged, and the link + // row stores that as a plain presence. Rejecting it refused every + // unrated symptom with a 422 the sheet could only show as + // "Could not save". + severity: z.number().int().min(1).max(4).nullable().optional(), }), ) .max(40)