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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user