124 lines
8.9 KiB
TypeScript
124 lines
8.9 KiB
TypeScript
import { afterEach, expect, test } from 'bun:test';
|
|
import { discordFixture, control, submit } from './test-fixture';
|
|
let f: ReturnType<typeof discordFixture>;
|
|
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');
|
|
});
|