Implement PocketBase auth helpers (P0.1)

Add authentication utilities to pocketbase.ts for server-side auth:
- createPocketBaseClient() - factory for fresh instances per request
- isAuthenticated(pb) - checks authStore validity
- getCurrentUser(pb) - returns typed User from authStore
- loadAuthFromCookies(pb, cookies) - loads auth from Next.js cookies

Includes 9 unit tests covering all auth state scenarios and cookie loading.
This unblocks P0.2 (auth middleware) and all downstream API/page work.

🤖 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 18:37:15 +00:00
parent 03f1bde24c
commit caed11a445
3 changed files with 267 additions and 9 deletions

185
src/lib/pocketbase.test.ts Normal file
View File

@@ -0,0 +1,185 @@
// ABOUTME: Unit tests for PocketBase authentication helper utilities.
// ABOUTME: Tests isAuthenticated, getCurrentUser, and loadAuthFromCookies functions.
import { describe, expect, it, vi } from "vitest";
import {
createPocketBaseClient,
getCurrentUser,
isAuthenticated,
loadAuthFromCookies,
} from "./pocketbase";
describe("isAuthenticated", () => {
it("returns false when authStore is not valid", () => {
const mockPb = {
authStore: {
isValid: false,
model: null,
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
expect(isAuthenticated(mockPb as any)).toBe(false);
});
it("returns true when authStore is valid", () => {
const mockPb = {
authStore: {
isValid: true,
model: { id: "user123" },
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
expect(isAuthenticated(mockPb as any)).toBe(true);
});
});
describe("getCurrentUser", () => {
it("returns null when not authenticated", () => {
const mockPb = {
authStore: {
isValid: false,
model: null,
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
expect(getCurrentUser(mockPb as any)).toBeNull();
});
it("returns null when authStore is valid but model is null", () => {
const mockPb = {
authStore: {
isValid: true,
model: null,
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
expect(getCurrentUser(mockPb as any)).toBeNull();
});
it("returns mapped user when authenticated", () => {
const mockRecord = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted1",
garminOauth2Token: "encrypted2",
garminTokenExpiresAt: "2025-06-01T00:00:00Z",
calendarToken: "cal-token-123",
lastPeriodDate: "2025-01-01",
cycleLength: 28,
notificationTime: "07:00",
timezone: "America/New_York",
activeOverrides: ["flare"],
created: "2024-01-01T00:00:00Z",
updated: "2024-06-01T00:00:00Z",
};
const mockPb = {
authStore: {
isValid: true,
model: mockRecord,
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
const user = getCurrentUser(mockPb as any);
expect(user).not.toBeNull();
expect(user?.id).toBe("user123");
expect(user?.email).toBe("test@example.com");
expect(user?.garminConnected).toBe(true);
expect(user?.cycleLength).toBe(28);
expect(user?.activeOverrides).toEqual(["flare"]);
expect(user?.timezone).toBe("America/New_York");
});
it("handles empty activeOverrides array", () => {
const mockRecord = {
id: "user456",
email: "user@test.com",
garminConnected: false,
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: "",
calendarToken: "token",
lastPeriodDate: "2025-01-15",
cycleLength: 31,
notificationTime: "08:00",
timezone: "UTC",
activeOverrides: [],
created: "2024-01-01T00:00:00Z",
updated: "2024-01-01T00:00:00Z",
};
const mockPb = {
authStore: {
isValid: true,
model: mockRecord,
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
const user = getCurrentUser(mockPb as any);
expect(user).not.toBeNull();
expect(user?.activeOverrides).toEqual([]);
});
});
describe("loadAuthFromCookies", () => {
it("does nothing when pb_auth cookie is not present", () => {
const loadFromCookie = vi.fn();
const mockPb = {
authStore: {
isValid: false,
model: null,
loadFromCookie,
},
};
const mockCookieStore = {
get: vi.fn().mockReturnValue(undefined),
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
loadAuthFromCookies(mockPb as any, mockCookieStore as any);
expect(mockCookieStore.get).toHaveBeenCalledWith("pb_auth");
expect(loadFromCookie).not.toHaveBeenCalled();
});
it("loads auth from cookie when pb_auth is present", () => {
const loadFromCookie = vi.fn();
const mockPb = {
authStore: {
isValid: false,
model: null,
loadFromCookie,
},
};
const mockCookieStore = {
get: vi
.fn()
.mockReturnValue({ name: "pb_auth", value: "token-value-here" }),
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
loadAuthFromCookies(mockPb as any, mockCookieStore as any);
expect(mockCookieStore.get).toHaveBeenCalledWith("pb_auth");
expect(loadFromCookie).toHaveBeenCalledWith("pb_auth=token-value-here");
});
});
describe("createPocketBaseClient", () => {
it("creates a new PocketBase instance", () => {
const client = createPocketBaseClient();
expect(client).toBeDefined();
expect(client.authStore).toBeDefined();
});
});

View File

@@ -1,10 +1,82 @@
// ABOUTME: PocketBase client initialization and utilities.
// ABOUTME: PocketBase client initialization and authentication utilities.
// ABOUTME: Provides typed access to the PocketBase backend for auth and data.
import PocketBase from "pocketbase";
import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import PocketBase, { type RecordModel } from "pocketbase";
import type { OverrideType, User } from "@/types";
const POCKETBASE_URL = process.env.POCKETBASE_URL || "http://localhost:8090";
/**
* Singleton PocketBase client for client-side usage.
* For server-side, use createPocketBaseClient() to get a fresh instance.
*/
export const pb = new PocketBase(POCKETBASE_URL);
// Disable auto-cancellation for server-side usage
pb.autoCancellation(false);
/**
* Creates a new PocketBase client instance.
* Use this in server components and API routes to get a fresh client
* that can be loaded with request-specific auth.
*/
export function createPocketBaseClient(): PocketBase {
const client = new PocketBase(POCKETBASE_URL);
client.autoCancellation(false);
return client;
}
/**
* Checks if the PocketBase client has a valid authenticated session.
*/
export function isAuthenticated(pbClient: PocketBase): boolean {
return pbClient.authStore.isValid;
}
/**
* Gets the current authenticated user from the PocketBase auth store.
* Returns null if not authenticated or if the model is not available.
*/
export function getCurrentUser(pbClient: PocketBase): User | null {
if (!pbClient.authStore.isValid || !pbClient.authStore.model) {
return null;
}
return mapRecordToUser(pbClient.authStore.model);
}
/**
* Loads authentication state from Next.js cookies into the PocketBase client.
* Call this in server components/routes before checking auth.
*/
export function loadAuthFromCookies(
pbClient: PocketBase,
cookieStore: ReadonlyRequestCookies,
): void {
const cookie = cookieStore.get("pb_auth");
if (cookie) {
pbClient.authStore.loadFromCookie(`pb_auth=${cookie.value}`);
}
}
/**
* Maps a PocketBase record to our typed User interface.
*/
function mapRecordToUser(record: RecordModel): User {
return {
id: record.id,
email: record.email as string,
garminConnected: record.garminConnected as boolean,
garminOauth1Token: record.garminOauth1Token as string,
garminOauth2Token: record.garminOauth2Token as string,
garminTokenExpiresAt: new Date(record.garminTokenExpiresAt as string),
calendarToken: record.calendarToken as string,
lastPeriodDate: new Date(record.lastPeriodDate as string),
cycleLength: record.cycleLength as number,
notificationTime: record.notificationTime as string,
timezone: record.timezone as string,
activeOverrides: (record.activeOverrides as OverrideType[]) || [],
created: new Date(record.created as string),
updated: new Date(record.updated as string),
};
}