103 lines
6.9 KiB
TypeScript
103 lines
6.9 KiB
TypeScript
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');
|
|
});
|
|
test('archiving and restoring retains same-day task states and combined completion', async () => {
|
|
const h = await taskHabit(); await tick(h, true);
|
|
const c = await f.json('/charts', 'POST', { name: 'Combined', habitIds: [h.id] }, 201);
|
|
await f.request(`/habits/${h.id}`, 'DELETE');
|
|
expect((await detail(h.id)).due).toBe(false);
|
|
await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
|
|
expect((await detail(h.id)).value).toBe(1);
|
|
expect((await f.json(`/charts/${c.id}/days/2026-09-04`)).ratio).toBe(1);
|
|
await tick(h, false); await f.request(`/habits/${h.id}`, 'DELETE'); await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
|
|
expect((await detail(h.id)).value).toBe(0);
|
|
});
|
|
test('temporarily disabling the schedule retains same-day occurrence progress', async () => {
|
|
const h = await taskHabit(); await tick(h, true);
|
|
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
|
|
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'daily' } });
|
|
expect((await detail(h.id)).value).toBe(1);
|
|
});
|
|
test('post-deadline corrections never invent expiry for an originally completed occurrence', async () => {
|
|
const h = await taskHabit(); await tick(h, true); f.setTime('2026-09-05T12:00Z');
|
|
expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
|
|
await tick(h, false);
|
|
for (let i = 0; i < 2; i++) expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
|
|
await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
|
|
});
|
|
test('expired occurrences preserve original expiry through repeated historical corrections', async () => {
|
|
const h = await taskHabit(); f.setTime('2026-09-05T12:00Z');
|
|
const expired = (await detail(h.id)).tasks[0].expiredAt;
|
|
expect(expired).toBe(Date.parse('2026-09-04T22:00Z'));
|
|
await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
|
|
await tick(h, false); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired);
|
|
});
|
|
test('overlapping patches preserve unrelated changes', async () => {
|
|
const h = await f.json('/habits', 'POST', { name: 'Original', method: 'count', target: 8 }, 201);
|
|
await Promise.all([
|
|
f.json(`/habits/${h.id}`, 'PATCH', { name: 'Renamed' }),
|
|
f.json(`/habits/${h.id}`, 'PATCH', { target: 10 }),
|
|
]);
|
|
expect(await f.json(`/habits/${h.id}`)).toMatchObject({ name: 'Renamed', target: 10 });
|
|
});
|
|
test('count correction audit records inherited progress rather than the raw zero default', async () => {
|
|
const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201);
|
|
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
|
|
f.setTime('2026-09-05T12:00Z');
|
|
await f.json(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', { count: 8 });
|
|
const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`);
|
|
expect(audit.records[0].events[0].before.count).toBe(7);
|
|
});
|
|
function delayedPatch(path: string, input: object) {
|
|
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
let began!: () => void;
|
|
const started = new Promise<void>(resolve => { began = resolve; });
|
|
const encoded = new TextEncoder().encode(JSON.stringify(input));
|
|
const stream = new ReadableStream<Uint8Array>({ start(value) { controller = value; }, pull() { began(); } });
|
|
const response = f.app.request(`${f.origin}/api${path}`, { method: 'PATCH', body: stream,
|
|
headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json', 'Content-Length': String(encoded.length) } });
|
|
return { started, response, finish() { controller.enqueue(encoded); controller.close(); } };
|
|
}
|
|
test('a request streaming across midnight applies configuration on its actual write date', async () => {
|
|
const h = await taskHabit(); f.setTime('2026-09-04T21:59:59Z');
|
|
const pending = delayedPatch(`/habits/${h.id}`, { name: 'Tomorrow' }); await pending.started;
|
|
f.setTime('2026-09-04T22:01Z'); pending.finish();
|
|
expect((await pending.response).status).toBe(200);
|
|
expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05');
|
|
expect((await detail(h.id)).name).toBe('Routine');
|
|
});
|
|
test('a streaming request observes timezone changes completed before its write', async () => {
|
|
const h = await taskHabit();
|
|
const pending = delayedPatch(`/habits/${h.id}`, { name: 'After travel' }); await pending.started;
|
|
await f.json('/me', 'PATCH', { timezone: 'Pacific/Kiritimati' }); pending.finish();
|
|
expect((await pending.response).status).toBe(200);
|
|
expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05');
|
|
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).timezone).toBe('Pacific/Kiritimati');
|
|
expect((await detail(h.id)).name).toBe('Routine');
|
|
});
|
|
test('task occurrences retain definition order across dates and revisions', async () => {
|
|
const h = await f.json('/habits', 'POST', { name: 'Order', method: 'tasks', tasks: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }] }, 201);
|
|
for (let i = 0; i < 3; i++) f.sqlite.query('UPDATE task_occurrences SET id = ? WHERE task_id = ?').run(String(3 - i), h.tasks[i].id);
|
|
expect((await detail(h.id)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
|
|
await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Rename' });
|
|
f.setTime('2026-09-05T12:00Z');
|
|
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
|
|
});
|