import { afterEach, expect, test } from 'bun:test'; import { eq } from 'drizzle-orm'; import { reminderDeliveries, users } from '../db/schema'; import { discordFixture, control, submit, messageText, components } from './test-fixture'; import { commands } from './commands'; let f: ReturnType; afterEach(() => f?.close()); const click = (reply: any, label: string) => ({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label) } }); const draft = { name: 'Take a breath', times: '09:00, 18:30', days: 'Mon, Wed, Fri', date: '' }; async function choose(list: any, guild = false, index = '0') { return f.run(click(list, 'Manage reminder'), guild); } test('settings and reminders are global commands in both contexts', () => { for (const name of ['settings', 'reminders']) expect(commands.find(c => c.name === name)?.contexts).toEqual([0, 1]); }); for (const guild of [false, true]) test(`full named reminder management shares website state in ${guild ? 'server' : 'DM'}`, async () => { f = discordFixture(true); await f.json('/reminders', 'PUT', { enabled: false, reminders: [], quietStart: '22:00', quietEnd: '08:00' }); let list = await f.run({ data: { name: 'reminders' } }, guild); expect(list.data.flags).toBe(32768 | (guild ? 64 : 0)); const form = await f.run(click(list, 'Add reminder'), guild); const interaction = f.payload(submit(form.data.custom_id, draft), guild); list = await (await f.requestDiscord(interaction)).json(); f.restart(); await f.requestDiscord(interaction); let web = await f.json('/reminders'); expect(web.reminders).toEqual(['09:00', '18:30'].map(time => ({ type: 'weekly', name: draft.name, time, days: [1, 3, 5] }))); expect(f.changed).toEqual(['alice']); list = await f.run(click(list, 'Enable delivery'), guild); expect((await f.json('/reminders')).enabled).toBe(true); let detail = await choose(list, guild); const edit = await f.run(click(detail, 'Edit reminder'), guild); list = await f.run(submit(edit.data.custom_id, { name: 'Birthday', times: '12:00, 14:00', days: '', date: '2026-09-30' }), guild); web = await f.json('/reminders'); expect(web.reminders).toEqual(['12:00', '14:00'].map(time => ({ type: 'date', name: 'Birthday', time, date: '2026-09-30' }))); const quiet = await f.run(click(list, 'Quiet hours'), guild); list = await f.run(submit(quiet.data.custom_id, { start: '23:00', end: '07:00' }), guild); expect((await f.json('/reminders')).quietStart).toBe('23:00'); detail = await choose(list, guild); const confirmation = await f.run(click(detail, 'Remove reminder'), guild); list = await f.run(click(confirmation, 'Confirm removal'), guild); expect((await f.json('/reminders')).reminders).toEqual([]); expect((await f.json('/reminders')).enabled).toBe(false); expect(messageText(list)).toContain('No reminders'); const settings = await f.run({ data: { name: 'settings' } }, guild); const tz = await f.run(click(settings, 'Change timezone'), guild); await f.run(submit(tz.data.custom_id, { timezone: 'America/New_York' }), guild); expect((await f.json('/me')).timezone).toBe('America/New_York'); expect((await f.json('/today')).timezone).toBe('America/New_York'); expect(f.changed.length).toBe(6); }); test('bad input can reopen a prefilled form without consuming the valid control', async () => { f = discordFixture(); const list = await f.run({ data: { name: 'reminders' } }); let form = await f.run(click(list, 'Add reminder')); for (const invalid of [{ ...draft, times: '25:00' }, { ...draft, days: 'Funday' }, { ...draft, days: '', date: '2026-02-30' }, { ...draft, date: '2026-09-30' }]) { const failed = await f.run(submit(form.data.custom_id, invalid)); expect(failed.type).toBe(7); form = await f.run(click(failed, 'Try again')); expect(form.type).toBe(9); expect(form.data.components[0].components[0].value).toBe(draft.name); } const saved = await f.run(submit(form.data.custom_id, draft)); expect(messageText(saved)).toContain(draft.name); const enabled = await f.run(click(saved, 'Enable delivery')); expect(messageText(enabled)).toContain('not configured'); expect((await f.json('/reminders')).enabled).toBe(false); const settings = await f.run({ data: { name: 'settings' } }); const timezone = await f.run(click(settings, 'Change timezone')); const invalid = await f.run(submit(timezone.data.custom_id, { timezone: 'Invalid/Zone' })); expect(messageText(invalid)).toContain('Use a timezone'); const retry = await f.run(click(invalid, 'Try again')); await f.run(submit(retry.data.custom_id, { timezone: 'UTC' })); expect((await f.json('/me')).timezone).toBe('UTC'); }); test('website edits and timezone changes invalidate old controls even within one millisecond', async () => { f = discordFixture(); const list = await f.run({ data: { name: 'reminders' } }); const form = await f.run(click(list, 'Add reminder')); const before = await f.json('/reminders'); const prefs = { enabled: false, reminders: before.reminders, quietStart: '22:00', quietEnd: '08:00' }; await f.json('/reminders', 'PUT', prefs); const blocked = await f.run(submit(form.data.custom_id, draft)); expect(messageText(blocked)).toContain('Settings changed'); const newer = await f.run({ data: { name: 'reminders' } }); const newerForm = await f.run(click(newer, 'Add reminder')); await f.json('/reminders', 'PUT', { ...prefs, quietStart: '21:00' }); await f.json('/reminders', 'PUT', prefs); expect(messageText((await f.run(submit(newerForm.data.custom_id, draft))))).toContain('Settings changed'); const settings = await f.run({ data: { name: 'settings' } }); const tz = await f.run(click(settings, 'Change timezone')); await f.json('/me', 'PATCH', { timezone: 'UTC' }); await f.json('/me', 'PATCH', { timezone: 'Europe/Belgrade' }); expect(messageText((await f.run(submit(tz.data.custom_id, { timezone: 'Asia/Tokyo' }))))).toContain('Settings changed'); expect((await f.json('/me')).timezone).toBe('Europe/Belgrade'); }); test('old forms cannot cross accounts; new Discord accounts discover UTC setup', async () => { f = discordFixture(); const list = await f.run({ data: { name: 'reminders' } }); const form = await f.run(click(list, 'Add reminder')); const blocked = await f.run(submit(form.data.custom_id, draft), true, f.bob); expect(blocked.data.flags).toBe(64); expect(messageText(blocked)).toContain('belongs to another user'); expect((await f.json('/reminders', 'GET', undefined, 200, 'b')).reminders).toHaveLength(1); const id = '555555555555555555'; const settings = await f.run({ data: { name: 'settings' } }, false, id); expect(messageText(settings)).toContain('New accounts start in UTC'); expect(f.db.select().from(users).where(eq(users.discordId, id)).get()?.timezone).toBe('UTC'); }); test('paginated list, 20-slot limit, delivery status and quiet-hour validation', async () => { f = discordFixture(true); const reminders = Array.from({ length: 20 }, (_, i) => ({ type: 'weekly', name: `Reminder ${i + 1}`, time: '12:00', days: [1] })); await f.json('/reminders', 'PUT', { enabled: false, reminders, quietStart: '22:00', quietEnd: '08:00' }); f.db.insert(reminderDeliveries).values({ id: 'delivery', userId: 'alice', date: '2026-09-04', status: 'pending', updatedAt: 1 }).run(); const list = await f.run({ data: { name: 'reminders' } }); expect(messageText(list)).toContain('Delivery unconfirmed'); const next = await f.run(click(list, 'Next')); expect(messageText(next)).toContain('Reminder 12'); const form = await f.run(click(next, 'Add reminder')); const fail = await f.run(submit(form.data.custom_id, draft)); expect(messageText(fail)).toContain('up to 20'); expect((await f.json('/reminders')).reminders).toHaveLength(20); const quiet = await f.run(click(list, 'Quiet hours')); const bad = await f.run(submit(quiet.data.custom_id, { start: '25:00', end: '00:00' })); const retry = await f.run(click(bad, 'Try again')); const done = await f.run(submit(retry.data.custom_id, { start: '00:00', end: '00:00' })); expect(messageText(done)).toContain('(off)'); const expired = await f.run({ data: { name: 'settings' } }); f.setDiscordTime('2026-09-04T12:16:00Z'); expect(messageText((await f.run(click(expired, 'Change timezone'))))).toContain('expired'); });