Implement notifications cron endpoint (P2.5)

Add daily email notification system that sends training decisions
at each user's preferred time in their timezone.

Features:
- Timezone-aware notification matching using Intl.DateTimeFormat
- DailyLog-based notifications with duplicate prevention
- Nutrition guidance integration via getNutritionGuidance
- Graceful error handling (continues processing on per-user failures)
- Summary response with detailed stats

Includes 20 tests covering:
- CRON_SECRET authentication
- Timezone matching (UTC and America/New_York)
- DailyLog existence and already-sent checks
- Email content assembly
- Error handling and response format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 19:56:16 +00:00
parent fc970a2c61
commit 901543cb4d
3 changed files with 615 additions and 9 deletions

View File

@@ -0,0 +1,459 @@
// 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);
vi.mock("@/lib/email", () => ({
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
}));
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> = {}): 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),
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> = {}): 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);
});
});
});