268 lines
16 KiB
TypeScript
268 lines
16 KiB
TypeScript
import { expect, test, spyOn } from "bun:test";
|
|
import { eq } from "drizzle-orm";
|
|
import { fixture } from "../habits/test-fixture";
|
|
import { reminderDeliveries, reminderSettings } from "../db/schema";
|
|
import { sendDueReminders } from "./worker";
|
|
import { createApi } from "../api";
|
|
import { defaultReminder, inQuietHours } from "./contracts";
|
|
import type { DiscordFetch } from "../sharing/routes";
|
|
const config = { token: "test-only-bot", origin: "https://minabot.example" };
|
|
async function setup() {
|
|
const f = fixture();
|
|
const habit = await f.json("/habits", "POST", { name: "Private habit name", method: "manual" }, 201);
|
|
f.db.insert(reminderSettings).values({ userId: "alice", ...defaultReminder, enabled: true, updatedAt: 0 }).run();
|
|
return { f, habit };
|
|
}
|
|
function transport(sent: string[]): DiscordFetch {
|
|
return async (url, init) => {
|
|
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "223456789012345678" });
|
|
sent.push(String(init?.body)); return Response.json({ id: "323456789012345678" });
|
|
};
|
|
}
|
|
|
|
test("reminders follow local time, send only unfinished habits, and deduplicate concurrent workers", async () => {
|
|
const { f, habit } = await setup(); const sent: string[] = [];
|
|
try {
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T17:59:00Z")); expect(sent).toHaveLength(0);
|
|
const now = () => Date.parse("2026-09-04T18:00:00Z");
|
|
await Promise.all([sendDueReminders(f.db, config, transport(sent), now), sendDueReminders(f.db, config, transport(sent), now)]);
|
|
expect(sent).toHaveLength(1); expect(sent[0]).not.toContain("Private habit name");
|
|
expect(JSON.parse(sent[0]!).allowed_mentions.parse).toEqual([]);
|
|
expect(JSON.parse(sent[0]!).components).toBeUndefined();
|
|
await sendDueReminders(f.db, config, transport(sent), now); expect(sent).toHaveLength(1);
|
|
f.setTime("2026-09-05T18:00:00Z");
|
|
await f.json(`/habits/${habit.id}/days/2026-09-05/progress`, "PUT", { done: true });
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-05T18:00:00Z")); expect(sent).toHaveLength(1);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("quiet hours defer until their end and DST overlap sends once", async () => {
|
|
const { f } = await setup(); const sent: string[] = [];
|
|
try {
|
|
expect(inQuietHours("23:00", "22:00", "08:00")).toBe(true);
|
|
expect(inQuietHours("07:59", "22:00", "08:00")).toBe(true);
|
|
expect(inQuietHours("08:00", "22:00", "08:00")).toBe(false);
|
|
f.db.update(reminderSettings).set({ quietStart: "19:00", quietEnd: "21:00" }).run();
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T18:00:00Z")); expect(sent).toHaveLength(0);
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T19:00:00Z")); expect(sent).toHaveLength(1);
|
|
f.db.update(reminderSettings).set({ time: "02:30", quietStart: "00:00", quietEnd: "00:00" }).run();
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-10-25T00:30:00Z"));
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-10-25T01:30:00Z")); expect(sent).toHaveLength(2);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("opt-out during channel creation cancels the message", async () => {
|
|
const { f } = await setup(); let posts = 0;
|
|
try {
|
|
await sendDueReminders(f.db, { ...config, commandsAvailable: true }, async url => {
|
|
if (!url.endsWith("/users/@me/channels")) posts++;
|
|
f.db.update(reminderSettings).set({ enabled: false }).run();
|
|
return Response.json({ id: "223456789012345678" });
|
|
}, () => Date.parse("2026-09-04T18:00:00Z"));
|
|
expect(posts).toBe(0);
|
|
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe("skipped");
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("rate limits persist their retry deadline; ambiguous sends are not retried", async () => {
|
|
const { f } = await setup(); let attempts = 0;
|
|
let timestamp = Date.parse("2026-09-04T18:00:00Z");
|
|
try {
|
|
const request: DiscordFetch = async url => {
|
|
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "223456789012345678" });
|
|
attempts++;
|
|
if (attempts === 1) return Response.json({ retry_after: 600 }, { status: 429 });
|
|
throw new Error("Ambiguous connection failure");
|
|
};
|
|
await sendDueReminders(f.db, config, request, () => timestamp);
|
|
timestamp += 60000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(1);
|
|
timestamp += 540000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(2);
|
|
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe("uncertain");
|
|
timestamp += 60000; await sendDueReminders(f.db, config, request, () => timestamp); expect(attempts).toBe(2);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("reminder preferences require authentication, origin, valid clocks, and configured delivery", async () => {
|
|
const f = fixture();
|
|
try {
|
|
expect((await f.request("/reminders", "PUT", { ...defaultReminder, enabled: true })).status).toBe(422);
|
|
expect((await f.request("/reminders", "PUT", defaultReminder, "a", { Origin: "https://evil.test" })).status).toBe(403);
|
|
expect((await f.request("/reminders", "GET", undefined, "x")).status).toBe(401);
|
|
const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-at-least-32-characters" }, undefined, undefined, undefined, { token: "test", channelId: "" });
|
|
const call = (body: unknown) => app.request(`${f.origin}/api/reminders`, { method: "PUT", headers: { Cookie: `minabot_session=${"a".repeat(43)}`, Origin: f.origin, "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
expect((await call({ ...defaultReminder, time: "25:00" })).status).toBe(422);
|
|
expect((await call({ ...defaultReminder, enabled: true })).status).toBe(200);
|
|
expect(f.db.select().from(reminderSettings).where(eq(reminderSettings.userId, "alice")).get()?.enabled).toBe(true);
|
|
expect((await f.json("/reminders", "GET", undefined, 200, "b")).enabled).toBe(false);
|
|
const exported = await f.json("/account/export"); expect(exported.reminderSettings[0].enabled).toBe(true);
|
|
await f.request("/account", "DELETE", { confirmation: "DELETE" });
|
|
expect(f.db.select().from(reminderSettings).all()).toEqual([]);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("multiple times respect weekdays and dates, deduplicate overlaps, and stop after completion", async () => {
|
|
const { f, habit } = await setup(); const sent: string[] = [];
|
|
try {
|
|
f.db.update(reminderSettings).set({ reminders: [
|
|
{ type: "weekly", time: "10:00", days: [5] },
|
|
{ type: "date", time: "10:00", date: "2026-09-04" },
|
|
{ type: "date", time: "12:00", date: "2026-09-04" },
|
|
{ type: "weekly", time: "14:00", days: [1] },
|
|
{ type: "weekly", time: "18:00", days: [5] },
|
|
] }).run();
|
|
const tick = (time: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${time}:00Z`));
|
|
await Promise.all([tick("08:00"), tick("08:00")]); expect(sent).toHaveLength(1);
|
|
await tick("10:00"); expect(sent).toHaveLength(2);
|
|
await tick("12:00"); expect(sent).toHaveLength(2);
|
|
f.setTime("2026-09-04T15:00:00Z");
|
|
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { done: true });
|
|
await tick("16:00"); expect(sent).toHaveLength(2);
|
|
expect(f.db.select().from(reminderDeliveries).all().map(row => row.slot)).toEqual(["10:00", "12:00"]);
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-05T18:00:00Z")); expect(sent).toHaveLength(2);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("overdue reminders coalesce after quiet hours and retain separate delivery records", async () => {
|
|
const { f } = await setup(); const sent: string[] = [];
|
|
try {
|
|
f.db.update(reminderSettings).set({ quietStart: "09:00", quietEnd: "13:00", reminders: [
|
|
{ type: "weekly", time: "10:00", days: [5] }, { type: "weekly", time: "12:00", days: [5] }, { type: "weekly", time: "16:00", days: [5] },
|
|
] }).run();
|
|
const tick = (time: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${time}:00Z`));
|
|
await tick("10:00"); expect(sent).toHaveLength(0);
|
|
await Promise.all([tick("11:00"), tick("11:00")]); expect(sent).toHaveLength(1);
|
|
expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(2);
|
|
await tick("14:00"); expect(sent).toHaveLength(2);
|
|
await tick("15:00"); expect(sent).toHaveLength(2);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("removing a due schedule while opening Discord cancels delivery", async () => {
|
|
const { f } = await setup(); let posts = 0;
|
|
try {
|
|
await sendDueReminders(f.db, config, async url => {
|
|
if (!url.endsWith("/users/@me/channels")) posts++;
|
|
f.db.update(reminderSettings).set({ reminders: [{ type: "date", time: "20:00", date: "2026-09-05" }] }).run();
|
|
return Response.json({ id: "223456789012345678" });
|
|
}, () => Date.parse("2026-09-04T18:00:00Z"));
|
|
expect(posts).toBe(0);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("schedule validation rejects empty days, invalid dates, and excessive entries", async () => {
|
|
const { reminderInput } = await import("./contracts");
|
|
const parse = (reminders: unknown[]) => reminderInput.safeParse({ enabled: true, quietStart: "22:00", quietEnd: "08:00", reminders }).success;
|
|
expect(parse([])).toBe(false);
|
|
expect(parse([{ type: "weekly", time: "12:00", days: [] }])).toBe(false);
|
|
expect(parse([{ type: "date", time: "12:00", date: "2026-02-30" }])).toBe(false);
|
|
expect(parse(Array.from({ length: 21 }, () => ({ type: "weekly", time: "12:00", days: [1] })))).toBe(false);
|
|
expect(parse([{ type: "weekly", time: "12:00", days: [1, 3] }, { type: "date", time: "18:00", date: "2026-09-08" }])).toBe(true);
|
|
});
|
|
|
|
test("named schedules round-trip through the API and account export", async () => {
|
|
const f = fixture();
|
|
try {
|
|
const reminders = [{ type: "weekly", name: "Morning check-in", time: "09:00", days: [1, 2, 3, 4, 5] }, { type: "date", name: "Evening review", time: "18:00", date: "2026-09-08" }];
|
|
await f.json("/reminders", "PUT", { enabled: false, reminders, quietStart: "22:00", quietEnd: "08:00" });
|
|
expect((await f.json("/reminders")).reminders).toEqual(reminders);
|
|
expect((await f.json("/account/export")).reminderSettings[0].reminders).toEqual(reminders);
|
|
expect((await f.request("/reminders", "PUT", { enabled: false, reminders: [{ ...reminders[0], name: "x".repeat(81) }], quietStart: "22:00", quietEnd: "08:00" })).status).toBe(422);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("legacy delivery records prevent upgrade duplicates and deferred records still retry", async () => {
|
|
const { f } = await setup(); const sent: string[] = [];
|
|
try {
|
|
const timestamp = Date.parse("2026-09-04T18:00:00Z");
|
|
f.db.insert(reminderDeliveries).values({ id: "old", userId: "alice", date: "2026-09-04", status: "sent", updatedAt: timestamp }).run();
|
|
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(0);
|
|
f.db.update(reminderDeliveries).set({ status: "deferred", retryAt: timestamp }).run();
|
|
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(1);
|
|
await sendDueReminders(f.db, config, transport(sent), () => timestamp); expect(sent).toHaveLength(1);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("a stale pending send does not block a later reminder and is not retried", async () => {
|
|
const { f } = await setup(); const sent: string[] = [];
|
|
try {
|
|
const timestamp = Date.parse("2026-09-04T18:00:00Z");
|
|
f.db.update(reminderSettings).set({ reminders: [{ type: "weekly", time: "10:00", days: [5] }, { type: "weekly", time: "20:00", days: [5] }] }).run();
|
|
f.db.insert(reminderDeliveries).values({ id: "stale", userId: "alice", date: "2026-09-04", slot: "10:00", status: "pending", updatedAt: timestamp - 120000 }).run();
|
|
await sendDueReminders(f.db, config, transport(sent), () => timestamp);
|
|
expect(sent).toHaveLength(1);
|
|
expect(f.db.select().from(reminderDeliveries).where(eq(reminderDeliveries.id, "stale")).get()?.status).toBe("uncertain");
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test("messages include names only for the applicable reminders in the delivery batch", async () => {
|
|
const { f } = await setup(); const sent: string[] = [];
|
|
try {
|
|
f.db.update(reminderSettings).set({ reminders: [
|
|
{ type: "weekly", name: "Morning check-in", time: "09:00", days: [5] },
|
|
{ type: "date", name: "Midday review", time: "12:00", date: "2026-09-04" },
|
|
{ type: "date", name: "Tomorrow's review", time: "12:00", date: "2026-09-05" },
|
|
{ type: "weekly", name: "Evening review", time: "20:00", days: [5] },
|
|
] }).run();
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T10:00:00Z"));
|
|
const first = JSON.parse(sent[0]!).content;
|
|
expect(first).toContain("Reminder: Morning check-in; Midday review\n");
|
|
expect(first).not.toContain("Tomorrow"); expect(first).not.toContain("Evening review"); expect(first).not.toContain("Private habit name");
|
|
await sendDueReminders(f.db, config, transport(sent), () => Date.parse("2026-09-04T18:00:00Z"));
|
|
const second = JSON.parse(sent[1]!).content;
|
|
expect(second).toContain("Reminder: Evening review\n"); expect(second).not.toContain("Morning check-in");
|
|
expect(JSON.parse(sent[1]!).allowed_mentions.parse).toEqual([]);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
|
|
test('delivered slots skip habit evaluation while later slots and retries remain eligible', async () => {
|
|
const { f } = await setup(); const sent: string[] = [];
|
|
const { HabitService } = await import('../habits/service');
|
|
let sync: ReturnType<typeof spyOn> | undefined;
|
|
try {
|
|
f.db.update(reminderSettings).set({ quietStart: '00:00', quietEnd: '00:00', reminders: [
|
|
{ type: 'date', date: '2026-09-04', time: '10:00' }, { type: 'date', date: '2026-09-04', time: '12:00' },
|
|
] }).run();
|
|
const tick = (clock: string) => sendDueReminders(f.db, config, transport(sent), () => Date.parse(`2026-09-04T${clock}:00Z`));
|
|
await tick('08:00'); expect(sent).toHaveLength(1);
|
|
sync = spyOn(HabitService.prototype, 'sync');
|
|
for (let i = 0; i < 5; i++) await tick('08:30');
|
|
expect(sync).not.toHaveBeenCalled(); expect(sent).toHaveLength(1);
|
|
await tick('10:00'); expect(sync).toHaveBeenCalled(); expect(sent).toHaveLength(2);
|
|
} finally { sync?.mockRestore(); f.close(); }
|
|
});
|
|
|
|
test('reminder catch-up yields and rechecks opt-out before claiming or sending', async () => {
|
|
const f = fixture(); let requests = 0;
|
|
try {
|
|
await f.json('/habits', 'POST', { name: 'Backlog', method: 'tasks', tasks: Array.from({ length: 50 }, (_, i) => ({ name: `Task ${i}` })) }, 201);
|
|
f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run();
|
|
const work = sendDueReminders(f.db, config, async () => { requests++; throw new Error('No sends expected'); }, () => Date.parse('2027-09-04T18:00:00Z'));
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
f.db.update(reminderSettings).set({ enabled: false }).run();
|
|
await work;
|
|
expect(requests).toBe(0);
|
|
expect(f.db.select().from(reminderDeliveries).all()).toHaveLength(0);
|
|
} finally { f.close(); }
|
|
});
|
|
|
|
test('an opt-out queued at readiness completion prevents the final reminder POST', async () => {
|
|
const { f } = await setup(); let posts = 0, evaluations = 0;
|
|
const { HabitService } = await import('../habits/service');
|
|
const original = HabitService.withPrepared;
|
|
const prepare = spyOn(HabitService, 'withPrepared').mockImplementation((db, user, now, run) => original(db, user, now, service => {
|
|
const result = run(service);
|
|
if (++evaluations === 2) queueMicrotask(() => f.db.update(reminderSettings).set({ enabled: false }).run());
|
|
return result;
|
|
}));
|
|
try {
|
|
await sendDueReminders(f.db, config, async url => {
|
|
if (!url.endsWith('/users/@me/channels')) posts++;
|
|
return Response.json({ id: '223456789012345678' });
|
|
}, () => Date.parse('2026-09-04T18:00:00Z'));
|
|
expect(evaluations).toBe(2);
|
|
expect(posts).toBe(0);
|
|
expect(f.db.select().from(reminderDeliveries).get()?.status).toBe('skipped');
|
|
} finally { prepare.mockRestore(); f.close(); }
|
|
});
|