Fix Garmin token storage and flaky e2e test

1. Increase garminOauth1Token and garminOauth2Token max length from
   5000 to 20000 characters to accommodate encrypted OAuth tokens.
   Add logic to update existing field constraints in addUserFields().

2. Fix flaky pocketbase-harness e2e test by adding retry logic with
   exponential backoff to createAdminUser() and createTestUser().
   Handles SQLite database lock during PocketBase startup migrations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 12:52:01 +00:00
parent 00b84d0b22
commit 6df145d916
3 changed files with 166 additions and 47 deletions

View File

@@ -83,19 +83,52 @@ async function waitForReady(url: string, timeoutMs = 30000): Promise<void> {
} }
/** /**
* Creates the admin superuser using the PocketBase CLI. * Sleeps for the specified number of milliseconds.
*/ */
function createAdminUser( 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, dataDir: string,
email: string, email: string,
password: string, password: string,
): void { maxRetries = 5,
): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
execSync( execSync(
`pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`, `pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`,
{ {
stdio: "pipe", 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;
} }
/** /**
@@ -186,6 +219,41 @@ async function setupCollections(pb: PocketBase): Promise<void> {
await setupApiRules(pb); 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;
}
/** /**
* Creates the test user with period data. * Creates the test user with period data.
*/ */
@@ -199,8 +267,9 @@ async function createTestUser(
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0]; const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
// Create the test user // Create the test user (with retry for transient errors)
const user = await pb.collection("users").create({ const user = await retryAsync(() =>
pb.collection("users").create({
email, email,
password, password,
passwordConfirm: password, passwordConfirm: password,
@@ -210,13 +279,16 @@ async function createTestUser(
cycleLength: 28, cycleLength: 28,
notificationTime: "07:00", notificationTime: "07:00",
timezone: "UTC", timezone: "UTC",
}); }),
);
// Create a period log entry // Create a period log entry (with retry for transient errors)
await pb.collection("period_logs").create({ await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id, user: user.id,
startDate: lastPeriodDate, startDate: lastPeriodDate,
}); }),
);
return user.id; return user.id;
} }
@@ -254,8 +326,8 @@ export async function start(
// Wait for PocketBase to be ready // Wait for PocketBase to be ready
await waitForReady(url); await waitForReady(url);
// Create admin user via CLI // Create admin user via CLI (with retry for database lock during migrations)
createAdminUser(dataDir, config.adminEmail, config.adminPassword); await createAdminUser(dataDir, config.adminEmail, config.adminPassword);
// Connect to PocketBase as admin // Connect to PocketBase as admin
const pb = new PocketBase(url); const pb = new PocketBase(url);

View File

@@ -7,6 +7,7 @@ import {
getExistingCollectionNames, getExistingCollectionNames,
getMissingCollections, getMissingCollections,
PERIOD_LOGS_COLLECTION, PERIOD_LOGS_COLLECTION,
USER_CUSTOM_FIELDS,
} from "./setup-db"; } from "./setup-db";
describe("PERIOD_LOGS_COLLECTION", () => { describe("PERIOD_LOGS_COLLECTION", () => {
@@ -162,3 +163,21 @@ describe("getMissingCollections", () => {
expect(missing).toHaveLength(0); expect(missing).toHaveLength(0);
}); });
}); });
describe("USER_CUSTOM_FIELDS garmin token max lengths", () => {
it("should have sufficient max length for garminOauth2Token field", () => {
const oauth2Field = USER_CUSTOM_FIELDS.find(
(f) => f.name === "garminOauth2Token",
);
expect(oauth2Field).toBeDefined();
expect(oauth2Field?.max).toBeGreaterThanOrEqual(10000);
});
it("should have sufficient max length for garminOauth1Token field", () => {
const oauth1Field = USER_CUSTOM_FIELDS.find(
(f) => f.name === "garminOauth1Token",
);
expect(oauth1Field).toBeDefined();
expect(oauth1Field?.max).toBeGreaterThanOrEqual(10000);
});
});

View File

