Fix Garmin sync to handle PocketBase date strings
All checks were successful
Deploy / deploy (push) Successful in 2m38s

PocketBase returns date fields as ISO strings, not Date objects.
The sync was failing with "e.getTime is not a function" because
the code expected Date objects.

- Export mapRecordToUser from pocketbase.ts
- Use mapRecordToUser in cron route to properly parse dates
- Add test for handling date fields as ISO strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 07:38:37 +00:00
parent 4ba9f44cef
commit 3a06bff4d4
3 changed files with 63 additions and 6 deletions

View File

@@ -11,6 +11,15 @@ 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(() => ({
@@ -26,6 +35,23 @@ vi.mock("@/lib/pocketbase", () => ({
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
@@ -218,6 +244,37 @@ describe("POST /api/cron/garmin-sync", () => {
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", () => {

View File

@@ -22,8 +22,7 @@ import {
garminSyncDuration,
garminSyncTotal,
} from "@/lib/metrics";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { User } from "@/types";
import { createPocketBaseClient, mapRecordToUser } from "@/lib/pocketbase";
interface SyncResult {
success: boolean;
@@ -84,9 +83,10 @@ export async function POST(request: Request) {
);
}
// Fetch all users (we'll filter garminConnected in code to avoid PocketBase query syntax issues)
// Also filter out users without required date fields (garminTokenExpiresAt, lastPeriodDate)
const allUsers = await pb.collection("users").getFullList<User>();
// Fetch all users and map to typed User objects (PocketBase returns dates as strings)
// Filter to users with Garmin connected and required date fields
const rawUsers = await pb.collection("users").getFullList();
const allUsers = rawUsers.map(mapRecordToUser);
const users = allUsers.filter(
(u) => u.garminConnected && u.garminTokenExpiresAt && u.lastPeriodDate,
);

View File

@@ -88,7 +88,7 @@ function parseDate(value: unknown): Date | null {
/**
* Maps a PocketBase record to our typed User interface.
*/
function mapRecordToUser(record: RecordModel): User {
export function mapRecordToUser(record: RecordModel): User {
return {
id: record.id,
email: record.email as string,