Harden notification digests with timezone settings and cron tests
swadhinbiswas committed 8 months ago
8424830
1 parent 148b673
commit 8424830
5 files changed
+536 -5
Showing 5 changed files+536-5
▼src/lib/chat-notifications.ts
+248-0
| @@ -6,8 +6,10 @@ | ||
6 | 6 | import { pgTable, text, timestamp, boolean, jsonb } from "drizzle-orm/pg-core"; |
7 | 7 | import { getDatabase, schema } from "@/db"; |
8 | 8 | import { eq, and, gte, inArray, desc } from "drizzle-orm"; |
9 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
9 | 10 | import { logger } from "./logger"; |
10 | 11 | import { repositories } from "@/db/schema/repositories"; |
12 | +import { sendEmail as sendPlatformEmail } from "./email"; | |
11 | 13 | |
12 | 14 | // ============================================================================ |
13 | 15 | // SCHEMA |
| @@ -638,3 +640,249 @@ export async function generateDigestEmail(options: DigestOptions): Promise<{ | ||
638 | 640 | |
639 | 641 | return { subject, html, itemCount }; |
640 | 642 | } |
643 | + | |
644 | +function parseDigestTimeToUtcMinutes(value: string | null | undefined): number { | |
645 | + const fallback = "09:00"; | |
646 | + const [hourRaw, minuteRaw] = (value || fallback).split(":"); | |
647 | + const hour = Number.parseInt(hourRaw || "9", 10); | |
648 | + const minute = Number.parseInt(minuteRaw || "0", 10); | |
649 | + if (!Number.isFinite(hour) || !Number.isFinite(minute)) return 9 * 60; | |
650 | + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return 9 * 60; | |
651 | + return hour * 60 + minute; | |
652 | +} | |
653 | + | |
654 | +function normalizeDigestDay(value: number | null | undefined): number { | |
655 | + if (!Number.isInteger(value)) return 1; | |
656 | + const day = Number(value); | |
657 | + if (day < 1 || day > 7) return 1; | |
658 | + return day; | |
659 | +} | |
660 | + | |
661 | +function normalizeDigestTimeZone(value: string | null | undefined): string { | |
662 | + if (!value || typeof value !== "string") return "UTC"; | |
663 | + try { | |
664 | + Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); | |
665 | + return value; | |
666 | + } catch { | |
667 | + return "UTC"; | |
668 | + } | |
669 | +} | |
670 | + | |
671 | +function getIsoWeekdayFromShortName(value: string): number { | |
672 | + const normalized = value.toLowerCase(); | |
673 | + const map: Record<string, number> = { | |
674 | + mon: 1, | |
675 | + tue: 2, | |
676 | + wed: 3, | |
677 | + thu: 4, | |
678 | + fri: 5, | |
679 | + sat: 6, | |
680 | + sun: 7, | |
681 | + }; | |
682 | + return map[normalized] ?? 1; | |
683 | +} | |
684 | + | |
685 | +function getZonedDateParts(date: Date, timeZone: string): { | |
686 | + year: number; | |
687 | + month: number; | |
688 | + day: number; | |
689 | + hour: number; | |
690 | + minute: number; | |
691 | + isoWeekday: number; | |
692 | +} { | |
693 | + const formatter = new Intl.DateTimeFormat("en-US", { | |
694 | + timeZone, | |
695 | + year: "numeric", | |
696 | + month: "2-digit", | |
697 | + day: "2-digit", | |
698 | + hour: "2-digit", | |
699 | + minute: "2-digit", | |
700 | + weekday: "short", | |
701 | + hourCycle: "h23", | |
702 | + }); | |
703 | + const parts = formatter.formatToParts(date); | |
704 | + const getPart = (type: Intl.DateTimeFormatPartTypes): string => | |
705 | + parts.find((part) => part.type === type)?.value || ""; | |
706 | + | |
707 | + return { | |
708 | + year: Number.parseInt(getPart("year"), 10), | |
709 | + month: Number.parseInt(getPart("month"), 10), | |
710 | + day: Number.parseInt(getPart("day"), 10), | |
711 | + hour: Number.parseInt(getPart("hour"), 10), | |
712 | + minute: Number.parseInt(getPart("minute"), 10), | |
713 | + isoWeekday: getIsoWeekdayFromShortName(getPart("weekday")), | |
714 | + }; | |
715 | +} | |
716 | + | |
717 | +function getDateKey(year: number, month: number, day: number): number { | |
718 | + return year * 10000 + month * 100 + day; | |
719 | +} | |
720 | + | |
721 | +function getMondayWeekStartKey(year: number, month: number, day: number, isoWeekday: number): number { | |
722 | + const diffToMonday = isoWeekday - 1; | |
723 | + const mondayUtc = new Date(Date.UTC(year, month - 1, day)); | |
724 | + mondayUtc.setUTCDate(mondayUtc.getUTCDate() - diffToMonday); | |
725 | + return getDateKey( | |
726 | + mondayUtc.getUTCFullYear(), | |
727 | + mondayUtc.getUTCMonth() + 1, | |
728 | + mondayUtc.getUTCDate() | |
729 | + ); | |
730 | +} | |
731 | + | |
732 | +export function shouldSendDigestNow( | |
733 | + digestType: "daily" | "weekly" | "none", | |
734 | + digestTime: string | null | undefined, | |
735 | + digestDay: number | null | undefined, | |
736 | + lastSentAt: Date | null | undefined, | |
737 | + now: Date, | |
738 | + timezone: string | null | undefined = "UTC" | |
739 | +): boolean { | |
740 | + if (digestType === "none") return false; | |
741 | + | |
742 | + const timeZone = normalizeDigestTimeZone(timezone); | |
743 | + const nowParts = getZonedDateParts(now, timeZone); | |
744 | + const nowMinutes = nowParts.hour * 60 + nowParts.minute; | |
745 | + const dueMinutes = parseDigestTimeToUtcMinutes(digestTime); | |
746 | + if (nowMinutes < dueMinutes) return false; | |
747 | + | |
748 | + if (digestType === "daily") { | |
749 | + if (!lastSentAt) return true; | |
750 | + const lastSentParts = getZonedDateParts(lastSentAt, timeZone); | |
751 | + return getDateKey(lastSentParts.year, lastSentParts.month, lastSentParts.day) | |
752 | + !== getDateKey(nowParts.year, nowParts.month, nowParts.day); | |
753 | + } | |
754 | + | |
755 | + const configuredDay = normalizeDigestDay(digestDay); // 1 = Monday | |
756 | + if (nowParts.isoWeekday !== configuredDay) return false; | |
757 | + | |
758 | + if (!lastSentAt) return true; | |
759 | + const lastSentParts = getZonedDateParts(lastSentAt, timeZone); | |
760 | + const lastSentDateKey = getDateKey(lastSentParts.year, lastSentParts.month, lastSentParts.day); | |
761 | + const weekStartKey = getMondayWeekStartKey(nowParts.year, nowParts.month, nowParts.day, nowParts.isoWeekday); | |
762 | + return lastSentDateKey < weekStartKey; | |
763 | +} | |
764 | + | |
765 | +export interface DigestRunOptions { | |
766 | + now?: Date; | |
767 | + dryRun?: boolean; | |
768 | +} | |
769 | + | |
770 | +export interface DigestRunResult { | |
771 | + checked: number; | |
772 | + due: number; | |
773 | + sent: number; | |
774 | + skippedNoEmail: number; | |
775 | + skippedEmpty: number; | |
776 | + failed: number; | |
777 | +} | |
778 | + | |
779 | +export async function runDueDigests(options: DigestRunOptions = {}): Promise<DigestRunResult> { | |
780 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
781 | + const now = options.now ?? new Date(); | |
782 | + const dryRun = options.dryRun ?? false; | |
783 | + | |
784 | + const result: DigestRunResult = { | |
785 | + checked: 0, | |
786 | + due: 0, | |
787 | + sent: 0, | |
788 | + skippedNoEmail: 0, | |
789 | + skippedEmpty: 0, | |
790 | + failed: 0, | |
791 | + }; | |
792 | + | |
793 | + const settings = await db.query.emailDigestSettings.findMany({ | |
794 | + where: and( | |
795 | + inArray(schema.emailDigestSettings.digestType, ["daily", "weekly"]) | |
796 | + ), | |
797 | + }); | |
798 | + | |
799 | + result.checked = settings.length; | |
800 | + | |
801 | + for (const setting of settings) { | |
802 | + const digestType = (setting.digestType as "daily" | "weekly" | "none") || "none"; | |
803 | + if (digestType === "none") continue; | |
804 | + const due = shouldSendDigestNow( | |
805 | + digestType, | |
806 | + setting.digestTime, | |
807 | + setting.digestDay, | |
808 | + setting.lastSentAt, | |
809 | + now, | |
810 | + setting.timezone | |
811 | + ); | |
812 | + if (!due) continue; | |
813 | + result.due++; | |
814 | + | |
815 | + try { | |
816 | + const user = await db.query.users.findFirst({ | |
817 | + where: eq(schema.users.id, setting.userId), | |
818 | + columns: { id: true, email: true, isActive: true }, | |
819 | + }); | |
820 | + | |
821 | + if (!user || !user.isActive || !user.email) { | |
822 | + result.skippedNoEmail++; | |
823 | + if (!dryRun) { | |
824 | + await db | |
825 | + .update(schema.emailDigestSettings) | |
826 | + .set({ lastSentAt: now, updatedAt: now }) | |
827 | + .where(eq(schema.emailDigestSettings.id, setting.id)); | |
828 | + } | |
829 | + continue; | |
830 | + } | |
831 | + | |
832 | + const prefs = await db.query.notificationPreferences.findMany({ | |
833 | + where: and( | |
834 | + eq(schema.notificationPreferences.userId, user.id), | |
835 | + eq(schema.notificationPreferences.emailEnabled, true) | |
836 | + ), | |
837 | + columns: { eventType: true }, | |
838 | + }); | |
839 | + const includeEvents = prefs.map((p) => p.eventType as NotificationEvent); | |
840 | + | |
841 | + const digest = await generateDigestEmail({ | |
842 | + userId: user.id, | |
843 | + period: digestType, | |
844 | + includeEvents, | |
845 | + }); | |
846 | + | |
847 | + if (digest.itemCount === 0) { | |
848 | + result.skippedEmpty++; | |
849 | + if (!dryRun) { | |
850 | + await db | |
851 | + .update(schema.emailDigestSettings) | |
852 | + .set({ lastSentAt: now, updatedAt: now }) | |
853 | + .where(eq(schema.emailDigestSettings.id, setting.id)); | |
854 | + } | |
855 | + continue; | |
856 | + } | |
857 | + | |
858 | + if (!dryRun) { | |
859 | + const sent = await sendPlatformEmail({ | |
860 | + to: user.email, | |
861 | + subject: digest.subject, | |
862 | + html: digest.html, | |
863 | + text: `${digest.subject}\n\nYou have ${digest.itemCount} new notification(s).`, | |
864 | + }); | |
865 | + if (!sent) { | |
866 | + result.failed++; | |
867 | + continue; | |
868 | + } | |
869 | + | |
870 | + await db | |
871 | + .update(schema.emailDigestSettings) | |
872 | + .set({ lastSentAt: now, updatedAt: now }) | |
873 | + .where(eq(schema.emailDigestSettings.id, setting.id)); | |
874 | + } | |
875 | + | |
876 | + result.sent++; | |
877 | + } catch (error) { | |
878 | + result.failed++; | |
879 | + logger.error({ | |
880 | + userId: setting.userId, | |
881 | + digestSettingId: setting.id, | |
882 | + error: error instanceof Error ? error.message : "Unknown error", | |
883 | + }, "Digest send failed"); | |
884 | + } | |
885 | + } | |
886 | + | |
887 | + return result; | |
888 | +} | |
641 | 889 | |
▼src/pages/api/user/notification-preferences.ts
+50-5
| @@ -18,6 +18,41 @@ import { logger } from "@/lib/logger"; | ||
18 | 18 | |
19 | 19 | // ... existing imports ... |
20 | 20 | |
21 | +type DigestType = "none" | "daily" | "weekly"; | |
22 | + | |
23 | +function normalizeDigestType(value: unknown): DigestType { | |
24 | + if (value === "daily" || value === "weekly" || value === "none") return value; | |
25 | + return "none"; | |
26 | +} | |
27 | + | |
28 | +function normalizeDigestTime(value: unknown): string { | |
29 | + if (typeof value !== "string") return "09:00"; | |
30 | + const match = /^(\d{2}):(\d{2})$/.exec(value); | |
31 | + if (!match) return "09:00"; | |
32 | + const hour = Number.parseInt(match[1], 10); | |
33 | + const minute = Number.parseInt(match[2], 10); | |
34 | + if (!Number.isInteger(hour) || !Number.isInteger(minute)) return "09:00"; | |
35 | + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return "09:00"; | |
36 | + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; | |
37 | +} | |
38 | + | |
39 | +function normalizeDigestDay(value: unknown): number { | |
40 | + if (!Number.isInteger(value)) return 1; | |
41 | + const day = Number(value); | |
42 | + if (day < 1 || day > 7) return 1; | |
43 | + return day; | |
44 | +} | |
45 | + | |
46 | +function normalizeTimeZone(value: unknown): string { | |
47 | + if (typeof value !== "string" || !value) return "UTC"; | |
48 | + try { | |
49 | + Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); | |
50 | + return value; | |
51 | + } catch { | |
52 | + return "UTC"; | |
53 | + } | |
54 | +} | |
55 | + | |
21 | 56 | export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => { |
22 | 57 | const user = locals.user; |
23 | 58 | |
| @@ -114,6 +149,11 @@ export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => { | ||
114 | 149 | |
115 | 150 | // Save digest settings |
116 | 151 | if (digestSettings) { |
152 | + const digestType = normalizeDigestType(digestSettings.digestType); | |
153 | + const digestTime = normalizeDigestTime(digestSettings.digestTime); | |
154 | + const digestDay = normalizeDigestDay(digestSettings.digestDay); | |
155 | + const timezone = normalizeTimeZone(digestSettings.timezone); | |
156 | + | |
117 | 157 | const existingDigest = await db.query.emailDigestSettings.findFirst({ |
118 | 158 | where: eq(schema.emailDigestSettings.userId, user.id), |
119 | 159 | }); |
| @@ -122,8 +162,10 @@ export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => { | ||
122 | 162 | await db |
123 | 163 | .update(schema.emailDigestSettings) |
124 | 164 | .set({ |
125 | - digestType: digestSettings.digestType || "none", | |
126 | - digestTime: digestSettings.digestTime || "09:00", | |
165 | + digestType, | |
166 | + digestTime, | |
167 | + digestDay, | |
168 | + timezone, | |
127 | 169 | updatedAt: new Date(), |
128 | 170 | }) |
129 | 171 | .where(eq(schema.emailDigestSettings.id, existingDigest.id)); |
| @@ -131,9 +173,10 @@ export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => { | ||
131 | 173 | await db.insert(schema.emailDigestSettings).values({ |
132 | 174 | id: generateId(), |
133 | 175 | userId: user.id, |
134 | - digestType: digestSettings.digestType || "none", | |
135 | - digestTime: digestSettings.digestTime || "09:00", | |
136 | - timezone: "UTC", | |
176 | + digestType, | |
177 | + digestTime, | |
178 | + digestDay, | |
179 | + timezone, | |
137 | 180 | createdAt: new Date(), |
138 | 181 | updatedAt: new Date(), |
139 | 182 | }); |
| @@ -201,6 +244,8 @@ export const GET: APIRoute = withErrorHandler(async ({ locals }) => { | ||
201 | 244 | ? { |
202 | 245 | digestType: digestSettings.digestType, |
203 | 246 | digestTime: digestSettings.digestTime, |
247 | + digestDay: digestSettings.digestDay, | |
248 | + timezone: digestSettings.timezone, | |
204 | 249 | } |
205 | 250 | : null, |
206 | 251 | }), |
207 | 252 | |
▼src/pages/settings/notification-preferences.astro
+57-0
| @@ -154,6 +154,8 @@ let quietHours = { | ||
154 | 154 | let digestSettings = { |
155 | 155 | digestType: "none" as "none" | "daily" | "weekly", |
156 | 156 | digestTime: "09:00", |
157 | + digestDay: 1, | |
158 | + timezone: "UTC", | |
157 | 159 | }; |
158 | 160 | |
159 | 161 | try { |
| @@ -195,6 +197,8 @@ try { | ||
195 | 197 | digestSettings = { |
196 | 198 | digestType: dbDigest.digestType as "none" | "daily" | "weekly", |
197 | 199 | digestTime: dbDigest.digestTime || "09:00", |
200 | + digestDay: dbDigest.digestDay ?? 1, | |
201 | + timezone: dbDigest.timezone || "UTC", | |
198 | 202 | }; |
199 | 203 | } |
200 | 204 | } catch (e) { |
| @@ -500,6 +504,33 @@ for (const event of eventTypes) { | ||
500 | 504 | </div> |
501 | 505 | </button> |
502 | 506 | </div> |
507 | + | |
508 | + <div id="digest-config" class:list={["mt-4 grid grid-cols-2 gap-4", digestSettings.digestType === "none" && "opacity-50 pointer-events-none"]}> | |
509 | + <div> | |
510 | + <label class="block text-sm font-medium text-gray-300 mb-1">Send Time (local)</label> | |
511 | + <input | |
512 | + type="time" | |
513 | + id="digest-time" | |
514 | + value={digestSettings.digestTime} | |
515 | + class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white" | |
516 | + /> | |
517 | + </div> | |
518 | + <div id="digest-day-wrap" class:list={[digestSettings.digestType !== "weekly" && "opacity-50 pointer-events-none"]}> | |
519 | + <label class="block text-sm font-medium text-gray-300 mb-1">Weekly Day</label> | |
520 | + <select | |
521 | + id="digest-day" | |
522 | + class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white" | |
523 | + > | |
524 | + <option value="1" selected={digestSettings.digestDay === 1}>Monday</option> | |
525 | + <option value="2" selected={digestSettings.digestDay === 2}>Tuesday</option> | |
526 | + <option value="3" selected={digestSettings.digestDay === 3}>Wednesday</option> | |
527 | + <option value="4" selected={digestSettings.digestDay === 4}>Thursday</option> | |
528 | + <option value="5" selected={digestSettings.digestDay === 5}>Friday</option> | |
529 | + <option value="6" selected={digestSettings.digestDay === 6}>Saturday</option> | |
530 | + <option value="7" selected={digestSettings.digestDay === 7}>Sunday</option> | |
531 | + </select> | |
532 | + </div> | |
533 | + </div> | |
503 | 534 | </div> |
504 | 535 | </div> |
505 | 536 | </div> |
| @@ -543,6 +574,21 @@ for (const event of eventTypes) { | ||
543 | 574 | document |
544 | 575 | .querySelector("[data-digest].bg-blue-500\\/10") |
545 | 576 | ?.getAttribute("data-digest") || "none"; |
577 | + const digestConfig = document.getElementById("digest-config"); | |
578 | + const digestDayWrap = document.getElementById("digest-day-wrap"); | |
579 | + const refreshDigestConfigState = () => { | |
580 | + if (selectedDigest === "none") { | |
581 | + digestConfig?.classList.add("opacity-50", "pointer-events-none"); | |
582 | + } else { | |
583 | + digestConfig?.classList.remove("opacity-50", "pointer-events-none"); | |
584 | + } | |
585 | + | |
586 | + if (selectedDigest === "weekly") { | |
587 | + digestDayWrap?.classList.remove("opacity-50", "pointer-events-none"); | |
588 | + } else { | |
589 | + digestDayWrap?.classList.add("opacity-50", "pointer-events-none"); | |
590 | + } | |
591 | + }; | |
546 | 592 | document.querySelectorAll("[data-digest]").forEach((btn) => { |
547 | 593 | btn.addEventListener("click", () => { |
548 | 594 | document.querySelectorAll("[data-digest]").forEach((b) => { |
| @@ -569,8 +615,10 @@ for (const event of eventTypes) { | ||
569 | 615 | ); |
570 | 616 | const digest = btn.getAttribute("data-digest"); |
571 | 617 | if (digest) selectedDigest = digest; |
618 | + refreshDigestConfigState(); | |
572 | 619 | }); |
573 | 620 | }); |
621 | + refreshDigestConfigState(); | |
574 | 622 | |
575 | 623 | // Save button |
576 | 624 | document.getElementById("save-btn")?.addEventListener("click", async () => { |
| @@ -644,6 +692,12 @@ for (const event of eventTypes) { | ||
644 | 692 | const allowUrgent = document.getElementById( |
645 | 693 | "allow-urgent", |
646 | 694 | ) as HTMLInputElement | null; |
695 | + const digestTime = document.getElementById( | |
696 | + "digest-time", | |
697 | + ) as HTMLInputElement | null; | |
698 | + const digestDay = document.getElementById( | |
699 | + "digest-day", | |
700 | + ) as HTMLSelectElement | null; | |
647 | 701 | |
648 | 702 | const data = { |
649 | 703 | preferences, |
| @@ -655,6 +709,9 @@ for (const event of eventTypes) { | ||
655 | 709 | }, |
656 | 710 | digestSettings: { |
657 | 711 | digestType: selectedDigest, |
712 | + digestTime: digestTime?.value || "09:00", | |
713 | + digestDay: Number.parseInt(digestDay?.value || "1", 10), | |
714 | + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", | |
658 | 715 | }, |
659 | 716 | }; |
660 | 717 | |
661 | 718 | |
▼tests/unit/digest-scheduler.test.ts
+74-0
| @@ -0,0 +1,74 @@ | ||
1 | +import { describe, expect, it } from "vitest"; | |
2 | +import { shouldSendDigestNow } from "@/lib/chat-notifications"; | |
3 | + | |
4 | +describe("digest scheduler logic", () => { | |
5 | + it("sends daily digest when due time passed and never sent", () => { | |
6 | + const now = new Date("2026-02-18T10:00:00.000Z"); | |
7 | + const shouldSend = shouldSendDigestNow("daily", "09:00", 1, null, now); | |
8 | + expect(shouldSend).toBe(true); | |
9 | + }); | |
10 | + | |
11 | + it("does not send daily digest before due time", () => { | |
12 | + const now = new Date("2026-02-18T08:59:00.000Z"); | |
13 | + const shouldSend = shouldSendDigestNow("daily", "09:00", 1, null, now); | |
14 | + expect(shouldSend).toBe(false); | |
15 | + }); | |
16 | + | |
17 | + it("does not re-send daily digest on same UTC day", () => { | |
18 | + const now = new Date("2026-02-18T12:00:00.000Z"); | |
19 | + const lastSentAt = new Date("2026-02-18T09:10:00.000Z"); | |
20 | + const shouldSend = shouldSendDigestNow("daily", "09:00", 1, lastSentAt, now); | |
21 | + expect(shouldSend).toBe(false); | |
22 | + }); | |
23 | + | |
24 | + it("sends weekly digest on configured day after due time", () => { | |
25 | + // Monday | |
26 | + const now = new Date("2026-02-16T10:00:00.000Z"); | |
27 | + const shouldSend = shouldSendDigestNow("weekly", "09:00", 1, null, now); | |
28 | + expect(shouldSend).toBe(true); | |
29 | + }); | |
30 | + | |
31 | + it("does not send weekly digest on non-configured day", () => { | |
32 | + // Tuesday | |
33 | + const now = new Date("2026-02-17T10:00:00.000Z"); | |
34 | + const shouldSend = shouldSendDigestNow("weekly", "09:00", 1, null, now); | |
35 | + expect(shouldSend).toBe(false); | |
36 | + }); | |
37 | + | |
38 | + it("does not re-send weekly digest in same week", () => { | |
39 | + // Monday this week | |
40 | + const now = new Date("2026-02-16T12:00:00.000Z"); | |
41 | + // Also Monday this week | |
42 | + const lastSentAt = new Date("2026-02-16T09:15:00.000Z"); | |
43 | + const shouldSend = shouldSendDigestNow("weekly", "09:00", 1, lastSentAt, now); | |
44 | + expect(shouldSend).toBe(false); | |
45 | + }); | |
46 | + | |
47 | + it("uses timezone for daily due-time checks", () => { | |
48 | + // 08:30 in America/New_York (UTC-5 in February) | |
49 | + const beforeDue = new Date("2026-02-18T13:30:00.000Z"); | |
50 | + const shouldSendBeforeDue = shouldSendDigestNow("daily", "09:00", 1, null, beforeDue, "America/New_York"); | |
51 | + expect(shouldSendBeforeDue).toBe(false); | |
52 | + | |
53 | + // 09:30 in America/New_York | |
54 | + const afterDue = new Date("2026-02-18T14:30:00.000Z"); | |
55 | + const shouldSendAfterDue = shouldSendDigestNow("daily", "09:00", 1, null, afterDue, "America/New_York"); | |
56 | + expect(shouldSendAfterDue).toBe(true); | |
57 | + }); | |
58 | + | |
59 | + it("normalizes invalid weekly day to Monday", () => { | |
60 | + // Monday UTC | |
61 | + const now = new Date("2026-02-16T10:00:00.000Z"); | |
62 | + const shouldSend = shouldSendDigestNow("weekly", "09:00", 99, null, now); | |
63 | + expect(shouldSend).toBe(true); | |
64 | + }); | |
65 | + | |
66 | + it("does not re-send daily digest on same local date in timezone", () => { | |
67 | + // 00:30 JST on Feb 18, 2026 | |
68 | + const now = new Date("2026-02-17T15:30:00.000Z"); | |
69 | + // 00:05 JST on the same local date | |
70 | + const lastSentAt = new Date("2026-02-17T15:05:00.000Z"); | |
71 | + const shouldSend = shouldSendDigestNow("daily", "00:00", 1, lastSentAt, now, "Asia/Tokyo"); | |
72 | + expect(shouldSend).toBe(false); | |
73 | + }); | |
74 | +}); | |
0 | 75 | |
▼tests/unit/notification-digest-cron.test.ts
+107-0
| @@ -0,0 +1,107 @@ | ||
1 | +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; | |
2 | + | |
3 | +const runDueDigestsMock = vi.fn(); | |
4 | +const loggerInfoMock = vi.fn(); | |
5 | +const loggerErrorMock = vi.fn(); | |
6 | + | |
7 | +vi.mock("@/lib/chat-notifications", () => ({ | |
8 | + runDueDigests: runDueDigestsMock, | |
9 | +})); | |
10 | + | |
11 | +vi.mock("@/lib/logger", () => ({ | |
12 | + logger: { | |
13 | + info: loggerInfoMock, | |
14 | + error: loggerErrorMock, | |
15 | + }, | |
16 | +})); | |
17 | + | |
18 | +describe("notification digest cron API", () => { | |
19 | + beforeEach(() => { | |
20 | + runDueDigestsMock.mockReset(); | |
21 | + loggerInfoMock.mockReset(); | |
22 | + loggerErrorMock.mockReset(); | |
23 | + vi.unstubAllEnvs(); | |
24 | + }); | |
25 | + | |
26 | + afterEach(() => { | |
27 | + vi.unstubAllEnvs(); | |
28 | + }); | |
29 | + | |
30 | + it("returns 401 when CRON_SECRET is missing", async () => { | |
31 | + vi.stubEnv("CRON_SECRET", ""); | |
32 | + vi.resetModules(); | |
33 | + const mod = await import("@/pages/api/cron/notification-digests"); | |
34 | + | |
35 | + const request = new Request("http://localhost/api/cron/notification-digests", { | |
36 | + method: "POST", | |
37 | + headers: { Authorization: "Bearer anything" }, | |
38 | + }); | |
39 | + | |
40 | + const response = await mod.POST({ request, url: new URL(request.url) } as any); | |
41 | + expect(response.status).toBe(401); | |
42 | + expect(runDueDigestsMock).not.toHaveBeenCalled(); | |
43 | + }); | |
44 | + | |
45 | + it("returns 401 when token is invalid", async () => { | |
46 | + vi.stubEnv("CRON_SECRET", "super-secret"); | |
47 | + vi.resetModules(); | |
48 | + const mod = await import("@/pages/api/cron/notification-digests"); | |
49 | + | |
50 | + const request = new Request("http://localhost/api/cron/notification-digests", { | |
51 | + method: "POST", | |
52 | + headers: { Authorization: "Bearer wrong" }, | |
53 | + }); | |
54 | + | |
55 | + const response = await mod.POST({ request, url: new URL(request.url) } as any); | |
56 | + expect(response.status).toBe(401); | |
57 | + expect(runDueDigestsMock).not.toHaveBeenCalled(); | |
58 | + }); | |
59 | + | |
60 | + it("passes dryRun=true to digest runner", async () => { | |
61 | + vi.stubEnv("CRON_SECRET", "super-secret"); | |
62 | + runDueDigestsMock.mockResolvedValue({ | |
63 | + checked: 4, | |
64 | + due: 2, | |
65 | + sent: 2, | |
66 | + skippedNoEmail: 0, | |
67 | + skippedEmpty: 0, | |
68 | + failed: 0, | |
69 | + }); | |
70 | + vi.resetModules(); | |
71 | + const mod = await import("@/pages/api/cron/notification-digests"); | |
72 | + | |
73 | + const request = new Request("http://localhost/api/cron/notification-digests?dryRun=true", { | |
74 | + method: "POST", | |
75 | + headers: { Authorization: "Bearer super-secret" }, | |
76 | + }); | |
77 | + | |
78 | + const response = await mod.POST({ request, url: new URL(request.url) } as any); | |
79 | + const body = await response.json(); | |
80 | + | |
81 | + expect(response.status).toBe(200); | |
82 | + expect(body.success).toBe(true); | |
83 | + expect(body.dryRun).toBe(true); | |
84 | + expect(runDueDigestsMock).toHaveBeenCalledWith({ dryRun: true }); | |
85 | + expect(loggerInfoMock).toHaveBeenCalledTimes(1); | |
86 | + }); | |
87 | + | |
88 | + it("returns 500 when digest runner throws", async () => { | |
89 | + vi.stubEnv("CRON_SECRET", "super-secret"); | |
90 | + runDueDigestsMock.mockRejectedValue(new Error("boom")); | |
91 | + vi.resetModules(); | |
92 | + const mod = await import("@/pages/api/cron/notification-digests"); | |
93 | + | |
94 | + const request = new Request("http://localhost/api/cron/notification-digests", { | |
95 | + method: "POST", | |
96 | + headers: { Authorization: "Bearer super-secret" }, | |
97 | + }); | |
98 | + | |
99 | + const response = await mod.POST({ request, url: new URL(request.url) } as any); | |
100 | + const body = await response.json(); | |
101 | + | |
102 | + expect(response.status).toBe(500); | |
103 | + expect(body.success).toBe(false); | |
104 | + expect(body.error).toBe("boom"); | |
105 | + expect(loggerErrorMock).toHaveBeenCalledTimes(1); | |
106 | + }); | |
107 | +}); | |
0 | 108 | |