Files
minabot/src/habits/regressions.test.ts

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']);
});