@@ -6,10 +6,12 @@ import PocketBase from "pocketbase";
* Collection field definition for PocketBase. * Collection field definition for PocketBase.
* For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties. * For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties.
*/ */
interface CollectionField { export interface CollectionField {
name: string; name: string;
type: string; type: string;
required?: boolean; required?: boolean;
// Text field max length (PocketBase defaults to 5000 if not specified)
max?: number;
// Relation field properties (top-level, not in options) // Relation field properties (top-level, not in options)
collectionId?: string; collectionId?: string;
maxSelect?: number; maxSelect?: number;
@@ -142,10 +144,10 @@ const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION];
* Custom fields to add to the users collection. * Custom fields to add to the users collection.
* These are required for Garmin integration and app functionality. * These are required for Garmin integration and app functionality.
*/ */
const USER_CUSTOM_FIELDS = [ export const USER_CUSTOM_FIELDS: CollectionField[] = [
{ name: "garminConnected", type: "bool" }, { name: "garminConnected", type: "bool" },
{ name: "garminOauth1Token", type: "text" }, { name: "garminOauth1Token", type: "text", max: 20000 },
{ name: "garminOauth2Token", type: "text" }, { name: "garminOauth2Token", type: "text", max: 20000 },
{ name: "garminTokenExpiresAt", type: "date" }, { name: "garminTokenExpiresAt", type: "date" },
{ name: "calendarToken", type: "text" }, { name: "calendarToken", type: "text" },
{ name: "lastPeriodDate", type: "date" }, { name: "lastPeriodDate", type: "date" },
@@ -156,36 +158,62 @@ const USER_CUSTOM_FIELDS = [
]; ];
/** /**
* Adds custom fields to the users collection if they don't already exist. * Adds or updates custom fields on the users collection.
* For new fields: adds them. For existing fields: updates max constraint if different.
* This is idempotent - safe to run multiple times. * This is idempotent - safe to run multiple times.
*/ */
export async function addUserFields(pb: PocketBase): Promise<void> { export async function addUserFields(pb: PocketBase): Promise<void> {
const usersCollection = await pb.collections.getOne("users"); const usersCollection = await pb.collections.getOne("users");
// Get existing field names // Build a map of existing fields by name
const existingFieldNames = new Set( const existingFieldsMap = new Map<string, Record<string, unknown>>(
(usersCollection.fields || []).map((f: { name: string }) => f.name), (usersCollection.fields || []).map((f: Record<string, unknown>) => [
f.name as string,
f,
]),
); );
// Filter to only new fields // Separate new fields from fields that need updating
const newFields = USER_CUSTOM_FIELDS.filter( const newFields: CollectionField[] = [];
(f) => !existingFieldNames.has(f.name), const fieldsToUpdate: string[] = [];
);
if (newFields.length > 0) { for (const definedField of USER_CUSTOM_FIELDS) {
// Combine existing fields with new ones const existingField = existingFieldsMap.get(definedField.name);
if (!existingField) {
newFields.push(definedField);
} else if (
definedField.max !== undefined &&
existingField.max !== definedField.max
) {
fieldsToUpdate.push(definedField.name);
existingField.max = definedField.max;
}
}
const hasChanges = newFields.length > 0 || fieldsToUpdate.length > 0;
if (hasChanges) {
// Combine existing fields (with updates) and new fields
const allFields = [...(usersCollection.fields || []), ...newFields]; const allFields = [...(usersCollection.fields || []), ...newFields];
await pb.collections.update(usersCollection.id, { await pb.collections.update(usersCollection.id, {
fields: allFields, fields: allFields,
}); });
if (newFields.length > 0) {
console.log( console.log(
` Added ${newFields.length} field(s) to users:`, ` Added ${newFields.length} field(s) to users:`,
newFields.map((f) => f.name), newFields.map((f) => f.name),
); );
}
if (fieldsToUpdate.length > 0) {
console.log(
` Updated max constraint for ${fieldsToUpdate.length} field(s):`,
fieldsToUpdate,
);
}
} else { } else {
console.log(" All user fields already exist."); console.log(" All user fields already exist with correct settings.");
} }
} }