Files
phaseflow/e2e/pocketbase-harness.ts
Petru Paler ff3d8fad2c Add Playwright fixtures with 5 test user types for e2e tests
Creates test infrastructure to enable previously skipped e2e tests:
- Onboarding user (no period data) for setup flow tests
- Established user (period 14 days ago) for normal usage tests
- Calendar user (with calendarToken) for ICS feed tests
- Garmin user (valid tokens) for connected state tests
- Garmin expired user (expired tokens) for expiry warning tests

Also fixes ICS feed route to strip .ics suffix from Next.js dynamic
route param, adds calendarToken to /api/user response, and sets
viewRule on users collection for unauthenticated ICS access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 05:54:49 +00:00

624 lines
16 KiB
TypeScript

// ABOUTME: PocketBase test harness for e2e tests - starts, configures, and stops PocketBase.
// ABOUTME: Provides ephemeral PocketBase instances with test data for Playwright tests.
import { type ChildProcess, execSync, spawn } from "node:child_process";
import { createCipheriv, randomBytes } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import PocketBase from "pocketbase";
import {
createCollection,
getExistingCollectionNames,
getMissingCollections,
} from "../scripts/setup-db";
/**
* Test user presets for different e2e test scenarios.
*/
export type TestUserPreset =
| "onboarding"
| "established"
| "calendar"
| "garmin"
| "garminExpired";
/**
* Configuration for each test user type.
*/
export const TEST_USERS: Record<
TestUserPreset,
{ email: string; password: string }
> = {
onboarding: {
email: "e2e-onboarding@phaseflow.local",
password: "e2e-onboarding-123",
},
established: {
email: "e2e-test@phaseflow.local",
password: "e2e-test-password-123",
},
calendar: {
email: "e2e-calendar@phaseflow.local",
password: "e2e-calendar-123",
},
garmin: {
email: "e2e-garmin@phaseflow.local",
password: "e2e-garmin-123",
},
garminExpired: {
email: "e2e-garmin-expired@phaseflow.local",
password: "e2e-garmin-expired-123",
},
};
/**
* Configuration for the test harness.
*/
export interface HarnessConfig {
port: number;
adminEmail: string;
adminPassword: string;
testUserEmail: string;
testUserPassword: string;
}
/**
* Default configuration for e2e tests.
*/
export const DEFAULT_CONFIG: HarnessConfig = {
port: 8091,
adminEmail: "admin@e2e-test.local",
adminPassword: "admin-password-e2e-123",
testUserEmail: "e2e-test@phaseflow.local",
testUserPassword: "e2e-test-password-123",
};
/**
* State of a running PocketBase harness instance.
*/
export interface HarnessState {
process: ChildProcess;
dataDir: string;
url: string;
config: HarnessConfig;
}
let currentState: HarnessState | null = null;
/**
* Gets the URL for the PocketBase instance.
*/
function getPocketBaseUrl(port: number): string {
return `http://127.0.0.1:${port}`;
}
/**
* Creates a temporary directory for PocketBase data.
*/
function createTempDataDir(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pocketbase-e2e-"));
return tempDir;
}
/**
* Waits for PocketBase to be ready by polling the health endpoint.
*/
async function waitForReady(url: string, timeoutMs = 30000): Promise<void> {
const startTime = Date.now();
const healthUrl = `${url}/api/health`;
while (Date.now() - startTime < timeoutMs) {
try {
const response = await fetch(healthUrl);
if (response.ok) {
return;
}
} catch {
// Server not ready yet, continue polling
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`PocketBase did not become ready within ${timeoutMs}ms`);
}
/**
* Sleeps for the specified number of milliseconds.
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Creates the admin superuser using the PocketBase CLI.
* Retries on database lock errors since PocketBase may still be running migrations.
*/
async function createAdminUser(
dataDir: string,
email: string,
password: string,
maxRetries = 5,
): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
execSync(
`pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`,
{
stdio: "pipe",
},
);
return;
} catch (err) {
lastError = err as Error;
const errorMsg = String(lastError.message || lastError);
// Retry on database lock errors
if (
errorMsg.includes("database is locked") ||
errorMsg.includes("SQLITE_BUSY")
) {
await sleep(100 * (attempt + 1)); // Exponential backoff: 100ms, 200ms, 300ms...
continue;
}
// For other errors, throw immediately
throw err;
}
}
throw lastError;
}
/**
* Adds custom fields to the users collection.
*/
async function addUserFields(pb: PocketBase): Promise<void> {
const usersCollection = await pb.collections.getOne("users");
// Define the custom user fields
const customFields = [
{ name: "garminConnected", type: "bool" },
{ name: "garminOauth1Token", type: "text" },
{ name: "garminOauth2Token", type: "text" },
{ name: "garminTokenExpiresAt", type: "date" },
{ name: "calendarToken", type: "text" },
{ name: "lastPeriodDate", type: "date" },
{ name: "cycleLength", type: "number" },
{ name: "notificationTime", type: "text" },
{ name: "timezone", type: "text" },
{ name: "activeOverrides", type: "json" },
];
// Get existing field names
const existingFieldNames = new Set(
(usersCollection.fields || []).map((f: { name: string }) => f.name),
);
// Filter to only new fields
const newFields = customFields.filter((f) => !existingFieldNames.has(f.name));
if (newFields.length > 0) {
// Combine existing fields with new ones
const allFields = [...(usersCollection.fields || []), ...newFields];
await pb.collections.update(usersCollection.id, {
fields: allFields,
});
}
}
/**
* Sets up API rules for collections to allow user access.
*/
async function setupApiRules(pb: PocketBase): Promise<void> {
// Allow users to update their own user record
// viewRule allows reading user records by ID (needed for ICS calendar feed)
const usersCollection = await pb.collections.getOne("users");
await pb.collections.update(usersCollection.id, {
viewRule: "", // Empty string = allow all authenticated & unauthenticated reads
updateRule: "id = @request.auth.id",
});
// Allow users to read/write their own period_logs
const periodLogs = await pb.collections.getOne("period_logs");
await pb.collections.update(periodLogs.id, {
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "user = @request.auth.id",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
});
// Allow users to read/write their own dailyLogs
const dailyLogs = await pb.collections.getOne("dailyLogs");
await pb.collections.update(dailyLogs.id, {
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "user = @request.auth.id",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
});
}
/**
* Sets up the database collections using the SDK.
*/
async function setupCollections(pb: PocketBase): Promise<void> {
// Add custom fields to users collection
await addUserFields(pb);
// Create period_logs and dailyLogs collections
const existingNames = await getExistingCollectionNames(pb);
const missing = getMissingCollections(existingNames);
for (const collection of missing) {
await createCollection(pb, collection);
}
// Set up API rules
await setupApiRules(pb);
}
/**
* Retries an async operation with exponential backoff.
*/
async function retryAsync<T>(
operation: () => Promise<T>,
maxRetries = 5,
baseDelayMs = 100,
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err as Error;
const errorMsg = String(lastError.message || lastError);
// Retry on transient errors (database busy, connection issues)
if (
errorMsg.includes("database is locked") ||
errorMsg.includes("SQLITE_BUSY") ||
errorMsg.includes("Failed to create record")
) {
await sleep(baseDelayMs * (attempt + 1));
continue;
}
// For other errors, throw immediately
throw err;
}
}
throw lastError;
}
/**
* Encrypts a string using AES-256-GCM (matches src/lib/encryption.ts format).
* Uses the test encryption key from playwright.config.ts.
*/
function encryptForTest(plaintext: string): string {
const key = Buffer.from(
"e2e-test-encryption-key-32chars".padEnd(32, "0").slice(0, 32),
);
const iv = randomBytes(16);
const cipher = createCipheriv("aes-256-gcm", key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
/**
* Creates the onboarding test user (no period data).
*/
async function createOnboardingUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.onboarding;
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
}),
);
return user.id;
}
/**
* Creates the established test user with period data (default user).
*/
async function createEstablishedUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.established;
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
}),
);
await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id,
startDate: lastPeriodDate,
}),
);
return user.id;
}
/**
* Creates the calendar test user with period data and calendar token.
*/
async function createCalendarUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.calendar;
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
calendarToken: "e2e-test-calendar-token-12345678",
}),
);
await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id,
startDate: lastPeriodDate,
}),
);
return user.id;
}
/**
* Creates the Garmin test user with period data and valid Garmin tokens.
*/
async function createGarminUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.garmin;
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
// Token expires 90 days in the future
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 90);
const oauth1Token = encryptForTest(
JSON.stringify({
oauth_token: "test-oauth1-token",
oauth_token_secret: "test-oauth1-secret",
}),
);
const oauth2Token = encryptForTest(
JSON.stringify({
access_token: "test-access-token",
refresh_token: "test-refresh-token",
token_type: "Bearer",
expires_in: 7776000,
}),
);
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
garminConnected: true,
garminOauth1Token: oauth1Token,
garminOauth2Token: oauth2Token,
garminTokenExpiresAt: expiresAt.toISOString(),
}),
);
await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id,
startDate: lastPeriodDate,
}),
);
return user.id;
}
/**
* Creates the Garmin expired test user with period data and expired Garmin tokens.
*/
async function createGarminExpiredUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.garminExpired;
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
// Token expired 1 day ago
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() - 1);
const oauth1Token = encryptForTest(
JSON.stringify({
oauth_token: "test-expired-oauth1-token",
oauth_token_secret: "test-expired-oauth1-secret",
}),
);
const oauth2Token = encryptForTest(
JSON.stringify({
access_token: "test-expired-access-token",
refresh_token: "test-expired-refresh-token",
token_type: "Bearer",
expires_in: 7776000,
}),
);
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
garminConnected: true,
garminOauth1Token: oauth1Token,
garminOauth2Token: oauth2Token,
garminTokenExpiresAt: expiredAt.toISOString(),
}),
);
await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id,
startDate: lastPeriodDate,
}),
);
return user.id;
}
/**
* Creates all test users for e2e tests.
*/
async function createAllTestUsers(pb: PocketBase): Promise<void> {
await createOnboardingUser(pb);
await createEstablishedUser(pb);
await createCalendarUser(pb);
await createGarminUser(pb);
await createGarminExpiredUser(pb);
}
/**
* Starts a fresh PocketBase instance for e2e testing.
*/
export async function start(
config: HarnessConfig = DEFAULT_CONFIG,
): Promise<HarnessState> {
if (currentState) {
throw new Error(
"PocketBase harness is already running. Call stop() first.",
);
}
const dataDir = createTempDataDir();
const url = getPocketBaseUrl(config.port);
// Start PocketBase process
const pbProcess = spawn(
"pocketbase",
["serve", `--dir=${dataDir}`, `--http=127.0.0.1:${config.port}`],
{
stdio: "pipe",
detached: false,
},
);
// Handle process errors
pbProcess.on("error", (err) => {
console.error("PocketBase process error:", err);
});
// Wait for PocketBase to be ready
await waitForReady(url);
// Create admin user via CLI (with retry for database lock during migrations)
await createAdminUser(dataDir, config.adminEmail, config.adminPassword);
// Connect to PocketBase as admin
const pb = new PocketBase(url);
pb.autoCancellation(false);
await pb
.collection("_superusers")
.authWithPassword(config.adminEmail, config.adminPassword);
// Set up collections
await setupCollections(pb);
// Create all test users for different e2e scenarios
await createAllTestUsers(pb);
currentState = {
process: pbProcess,
dataDir,
url,
config,
};
return currentState;
}
/**
* Stops the running PocketBase instance and cleans up.
*/
export async function stop(): Promise<void> {
if (!currentState) {
return;
}
const { process: pbProcess, dataDir } = currentState;
// Kill the PocketBase process
pbProcess.kill("SIGTERM");
// Wait a moment for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
// Force kill if still running
if (!pbProcess.killed) {
pbProcess.kill("SIGKILL");
}
// Clean up the temporary data directory
fs.rmSync(dataDir, { recursive: true, force: true });
currentState = null;
}
/**
* Gets the current harness state if running.
*/
export function getState(): HarnessState | null {
return currentState;
}