All checks were successful
Deploy / deploy (push) Successful in 2m29s
The period_logs collection was returning 403 errors because API rules were only configured in the e2e test harness, not in the production setup script. This consolidates the setup logic so both prod and test use the same setupApiRules() function. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
555 lines
14 KiB
TypeScript
555 lines
14 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 {
|
|
addUserFields,
|
|
createCollection,
|
|
getExistingCollectionNames,
|
|
getMissingCollections,
|
|
setupApiRules,
|
|
} 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|