fix(api): preserve omitted fields in task and chart patches

This commit is contained in:
syntaxbullet
2026-09-04 09:44:22 +02:00
parent 42054a92f2
commit 724b1423eb
2 changed files with 23 additions and 2 deletions

View File

@@ -14,7 +14,8 @@ export const scheduleSchema = z.discriminatedUnion('type', [
export type Schedule = z.infer<typeof scheduleSchema>;
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<typeof calendarSettingsSchema>;
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();

View File

@@ -0,0 +1,20 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
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');
});