From 724b1423ebc502c69fa887b49c7a23e8fefdd637 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 4 Sep 2026 09:44:22 +0200 Subject: [PATCH] fix(api): preserve omitted fields in task and chart patches --- src/habits/contracts.ts | 5 +++-- src/habits/regressions.test.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/habits/regressions.test.ts diff --git a/src/habits/contracts.ts b/src/habits/contracts.ts index a9a4097..93e0919 100644 --- a/src/habits/contracts.ts +++ b/src/habits/contracts.ts @@ -14,7 +14,8 @@ export const scheduleSchema = z.discriminatedUnion('type', [ export type Schedule = z.infer; const schedule = scheduleSchema.default({ type: 'daily' }); export const taskInput = z.object({ name, schedule }).strict(); -export const taskPatch = taskInput.partial().refine(v => Object.keys(v).length > 0); +// Creation defaults must not become writes when PATCH omits a property. +export const taskPatch = z.object({ name: name.optional(), schedule: scheduleSchema.optional() }).strict().refine(v => Object.keys(v).length > 0); const common = { name, schedule }; export const habitInput = z.discriminatedUnion('method', [ z.object({ ...common, method: z.literal('count'), carryPartialProgress: z.boolean().default(false), target: z.number().int().min(1).max(10000), unit: z.string().trim().min(1).max(80).default('steps') }).strict(), @@ -40,5 +41,5 @@ export const calendarSettingsSchema = z.object({ }).strict(); export type CalendarSettings = z.infer; export const chartInput = z.object({ name, habitIds: z.array(z.string().uuid()).min(1).max(100).refine(a => new Set(a).size === a.length), settings: calendarSettingsSchema.default(() => calendarSettingsSchema.parse({})) }).strict(); -export const chartPatch = chartInput.partial().refine(v => Object.keys(v).length > 0); +export const chartPatch = z.object({ name: name.optional(), habitIds: chartInput.shape.habitIds.optional(), settings: calendarSettingsSchema.optional() }).strict().refine(v => Object.keys(v).length > 0); export const timezoneInput = z.object({ timezone: z.string().min(1).max(100).refine(v => { try { return !/^[+-]/.test(v) && !!new Intl.DateTimeFormat('en', { timeZone: v }); } catch { return false; } }) }).strict(); diff --git a/src/habits/regressions.test.ts b/src/habits/regressions.test.ts new file mode 100644 index 0000000..b103f8e --- /dev/null +++ b/src/habits/regressions.test.ts @@ -0,0 +1,20 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { fixture } from './test-fixture'; +let f: ReturnType; +beforeEach(() => { f = fixture(); }); afterEach(() => f.close()); +const taskHabit = () => f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Weekly', schedule: { type: 'weekdays', days: [5] } }] }, 201); +const detail = (id: string) => f.json(`/habits/${id}/days/2026-09-04`); +const tick = (h: any, done: boolean) => f.json(`/habits/${h.id}/days/2026-09-04/tasks/${h.tasks[0].id}`, 'PUT', { done }); +test('task PATCH preserves omitted recurrence and rejects an empty patch', async () => { + const h = await taskHabit(); const path = `/habits/${h.id}/tasks/${h.tasks[0].id}`; + expect((await f.json(path, 'PATCH', { name: 'Renamed' })).schedule).toEqual({ type: 'weekdays', days: [5] }); + expect((await f.request(path, 'PATCH', {})).status).toBe(422); + expect((await f.json(path, 'PATCH', { schedule: { type: 'daily' } })).name).toBe('Renamed'); +}); +test('chart PATCH preserves omitted settings and rejects an empty patch', async () => { + const h = await taskHabit(); + const c = await f.json('/charts', 'POST', { name: 'Chart', habitIds: [h.id], settings: { mainColor: '#ff0000', shadeCount: 8 } }, 201); + expect((await f.json(`/charts/${c.id}`, 'PATCH', { name: 'Renamed' })).settings).toEqual(c.settings); + expect((await f.request(`/charts/${c.id}`, 'PATCH', {})).status).toBe(422); + expect((await f.json(`/charts/${c.id}`, 'PATCH', { settings: {} })).settings.mainColor).toBe('#196127'); +});