All checks were successful
Deploy / deploy (push) Successful in 2m27s
Body Battery: - Change endpoint from /usersummary-service/stats/bodyBattery/dates/ to /wellness-service/wellness/bodyBattery/reports/daily - Parse new response format: array with bodyBatteryValuesArray time series - Current value = last entry's level (index 2) - YesterdayLow = min level from yesterday's data Intensity Minutes: - Change endpoint from /fitnessstats-service/activity to /usersummary-service/stats/im/weekly - Add date parameter to function signature - Parse new response format: array with moderateValue/vigorousValue Endpoints verified against python-garminconnect source code. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
686 lines
22 KiB
TypeScript
686 lines
22 KiB
TypeScript
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
|
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { User } from "@/types";
|
|
|
|
// Mock users returned by PocketBase
|
|
let mockUsers: User[] = [];
|
|
// Track DailyLog creations
|
|
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
|
// Track user updates
|
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
|
|
|
// Helper to parse date values - handles both Date objects and ISO strings
|
|
function parseDate(value: unknown): Date | null {
|
|
if (!value) return null;
|
|
if (value instanceof Date) return value;
|
|
if (typeof value !== "string") return null;
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
// Mock PocketBase
|
|
vi.mock("@/lib/pocketbase", () => ({
|
|
createPocketBaseClient: vi.fn(() => ({
|
|
collection: vi.fn((name: string) => ({
|
|
getFullList: vi.fn(async () => {
|
|
if (name === "users") {
|
|
return mockUsers;
|
|
}
|
|
return [];
|
|
}),
|
|
create: mockPbCreate,
|
|
update: mockPbUpdate,
|
|
authWithPassword: vi.fn().mockResolvedValue({ token: "admin-token" }),
|
|
})),
|
|
})),
|
|
mapRecordToUser: vi.fn((record: Record<string, unknown>) => ({
|
|
id: record.id,
|
|
email: record.email,
|
|
garminConnected: record.garminConnected,
|
|
garminOauth1Token: record.garminOauth1Token,
|
|
garminOauth2Token: record.garminOauth2Token,
|
|
garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
|
|
garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt),
|
|
calendarToken: record.calendarToken,
|
|
lastPeriodDate: parseDate(record.lastPeriodDate),
|
|
cycleLength: record.cycleLength,
|
|
notificationTime: record.notificationTime,
|
|
timezone: record.timezone,
|
|
activeOverrides: record.activeOverrides || [],
|
|
created: new Date(record.created as string),
|
|
updated: new Date(record.updated as string),
|
|
})),
|
|
}));
|
|
|
|
// Mock decryption
|
|
const mockDecrypt = vi.fn((ciphertext: string) => {
|
|
// Return mock OAuth2 token JSON
|
|
if (ciphertext.includes("oauth2")) {
|
|
return JSON.stringify({ access_token: "mock-token-123" });
|
|
}
|
|
// Return mock OAuth1 token JSON (needed for refresh flow)
|
|
if (ciphertext.includes("oauth1")) {
|
|
return JSON.stringify({
|
|
oauth_token: "mock-oauth1-token",
|
|
oauth_token_secret: "mock-oauth1-secret",
|
|
});
|
|
}
|
|
return ciphertext.replace("encrypted:", "");
|
|
});
|
|
|
|
vi.mock("@/lib/encryption", () => ({
|
|
decrypt: (ciphertext: string) => mockDecrypt(ciphertext),
|
|
}));
|
|
|
|
// Mock Garmin API functions
|
|
const mockFetchHrvStatus = vi.fn().mockResolvedValue("Balanced");
|
|
const mockFetchBodyBattery = vi
|
|
.fn()
|
|
.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),
|
|
fetchBodyBattery: (...args: unknown[]) => mockFetchBodyBattery(...args),
|
|
fetchIntensityMinutes: (...args: unknown[]) =>
|
|
mockFetchIntensityMinutes(...args),
|
|
isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args),
|
|
daysUntilExpiry: (...args: unknown[]) => mockDaysUntilExpiry(...args),
|
|
}));
|
|
|
|
// Mock email sending
|
|
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
|
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
|
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
|
|
|
vi.mock("@/lib/email", () => ({
|
|
sendTokenExpirationWarning: (...args: unknown[]) =>
|
|
mockSendTokenExpirationWarning(...args),
|
|
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
|
|
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
|
mockSendPeriodConfirmationEmail(...args),
|
|
}));
|
|
|
|
// Mock logger (required for route to run without side effects)
|
|
vi.mock("@/lib/logger", () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { POST } from "./route";
|
|
|
|
describe("POST /api/cron/garmin-sync", () => {
|
|
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), // 30 days from now
|
|
garminRefreshTokenExpiresAt: null,
|
|
calendarToken: "cal-token",
|
|
lastPeriodDate: new Date("2025-01-01"),
|
|
cycleLength: 28,
|
|
notificationTime: "07:00",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: new Date("2024-01-01"),
|
|
updated: new Date("2025-01-10"),
|
|
...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();
|
|
vi.resetModules();
|
|
mockUsers = [];
|
|
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
|
|
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
|
|
process.env.CRON_SECRET = validSecret;
|
|
process.env.POCKETBASE_ADMIN_EMAIL = "admin@test.com";
|
|
process.env.POCKETBASE_ADMIN_PASSWORD = "test-password";
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it("returns 500 when POCKETBASE_ADMIN_EMAIL is not set", async () => {
|
|
process.env.POCKETBASE_ADMIN_EMAIL = "";
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(500);
|
|
const body = await response.json();
|
|
expect(body.error).toBe("Server misconfiguration");
|
|
});
|
|
|
|
it("returns 500 when POCKETBASE_ADMIN_PASSWORD is not set", async () => {
|
|
process.env.POCKETBASE_ADMIN_PASSWORD = "";
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(500);
|
|
const body = await response.json();
|
|
expect(body.error).toBe("Server misconfiguration");
|
|
});
|
|
});
|
|
|
|
describe("User fetching", () => {
|
|
it("fetches users with garminConnected=true", async () => {
|
|
mockUsers = [createMockUser()];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.usersProcessed).toBe(1);
|
|
});
|
|
|
|
it("skips users without Garmin connection", async () => {
|
|
mockUsers = [
|
|
createMockUser({ id: "user1", garminConnected: true }),
|
|
createMockUser({ id: "user2", garminConnected: false }),
|
|
];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.usersProcessed).toBe(1);
|
|
});
|
|
|
|
it("returns success with zero users when none are connected", async () => {
|
|
mockUsers = [];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.usersProcessed).toBe(0);
|
|
expect(body.success).toBe(true);
|
|
});
|
|
|
|
it("handles date fields as ISO strings from PocketBase", async () => {
|
|
// PocketBase returns date fields as ISO strings, not Date objects
|
|
// This simulates the raw response from pb.collection("users").getFullList()
|
|
const rawPocketBaseRecord = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: true,
|
|
garminOauth1Token: "encrypted:oauth1-token",
|
|
garminOauth2Token: "encrypted:oauth2-token",
|
|
garminTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date
|
|
garminRefreshTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date
|
|
calendarToken: "cal-token",
|
|
lastPeriodDate: "2025-01-01T00:00:00.000Z", // ISO string, not Date
|
|
cycleLength: 28,
|
|
notificationTime: "07:00",
|
|
timezone: "America/New_York",
|
|
activeOverrides: [],
|
|
created: "2024-01-01T00:00:00.000Z",
|
|
updated: "2025-01-10T00:00:00.000Z",
|
|
};
|
|
// Cast to User to simulate what getFullList<User>() returns
|
|
mockUsers = [rawPocketBaseRecord as unknown as User];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.usersProcessed).toBe(1);
|
|
expect(body.errors).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("Token handling", () => {
|
|
it("decrypts OAuth2 token before making Garmin API calls", async () => {
|
|
mockUsers = [createMockUser()];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
|
});
|
|
|
|
it("skips users with expired refresh tokens", async () => {
|
|
// Set refresh token to expired (in the past)
|
|
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
|
mockUsers = [
|
|
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
|
];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.skippedExpired).toBe(1);
|
|
expect(mockFetchHrvStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("processes users with valid tokens", async () => {
|
|
mockIsTokenExpired.mockReturnValue(false);
|
|
mockUsers = [createMockUser()];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockFetchHrvStatus).toHaveBeenCalled();
|
|
expect(mockFetchBodyBattery).toHaveBeenCalled();
|
|
expect(mockFetchIntensityMinutes).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Garmin data fetching", () => {
|
|
it("fetches HRV status with today's date", async () => {
|
|
mockUsers = [createMockUser()];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockFetchHrvStatus).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
|
"mock-token-123",
|
|
);
|
|
});
|
|
|
|
it("fetches body battery with today's date", async () => {
|
|
mockUsers = [createMockUser()];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockFetchBodyBattery).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
|
"mock-token-123",
|
|
);
|
|
});
|
|
|
|
it("fetches intensity minutes with today's date", async () => {
|
|
mockUsers = [createMockUser()];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockFetchIntensityMinutes).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
|
"mock-token-123",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("DailyLog creation", () => {
|
|
it("creates DailyLog entry with fetched data", async () => {
|
|
mockUsers = [createMockUser({ lastPeriodDate: new Date("2025-01-01") })];
|
|
mockFetchHrvStatus.mockResolvedValue("Balanced");
|
|
mockFetchBodyBattery.mockResolvedValue({ current: 90, yesterdayLow: 50 });
|
|
mockFetchIntensityMinutes.mockResolvedValue(45);
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user: "user123",
|
|
hrvStatus: "Balanced",
|
|
bodyBatteryCurrent: 90,
|
|
bodyBatteryYesterdayLow: 50,
|
|
weekIntensityMinutes: 45,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("includes cycle day and phase in DailyLog", async () => {
|
|
// Set lastPeriodDate to make cycle day calculable
|
|
const lastPeriodDate = new Date();
|
|
lastPeriodDate.setDate(lastPeriodDate.getDate() - 5); // 6 days ago = cycle day 6
|
|
mockUsers = [createMockUser({ lastPeriodDate, cycleLength: 28 })];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cycleDay: expect.any(Number),
|
|
phase: expect.stringMatching(
|
|
/^(MENSTRUAL|FOLLICULAR|OVULATION|EARLY_LUTEAL|LATE_LUTEAL)$/,
|
|
),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("includes phase limit and remaining minutes in DailyLog", async () => {
|
|
mockUsers = [createMockUser()];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
phaseLimit: expect.any(Number),
|
|
remainingMinutes: expect.any(Number),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("includes training decision in DailyLog", async () => {
|
|
mockUsers = [createMockUser()];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
trainingDecision: expect.stringMatching(
|
|
/^(REST|GENTLE|LIGHT|REDUCED|TRAIN)$/,
|
|
),
|
|
decisionReason: expect.any(String),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("sets date to today's date string", async () => {
|
|
mockUsers = [createMockUser()];
|
|
const today = new Date().toISOString().split("T")[0];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
date: today,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Error handling", () => {
|
|
it("continues processing other users when one fails", async () => {
|
|
mockUsers = [
|
|
createMockUser({ id: "user1" }),
|
|
createMockUser({ id: "user2" }),
|
|
];
|
|
// First user fails, second succeeds
|
|
mockFetchHrvStatus
|
|
.mockRejectedValueOnce(new Error("API error"))
|
|
.mockResolvedValueOnce("Balanced");
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.errors).toBe(1);
|
|
expect(body.usersProcessed).toBe(1);
|
|
});
|
|
|
|
it("handles decryption errors gracefully", async () => {
|
|
mockUsers = [createMockUser()];
|
|
mockDecrypt.mockImplementationOnce(() => {
|
|
throw new Error("Decryption failed");
|
|
});
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.errors).toBe(1);
|
|
});
|
|
|
|
it("stores default value 100 when body battery is null from Garmin", async () => {
|
|
// When Garmin API returns null for body battery values (no data available),
|
|
// we store the default value 100 instead of null.
|
|
// This prevents PocketBase's number field null-to-0 coercion from causing
|
|
// the dashboard to display 0 instead of a meaningful value.
|
|
mockUsers = [createMockUser()];
|
|
mockFetchBodyBattery.mockResolvedValue({
|
|
current: null,
|
|
yesterdayLow: null,
|
|
});
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bodyBatteryCurrent: 100,
|
|
bodyBatteryYesterdayLow: 100,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Response format", () => {
|
|
it("returns summary with counts", async () => {
|
|
mockUsers = [
|
|
createMockUser({ id: "user1" }),
|
|
createMockUser({ id: "user2" }),
|
|
];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body).toMatchObject({
|
|
success: true,
|
|
usersProcessed: 2,
|
|
errors: 0,
|
|
skippedExpired: 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);
|
|
});
|
|
|
|
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", () => {
|
|
// Use fake timers to ensure consistent date calculations
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Helper to create a date N days from now
|
|
function daysFromNow(days: number): Date {
|
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
it("sends warning email when refresh token expires in exactly 14 days", async () => {
|
|
mockUsers = [
|
|
createMockUser({
|
|
email: "user@example.com",
|
|
garminRefreshTokenExpiresAt: daysFromNow(14),
|
|
}),
|
|
];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
|
|
"user@example.com",
|
|
14,
|
|
"user123",
|
|
);
|
|
const body = await response.json();
|
|
expect(body.warningsSent).toBe(1);
|
|
});
|
|
|
|
it("sends warning email when refresh token expires in exactly 7 days", async () => {
|
|
mockUsers = [
|
|
createMockUser({
|
|
email: "user@example.com",
|
|
garminRefreshTokenExpiresAt: daysFromNow(7),
|
|
}),
|
|
];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
|
|
"user@example.com",
|
|
7,
|
|
"user123",
|
|
);
|
|
const body = await response.json();
|
|
expect(body.warningsSent).toBe(1);
|
|
});
|
|
|
|
it("does not send warning when refresh token expires in 30 days", async () => {
|
|
mockUsers = [
|
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
|
|
];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not send warning when refresh token expires in 15 days", async () => {
|
|
mockUsers = [
|
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
|
|
];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not send warning when refresh token expires in 8 days", async () => {
|
|
mockUsers = [
|
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
|
|
];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not send warning when refresh token expires in 6 days", async () => {
|
|
mockUsers = [
|
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(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",
|
|
garminRefreshTokenExpiresAt: daysFromNow(14),
|
|
}),
|
|
createMockUser({
|
|
id: "user2",
|
|
email: "user2@example.com",
|
|
garminRefreshTokenExpiresAt: daysFromNow(7),
|
|
}),
|
|
];
|
|
|
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledTimes(2);
|
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
|
|
"user1@example.com",
|
|
14,
|
|
"user1",
|
|
);
|
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
|
|
"user2@example.com",
|
|
7,
|
|
"user2",
|
|
);
|
|
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",
|
|
garminRefreshTokenExpiresAt: daysFromNow(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 refresh tokens", async () => {
|
|
// Expired refresh tokens are skipped entirely (not synced), so no warning
|
|
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
|
mockUsers = [
|
|
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
|
];
|
|
|
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
|
|
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// Note: Structured logging is implemented in the route but testing the mock
|
|
// integration is complex due to vitest module hoisting. The logging calls
|
|
// (logger.info for sync start/complete, logger.error for failures) are
|
|
// verified through manual testing and code review. See route.ts lines 79, 146, 162.
|
|
});
|