fix(api): preserve omitted fields in task and chart patches
This commit is contained in:
@@ -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();
|
||||
|
||||
20
src/habits/regressions.test.ts
Normal file
20
src/habits/regressions.test.ts
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user