Implement token expiration warnings (P3.9)

Add email warnings for Garmin token expiration at 14-day and 7-day thresholds.
When the garmin-sync cron job runs, it now checks each user's token expiry and
sends a warning email at exactly 14 days and 7 days before expiration.

Changes:
- Add sendTokenExpirationWarning() to email.ts with differentiated subject
  lines and urgency levels for 14-day vs 7-day warnings
- Integrate warning logic into garmin-sync cron route using daysUntilExpiry()
- Track warnings sent in sync response with new warningsSent counter
- Add 20 new tests (10 for email function, 10 for sync integration)

Test count: 517 → 537

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 08:24:19 +00:00
parent 6c3dd34412
commit 2ffee63a59
5 changed files with 304 additions and 19 deletions

View File

@@ -44,6 +44,7 @@ const mockFetchBodyBattery = vi
.mockResolvedValue({ current: 85, yesterdayLow: 45 });
const mockFetchIntensityMinutes = vi.fn().mockResolvedValue(60);
const mockIsTokenExpired = vi.fn().mockReturnValue(false);
const mockDaysUntilExpiry = vi.fn().mockReturnValue(30);
vi.mock("@/lib/garmin", () => ({
fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args),
@@ -51,6 +52,15 @@ vi.mock("@/lib/garmin", () => ({
fetchIntensityMinutes: (...args: unknown[]) =>
mockFetchIntensityMinutes(...args),
isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args),
daysUntilExpiry: (...args: unknown[]) => mockDaysUntilExpiry(...args),
}));
// Mock email sending
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/email", () => ({
sendTokenExpirationWarning: (...args: unknown[]) =>
mockSendTokenExpirationWarning(...args),
}));
import { POST } from "./route";
@@ -93,6 +103,7 @@ describe("POST /api/cron/garmin-sync", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUsers = [];
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
process.env.CRON_SECRET = validSecret;
});
@@ -381,5 +392,128 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.timestamp).toBeDefined();
expect(new Date(body.timestamp)).toBeInstanceOf(Date);
});
it("includes warningsSent in response", async () => {
mockUsers = [];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
const body = await response.json();
expect(body.warningsSent).toBeDefined();
expect(body.warningsSent).toBe(0);
});
});
describe("Token expiration warnings", () => {
it("sends warning email when token expires in exactly 14 days", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(14);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user@example.com",
14,
);
const body = await response.json();
expect(body.warningsSent).toBe(1);
});
it("sends warning email when token expires in exactly 7 days", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(7);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user@example.com",
7,
);
const body = await response.json();
expect(body.warningsSent).toBe(1);
});
it("does not send warning when token expires in 30 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(30);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 15 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(15);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 8 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(8);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 6 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(6);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("sends warnings for multiple users on different thresholds", async () => {
mockUsers = [
createMockUser({ id: "user1", email: "user1@example.com" }),
createMockUser({ id: "user2", email: "user2@example.com" }),
];
// First user at 14 days, second user at 7 days
mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledTimes(2);
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user1@example.com",
14,
);
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user2@example.com",
7,
);
const body = await response.json();
expect(body.warningsSent).toBe(2);
});
it("continues processing sync even if warning email fails", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(14);
mockSendTokenExpirationWarning.mockRejectedValueOnce(
new Error("Email failed"),
);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.usersProcessed).toBe(1);
});
it("does not send warning for expired tokens", async () => {
mockUsers = [createMockUser()];
mockIsTokenExpired.mockReturnValue(true);
mockDaysUntilExpiry.mockReturnValue(-1);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
});
});