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>
624 lines
16 KiB
TypeScript
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;
|
|
}
|