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:
@@ -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,
|
||||
email: string,
|
||||
password: string,
|
||||
): void {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +219,41 @@ async function setupCollections(pb: PocketBase): Promise<void> {
|
||||
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.
|
||||
*/
|
||||
@@ -199,8 +267,9 @@ async function createTestUser(
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
// Create the test user
|
||||
const user = await pb.collection("users").create({
|
||||
// Create the test user (with retry for transient errors)
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
@@ -210,13 +279,16 @@ async function createTestUser(
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a period log entry
|
||||
await pb.collection("period_logs").create({
|
||||
// Create a period log entry (with retry for transient errors)
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
@@ -254,8 +326,8 @@ export async function start(
|
||||
// Wait for PocketBase to be ready
|
||||
await waitForReady(url);
|
||||
|
||||
// Create admin user via CLI
|
||||
createAdminUser(dataDir, config.adminEmail, config.adminPassword);
|
||||
// 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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getExistingCollectionNames,
|
||||
getMissingCollections,
|
||||
PERIOD_LOGS_COLLECTION,
|
||||
USER_CUSTOM_FIELDS,
|
||||
} from "./setup-db";
|
||||
|
||||
describe("PERIOD_LOGS_COLLECTION", () => {
|
||||
@@ -162,3 +163,21 @@ describe("getMissingCollections", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,12 @@ import PocketBase from "pocketbase";
|
||||
* Collection field definition for PocketBase.
|
||||
* For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties.
|
||||
*/
|
||||
interface CollectionField {
|
||||
export interface CollectionField {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
// Text field max length (PocketBase defaults to 5000 if not specified)
|
||||
max?: number;
|
||||
// Relation field properties (top-level, not in options)
|
||||
collectionId?: string;
|
||||
maxSelect?: number;
|
||||
@@ -142,10 +144,10 @@ const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION];
|
||||
* Custom fields to add to the users collection.
|
||||
* These are required for Garmin integration and app functionality.
|
||||
*/
|
||||
const USER_CUSTOM_FIELDS = [
|
||||
export const USER_CUSTOM_FIELDS: CollectionField[] = [
|
||||
{ name: "garminConnected", type: "bool" },
|
||||
{ name: "garminOauth1Token", type: "text" },
|
||||
{ name: "garminOauth2Token", type: "text" },
|
||||
{ name: "garminOauth1Token", type: "text", max: 20000 },
|
||||
{ name: "garminOauth2Token", type: "text", max: 20000 },
|
||||
{ name: "garminTokenExpiresAt", type: "date" },
|
||||
{ name: "calendarToken", type: "text" },
|
||||
{ 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.
|
||||
*/
|
||||
export async function addUserFields(pb: PocketBase): Promise<void> {
|
||||
const usersCollection = await pb.collections.getOne("users");
|
||||
|
||||
// Get existing field names
|
||||
const existingFieldNames = new Set(
|
||||
(usersCollection.fields || []).map((f: { name: string }) => f.name),
|
||||
// Build a map of existing fields by name
|
||||
const existingFieldsMap = new Map<string, Record<string, unknown>>(
|
||||
(usersCollection.fields || []).map((f: Record<string, unknown>) => [
|
||||
f.name as string,
|
||||
f,
|
||||
]),
|
||||
);
|
||||
|
||||
// Filter to only new fields
|
||||
const newFields = USER_CUSTOM_FIELDS.filter(
|
||||
(f) => !existingFieldNames.has(f.name),
|
||||
);
|
||||
// Separate new fields from fields that need updating
|
||||
const newFields: CollectionField[] = [];
|
||||
const fieldsToUpdate: string[] = [];
|
||||
|
||||
if (newFields.length > 0) {
|
||||
// Combine existing fields with new ones
|
||||
for (const definedField of USER_CUSTOM_FIELDS) {
|
||||
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];
|
||||
|
||||
await pb.collections.update(usersCollection.id, {
|
||||
fields: allFields,
|
||||
});
|
||||
|
||||
if (newFields.length > 0) {
|
||||
console.log(
|
||||
` Added ${newFields.length} field(s) to users:`,
|
||||
newFields.map((f) => f.name),
|
||||
);
|
||||
}
|
||||
if (fieldsToUpdate.length > 0) {
|
||||
console.log(
|
||||
` Updated max constraint for ${fieldsToUpdate.length} field(s):`,
|
||||
fieldsToUpdate,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(" All user fields already exist.");
|
||||
console.log(" All user fields already exist with correct settings.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user