import { afterEach, expect, test } from 'bun:test'; import { discordFixture, control, submit } from './test-fixture'; let f: ReturnType; afterEach(() => f?.close()); async function click(reply: any, label: string, guild = false, values?: string[]) { return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild); } async function list(id: string, guild = false) { const home = await f.run({ data: { name: 'tasks' } }, guild); expect(home.data.flags).toBe(guild ? 64 : undefined); return click(home, 'Choose a task habit', guild, [id]); } async function task(id: string, taskId: string, guild = false) { return click(await list(id, guild), 'Choose a task', guild, [taskId]); } async function setup() { return f.json('/habits', 'POST', { name: 'Morning', method: 'tasks' }, 201); } for (const guild of [false, true]) test(`task add, rename, schedule and delete match website state in ${guild ? 'server' : 'DM'}`, async () => { f = discordFixture(); const habit = await setup(); const add = await click(await list(habit.id, guild), 'Add task', guild); let detail = await f.run(submit(add.data.custom_id, { name: 'Stretch' }), guild); let tasks = (await f.json(`/habits/${habit.id}/tasks`)).tasks; expect(tasks).toHaveLength(1); expect(tasks[0].name).toBe('Stretch'); expect(tasks[0].schedule).toEqual({ type: 'daily' }); const taskId = tasks[0].id; await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${taskId}`, 'PUT', { done: true }); const rename = await click(await task(habit.id, taskId, guild), 'Rename task', guild); detail = await f.run(submit(rename.data.custom_id, { name: 'Mobility' }), guild); tasks = (await f.json(`/habits/${habit.id}/tasks`)).tasks; expect(tasks[0].name).toBe('Mobility'); expect(tasks[0].id).toBe(taskId); expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true); const menu = await click(detail, 'Change task schedule', guild); const form = await click(menu, 'Recurrence', guild, ['weekly']); detail = await f.run(submit(form.data.custom_id, { every: '2', anchor: '2026-09-04', weekday: 'Friday' }), guild); expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'weekly', every: 2, anchor: '2026-09-04', weekday: 5 }); const confirm = await click(detail, 'Delete task', guild); expect(confirm.data.content).toContain('completion total may change'); expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(1); await click(confirm, 'Confirm task deletion', guild); expect((await f.json(`/habits/${habit.id}/tasks`)).tasks).toHaveLength(0); expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).due).toBe(false); expect(f.changed).toEqual(['alice', 'alice', 'alice', 'alice']); }); test('task scheduling supports daily, selected weekdays, day intervals and overlap warnings', async () => { f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [{ name: 'Plan' }] }, 201); const id = h.tasks[0].id; let detail = await task(h.id, id); let form = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['interval']); detail = await f.run(submit(form.data.custom_id, { every: '3', anchor: '2026-09-01' })); expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'interval', every: 3, anchor: '2026-09-01' }); let menu = await click(await click(detail, 'Change task schedule'), 'Recurrence', false, ['weekdays']); detail = await click(menu, 'Select weekdays', false, ['2']); expect(detail.data.content).toContain('never coincides'); menu = await click(detail, 'Change task schedule'); detail = await click(menu, 'Recurrence', false, ['daily']); expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].schedule).toEqual({ type: 'daily' }); form = await click(detail, 'Rename task'); expect((await f.run(submit(form.data.custom_id, { name: ' ' }))).data.content).toContain('Check the entered'); expect((await f.json(`/habits/${h.id}/tasks`)).tasks[0].name).toBe('Plan'); }); test('task changes preserve prior dates; stale controls and cross-owner task IDs are rejected', async () => { f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Old task' }] }, 201); const id = h.tasks[0].id; const rename = await click(await task(h.id, id), 'Rename task'); await f.json(`/habits/${h.id}/tasks/${id}`, 'PATCH', { name: 'Website rename' }); expect((await f.run(submit(rename.data.custom_id, { name: 'Stale' }))).data.content).toContain('habit changed'); f.setDiscordTime('2026-09-05T12:00:00Z'); await click(await click(await task(h.id, id), 'Delete task'), 'Confirm task deletion'); expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).tasks[0].name).toBe('Website rename'); expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(0); const bob = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201, 'b'); const add = await click(await list(h.id), 'Add task'); expect((await f.run(submit(add.data.custom_id, { name: 'Attack' }), true, f.bob)).data.content).toContain('belongs to another'); const bobMenu = await f.run({ data: { name: 'tasks' } }, false, f.bob); expect((await click(bobMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('belongs to another'); const aliceMenu = await f.run({ data: { name: 'tasks' } }); expect((await click(aliceMenu, 'Choose a task habit', false, [bob.id])).data.content).toContain('available options'); }); test('add forms are replay safe after restart and task pagination stays within limits', async () => { f = discordFixture(); const h = await setup(); const add = await click(await list(h.id), 'Add task'); const payload = f.payload(submit(add.data.custom_id, { name: 'Once' })); await f.requestDiscord(payload); f.restart(); await f.requestDiscord(payload); expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(1); expect(f.changed).toHaveLength(1); for (let i = 0; i < 11; i++) await f.json(`/habits/${h.id}/tasks`, 'POST', { name: `Task ${i}` }, 201); const first = await list(h.id); expect(first.data.components[0].components[0].options).toHaveLength(10); const next = await click(first, 'Next'); expect(next.data.components[0].components[0].options).toHaveLength(2); expect(control(next, 'Previous')).toBeTruthy(); }); test('midnight, color-only website edits, and count progress invalidate management snapshots', async () => { f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z'); const h = await setup(); let add = await click(await list(h.id), 'Add task'); f.setDiscordTime('2026-09-04T22:01:00Z'); expect((await f.run(submit(add.data.custom_id, { name: 'Wrong day' }))).data.content).toContain('habit changed'); add = await click(await list(h.id), 'Add task'); await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#abcdef' }); expect((await f.run(submit(add.data.custom_id, { name: 'Old color' }))).data.content).toContain('habit changed'); const c = await f.json('/habits', 'POST', { name: 'Count', method: 'count', target: 10 }, 201); const home = await f.run({ data: { name: 'habits' } }); const detail = await click(home, 'Choose a habit to manage', false, [c.id]); const form = await click(detail, 'Edit details'); await f.json(`/habits/${c.id}/days/2026-09-05/progress`, 'PUT', { count: 3 }); expect((await f.run(submit(form.data.custom_id, { name: 'Lost progress', target: '20', unit: 'steps', color: '#123456' }))).data.content).toContain('habit changed'); }); test('a complete checklist routine can be created entirely in Discord', async () => { f = discordFixture(); const empty = await f.run({ data: { name: 'tasks' } }, true); const methods = await click(empty, 'Create habit', true); const form = await click(methods, 'Tracking method', true, ['tasks']); const habit = await f.run(submit(form.data.custom_id, { name: 'Evening', color: '#abcdef' }), true); const tasks = await click(habit, 'Manage tasks', true); const add = await click(tasks, 'Add task', true); const task = await f.run(submit(add.data.custom_id, { name: 'Prepare tomorrow' }), true); expect(task.data.content).toContain('Prepare tomorrow'); const website = (await f.json('/habits')).habits[0]; expect(website.method).toBe('tasks'); expect(website.tasks[0].name).toBe('Prepare tomorrow'); const settings = await f.json(`/habits/${website.id}/calendar-settings`); expect(settings.mainColor).toBe('#abcdef'); const other = await f.json('/habits', 'POST', { name: 'Other', method: 'tasks', tasks: [{ name: 'Private task' }] }, 201, 'b'); const taskMenu = await list(website.id); const blocked = await click(taskMenu, 'Choose a task', false, [other.tasks[0].id]); expect(blocked.data.content).toContain('available options'); });