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
▼src/lib/chat-notifications.ts
+248-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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