Fix Garmin token connection not persisting after save
All checks were successful
Deploy / deploy (push) Successful in 1m38s
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Root cause: The setup-db script was missing user field definitions (garminConnected, tokens, etc.). Production PocketBase had no such fields, so updates silently failed to persist. Changes: - Add user custom fields to setup-db.ts (matches e2e harness) - Fix status route to use strict boolean check (=== true) - Add verification in tokens route with helpful error message - Add ENCRYPTION_KEY to playwright config for e2e tests - Add comprehensive e2e tests for Garmin connection flow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
191
e2e/garmin.spec.ts
Normal file
191
e2e/garmin.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// ABOUTME: E2E tests for Garmin token connection flow.
|
||||||
|
// ABOUTME: Tests saving tokens, verifying connection status, and disconnection.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("garmin connection", () => {
|
||||||
|
test.describe("unauthenticated", () => {
|
||||||
|
test("redirects to login when not authenticated", async ({ page }) => {
|
||||||
|
await page.goto("/settings/garmin");
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("authenticated", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const email = process.env.TEST_USER_EMAIL;
|
||||||
|
const password = process.env.TEST_USER_PASSWORD;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login via the login page
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const emailInput = page.getByLabel(/email/i);
|
||||||
|
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!hasEmailForm) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await emailInput.fill(email);
|
||||||
|
await page.getByLabel(/password/i).fill(password);
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
// Wait for redirect to dashboard, then navigate to garmin settings
|
||||||
|
await page.waitForURL("/", { timeout: 10000 });
|
||||||
|
await page.goto("/settings/garmin");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows not connected initially for new user", async ({ page }) => {
|
||||||
|
// Verify initial state shows "Not Connected"
|
||||||
|
const notConnectedText = page.getByText(/not connected/i);
|
||||||
|
await expect(notConnectedText).toBeVisible();
|
||||||
|
|
||||||
|
// Token input should be visible
|
||||||
|
const tokenInput = page.locator("#tokenInput");
|
||||||
|
await expect(tokenInput).toBeVisible();
|
||||||
|
|
||||||
|
// Save button should be visible
|
||||||
|
const saveButton = page.getByRole("button", { name: /save tokens/i });
|
||||||
|
await expect(saveButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can save valid tokens and become connected", async ({ page }) => {
|
||||||
|
// Verify initial state shows "Not Connected"
|
||||||
|
await expect(page.getByText(/not connected/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Create valid token JSON - expires 90 days from now
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||||
|
|
||||||
|
const validTokens = JSON.stringify({
|
||||||
|
oauth1: { token: "test-oauth1-token", secret: "test-oauth1-secret" },
|
||||||
|
oauth2: { access_token: "test-oauth2-access-token" },
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter tokens in textarea
|
||||||
|
const tokenInput = page.locator("#tokenInput");
|
||||||
|
await tokenInput.fill(validTokens);
|
||||||
|
|
||||||
|
// Click Save Tokens button
|
||||||
|
const saveButton = page.getByRole("button", { name: /save tokens/i });
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Wait for success toast - sonner renders toasts with role="status"
|
||||||
|
const successToast = page.getByText(/tokens saved successfully/i);
|
||||||
|
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify status changes to "Connected" with green indicator
|
||||||
|
const connectedText = page.getByText("Connected", { exact: true });
|
||||||
|
await expect(connectedText).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Green indicator should be visible (the circular badge)
|
||||||
|
const greenIndicator = page.locator(".bg-green-500").first();
|
||||||
|
await expect(greenIndicator).toBeVisible();
|
||||||
|
|
||||||
|
// Disconnect button should now be visible
|
||||||
|
const disconnectButton = page.getByRole("button", {
|
||||||
|
name: /disconnect/i,
|
||||||
|
});
|
||||||
|
await expect(disconnectButton).toBeVisible();
|
||||||
|
|
||||||
|
// Token input should be hidden when connected
|
||||||
|
await expect(tokenInput).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error toast for invalid JSON", async ({ page }) => {
|
||||||
|
const tokenInput = page.locator("#tokenInput");
|
||||||
|
await tokenInput.fill("not valid json");
|
||||||
|
|
||||||
|
const saveButton = page.getByRole("button", { name: /save tokens/i });
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Error toast should appear
|
||||||
|
const errorToast = page.getByText(/invalid json/i);
|
||||||
|
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error toast for missing required fields", async ({ page }) => {
|
||||||
|
const tokenInput = page.locator("#tokenInput");
|
||||||
|
await tokenInput.fill('{"oauth1": {}}'); // Missing oauth2 and expires_at
|
||||||
|
|
||||||
|
const saveButton = page.getByRole("button", { name: /save tokens/i });
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Error toast should appear for missing oauth2
|
||||||
|
const errorToast = page.getByText(/oauth2 is required/i);
|
||||||
|
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can disconnect after connecting", async ({ page }) => {
|
||||||
|
// First connect
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||||
|
|
||||||
|
const validTokens = JSON.stringify({
|
||||||
|
oauth1: { token: "test-token", secret: "test-secret" },
|
||||||
|
oauth2: { access_token: "test-access-token" },
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenInput = page.locator("#tokenInput");
|
||||||
|
await tokenInput.fill(validTokens);
|
||||||
|
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||||
|
|
||||||
|
// Wait for connected state
|
||||||
|
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click disconnect
|
||||||
|
const disconnectButton = page.getByRole("button", {
|
||||||
|
name: /disconnect/i,
|
||||||
|
});
|
||||||
|
await disconnectButton.click();
|
||||||
|
|
||||||
|
// Wait for disconnect success toast
|
||||||
|
const successToast = page.getByText(/garmin disconnected/i);
|
||||||
|
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify status returns to "Not Connected"
|
||||||
|
await expect(page.getByText(/not connected/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token input should be visible again
|
||||||
|
await expect(page.locator("#tokenInput")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows days until expiry when connected", async ({ page }) => {
|
||||||
|
// Connect with tokens expiring in 45 days
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 45);
|
||||||
|
|
||||||
|
const validTokens = JSON.stringify({
|
||||||
|
oauth1: { token: "test-token", secret: "test-secret" },
|
||||||
|
oauth2: { access_token: "test-access-token" },
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenInput = page.locator("#tokenInput");
|
||||||
|
await tokenInput.fill(validTokens);
|
||||||
|
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||||
|
|
||||||
|
// Wait for connected state
|
||||||
|
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show days until expiry (approximately 45 days)
|
||||||
|
const expiryText = page.getByText(/\d+ days/i);
|
||||||
|
await expect(expiryText).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,6 +54,8 @@ export default defineConfig({
|
|||||||
// Use the test PocketBase instance (port 8091)
|
// Use the test PocketBase instance (port 8091)
|
||||||
NEXT_PUBLIC_POCKETBASE_URL: "http://127.0.0.1:8091",
|
NEXT_PUBLIC_POCKETBASE_URL: "http://127.0.0.1:8091",
|
||||||
POCKETBASE_URL: "http://127.0.0.1:8091",
|
POCKETBASE_URL: "http://127.0.0.1:8091",
|
||||||
|
// Required for Garmin token encryption
|
||||||
|
ENCRYPTION_KEY: "e2e-test-encryption-key-32chars",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,6 +138,57 @@ export const DAILY_LOGS_COLLECTION: CollectionDefinition = {
|
|||||||
*/
|
*/
|
||||||
const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION];
|
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 = [
|
||||||
|
{ 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds custom fields to the users collection if they don't already exist.
|
||||||
|
* 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),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter to only new fields
|
||||||
|
const newFields = USER_CUSTOM_FIELDS.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,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` Added ${newFields.length} field(s) to users:`,
|
||||||
|
newFields.map((f) => f.name),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(" All user fields already exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the names of existing collections from PocketBase.
|
* Gets the names of existing collections from PocketBase.
|
||||||
*/
|
*/
|
||||||
@@ -242,6 +293,10 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add custom fields to users collection
|
||||||
|
console.log("Checking users collection fields...");
|
||||||
|
await addUserFields(pb);
|
||||||
|
|
||||||
// Get existing collections
|
// Get existing collections
|
||||||
const existingNames = await getExistingCollectionNames(pb);
|
const existingNames = await getExistingCollectionNames(pb);
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin";
|
|||||||
export const GET = withAuth(async (_request, user, pb) => {
|
export const GET = withAuth(async (_request, user, pb) => {
|
||||||
// Fetch fresh user data from database (auth store cookie may be stale)
|
// Fetch fresh user data from database (auth store cookie may be stale)
|
||||||
const freshUser = await pb.collection("users").getOne(user.id);
|
const freshUser = await pb.collection("users").getOne(user.id);
|
||||||
const connected = freshUser.garminConnected;
|
// Use strict equality to handle undefined (field missing from schema)
|
||||||
|
const connected = freshUser.garminConnected === true;
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ let currentMockUser: User | null = null;
|
|||||||
// Track PocketBase update calls
|
// Track PocketBase update calls
|
||||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
// Track PocketBase getOne calls - returns user with garminConnected: true after update
|
||||||
|
const mockPbGetOne = vi.fn().mockResolvedValue({ garminConnected: true });
|
||||||
|
|
||||||
// Create mock PocketBase client
|
// Create mock PocketBase client
|
||||||
const mockPb = {
|
const mockPb = {
|
||||||
collection: vi.fn(() => ({
|
collection: vi.fn(() => ({
|
||||||
update: mockPbUpdate,
|
update: mockPbUpdate,
|
||||||
|
getOne: mockPbGetOne,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { encrypt } from "@/lib/encryption";
|
import { encrypt } from "@/lib/encryption";
|
||||||
import { daysUntilExpiry } from "@/lib/garmin";
|
import { daysUntilExpiry } from "@/lib/garmin";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const POST = withAuth(async (request, user, pb) => {
|
export const POST = withAuth(async (request, user, pb) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -63,6 +64,26 @@ export const POST = withAuth(async (request, user, pb) => {
|
|||||||
garminConnected: true,
|
garminConnected: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verify the update persisted (catches schema issues where field doesn't exist)
|
||||||
|
const updatedUser = await pb.collection("users").getOne(user.id);
|
||||||
|
if (updatedUser.garminConnected !== true) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
expected: true,
|
||||||
|
actual: updatedUser.garminConnected,
|
||||||
|
},
|
||||||
|
"garminConnected field not persisted - check PocketBase schema",
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Failed to save connection status. The garminConnected field may be missing from the database schema. Run pnpm db:setup to fix.",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate days until expiry
|
// Calculate days until expiry
|
||||||
const expiryDays = daysUntilExpiry({
|
const expiryDays = daysUntilExpiry({
|
||||||
oauth1: "",
|
oauth1: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user