feat: add opt-in daily Discord reminders with quiet hours
This commit is contained in:
100
src/reminders/worker.test.ts
Normal file
100
src/reminders/worker.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { expect, test } 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([]);
|
||||
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, 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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user