// ABOUTME: Unit tests for notifications cron endpoint. // ABOUTME: Tests daily email notifications sent at user's preferred time. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DailyLog, User } from "@/types"; // Mock users and daily logs returned by PocketBase let mockUsers: User[] = []; let mockDailyLogs: DailyLog[] = []; const mockPbUpdate = vi.fn().mockResolvedValue({ id: "log123" }); // Mock PocketBase vi.mock("@/lib/pocketbase", () => ({ createPocketBaseClient: vi.fn(() => ({ collection: vi.fn((name: string) => ({ getFullList: vi.fn(async () => { if (name === "users") { return mockUsers; } if (name === "dailyLogs") { return mockDailyLogs; } return []; }), update: mockPbUpdate, })), })), })); // Mock email sending const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined); const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined); const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined); vi.mock("@/lib/email", () => ({ sendDailyEmail: (data: unknown) => mockSendDailyEmail(data), sendTokenExpirationWarning: (...args: unknown[]) => mockSendTokenExpirationWarning(...args), sendPeriodConfirmationEmail: (...args: unknown[]) => mockSendPeriodConfirmationEmail(...args), })); import { POST } from "./route"; describe("POST /api/cron/notifications", () => { const validSecret = "test-cron-secret"; // Helper to create a mock user function createMockUser(overrides: Partial = {}): User { return { id: "user123", email: "test@example.com", garminConnected: true, garminOauth1Token: "encrypted:oauth1-token", garminOauth2Token: "encrypted:oauth2-token", garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), garminRefreshTokenExpiresAt: null, calendarToken: "cal-token", lastPeriodDate: new Date("2025-01-01"), cycleLength: 28, notificationTime: "07:00", timezone: "UTC", activeOverrides: [], created: new Date("2024-01-01"), updated: new Date("2025-01-10"), ...overrides, }; } // Helper to create a mock daily log function createMockDailyLog(overrides: Partial = {}): DailyLog { return { id: "log123", user: "user123", date: new Date(), cycleDay: 5, phase: "FOLLICULAR", bodyBatteryCurrent: 85, bodyBatteryYesterdayLow: 50, hrvStatus: "Balanced", weekIntensityMinutes: 45, phaseLimit: 120, remainingMinutes: 75, trainingDecision: "TRAIN", decisionReason: "OK to train - follow phase plan", notificationSentAt: null, created: new Date(), ...overrides, }; } // Helper to create mock request with optional auth header function createMockRequest(authHeader?: string): Request { const headers = new Headers(); if (authHeader) { headers.set("authorization", authHeader); } return { headers, } as unknown as Request; } beforeEach(() => { vi.clearAllMocks(); mockUsers = []; mockDailyLogs = []; process.env.CRON_SECRET = validSecret; // Mock current time to 07:00 UTC vi.useFakeTimers(); vi.setSystemTime(new Date("2025-01-15T07:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); describe("Authentication", () => { it("returns 401 when authorization header is missing", async () => { const response = await POST(createMockRequest()); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe("Unauthorized"); }); it("returns 401 when secret is incorrect", async () => { const response = await POST(createMockRequest("Bearer wrong-secret")); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe("Unauthorized"); }); it("returns 401 when CRON_SECRET env var is not set", async () => { process.env.CRON_SECRET = ""; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(401); }); }); describe("User time matching", () => { it("sends notification when current hour matches user notificationTime in UTC", async () => { // Current time is 07:00 UTC, user wants notifications at 07:00 UTC mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = [createMockDailyLog()]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); expect(mockSendDailyEmail).toHaveBeenCalled(); }); it("does not send notification when hour does not match", async () => { // Current time is 07:00 UTC, user wants notifications at 08:00 UTC mockUsers = [ createMockUser({ notificationTime: "08:00", timezone: "UTC" }), ]; mockDailyLogs = [createMockDailyLog()]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); expect(mockSendDailyEmail).not.toHaveBeenCalled(); }); it("handles timezone conversion correctly", async () => { // Current time is 07:00 UTC = 02:00 America/New_York (EST is UTC-5) mockUsers = [ createMockUser({ notificationTime: "02:00", timezone: "America/New_York", }), ]; mockDailyLogs = [createMockDailyLog()]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); expect(mockSendDailyEmail).toHaveBeenCalled(); }); it("skips users with non-matching timezone hours", async () => { // Current time is 07:00 UTC = 02:00 EST, user wants 07:00 EST (which is 12:00 UTC) mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "America/New_York", }), ]; mockDailyLogs = [createMockDailyLog()]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); expect(mockSendDailyEmail).not.toHaveBeenCalled(); }); }); describe("DailyLog handling", () => { it("does not send notification if no DailyLog exists for today", async () => { mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = []; // No daily log const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); expect(mockSendDailyEmail).not.toHaveBeenCalled(); const body = await response.json(); expect(body.skippedNoLog).toBe(1); }); it("does not send notification if already sent today", async () => { mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = [createMockDailyLog({ notificationSentAt: new Date() })]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); expect(mockSendDailyEmail).not.toHaveBeenCalled(); const body = await response.json(); expect(body.skippedAlreadySent).toBe(1); }); it("updates notificationSentAt after sending email", async () => { mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = [createMockDailyLog({ id: "log456" })]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockPbUpdate).toHaveBeenCalledWith("log456", { notificationSentAt: expect.any(String), }); }); }); describe("Email content", () => { it("sends email with correct user email address", async () => { mockUsers = [ createMockUser({ email: "recipient@example.com", notificationTime: "07:00", timezone: "UTC", }), ]; mockDailyLogs = [createMockDailyLog()]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendDailyEmail).toHaveBeenCalledWith( expect.objectContaining({ to: "recipient@example.com", }), ); }); it("includes cycle day and phase from DailyLog", async () => { mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = [ createMockDailyLog({ cycleDay: 10, phase: "FOLLICULAR" }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendDailyEmail).toHaveBeenCalledWith( expect.objectContaining({ cycleDay: 10, phase: "FOLLICULAR", }), ); }); it("includes biometric data from DailyLog", async () => { mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = [ createMockDailyLog({ bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 45, hrvStatus: "Balanced", weekIntensityMinutes: 60, phaseLimit: 120, remainingMinutes: 60, }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendDailyEmail).toHaveBeenCalledWith( expect.objectContaining({ bodyBatteryCurrent: 90, bodyBatteryYesterdayLow: 45, hrvStatus: "Balanced", weekIntensity: 60, phaseLimit: 120, remainingMinutes: 60, }), ); }); it("includes training decision from DailyLog", async () => { mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = [ createMockDailyLog({ trainingDecision: "TRAIN", decisionReason: "OK to train - follow phase plan", }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendDailyEmail).toHaveBeenCalledWith( expect.objectContaining({ decision: expect.objectContaining({ status: "TRAIN", reason: "OK to train - follow phase plan", }), }), ); }); it("includes nutrition guidance based on cycle day", async () => { mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = [createMockDailyLog({ cycleDay: 10 })]; // Day 10 = follicular, flax+pumpkin seeds await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendDailyEmail).toHaveBeenCalledWith( expect.objectContaining({ seeds: expect.stringContaining("Flax"), carbRange: expect.any(String), ketoGuidance: expect.any(String), }), ); }); }); describe("Error handling", () => { it("continues processing other users when email sending fails", async () => { mockUsers = [ createMockUser({ id: "user1", email: "user1@example.com", notificationTime: "07:00", timezone: "UTC", }), createMockUser({ id: "user2", email: "user2@example.com", notificationTime: "07:00", timezone: "UTC", }), ]; mockDailyLogs = [ createMockDailyLog({ id: "log1", user: "user1" }), createMockDailyLog({ id: "log2", user: "user2" }), ]; // First email fails, second succeeds mockSendDailyEmail .mockRejectedValueOnce(new Error("Email error")) .mockResolvedValueOnce(undefined); const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); const body = await response.json(); expect(body.errors).toBe(1); expect(body.notificationsSent).toBe(1); }); it("handles null body battery values", async () => { mockUsers = [ createMockUser({ notificationTime: "07:00", timezone: "UTC" }), ]; mockDailyLogs = [ createMockDailyLog({ bodyBatteryCurrent: null, bodyBatteryYesterdayLow: null, }), ]; await POST(createMockRequest(`Bearer ${validSecret}`)); expect(mockSendDailyEmail).toHaveBeenCalledWith( expect.objectContaining({ bodyBatteryCurrent: null, bodyBatteryYesterdayLow: null, }), ); }); }); describe("Response format", () => { it("returns success with zero notifications when no users match", async () => { mockUsers = []; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); const body = await response.json(); expect(body).toMatchObject({ success: true, notificationsSent: 0, errors: 0, }); }); it("returns summary with counts", async () => { mockUsers = [ createMockUser({ id: "user1", notificationTime: "07:00", timezone: "UTC", }), createMockUser({ id: "user2", notificationTime: "07:00", timezone: "UTC", }), ]; mockDailyLogs = [ createMockDailyLog({ id: "log1", user: "user1" }), createMockDailyLog({ id: "log2", user: "user2" }), ]; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); expect(response.status).toBe(200); const body = await response.json(); expect(body).toMatchObject({ success: true, notificationsSent: 2, errors: 0, skippedNoLog: 0, skippedAlreadySent: 0, skippedWrongTime: 0, }); }); it("includes timestamp in response", async () => { mockUsers = []; const response = await POST(createMockRequest(`Bearer ${validSecret}`)); const body = await response.json(); expect(body.timestamp).toBeDefined(); expect(new Date(body.timestamp)).toBeInstanceOf(Date); }); }); });