Compare commits

...

2 Commits

Author SHA1 Message Date
27f084f950 Fix Garmin token connection not persisting after save
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>
2026-01-13 13:20:50 +00:00
3b9e023736 Remove CI workflow. 2026-01-13 09:41:39 +00:00
7 changed files with 275 additions and 59 deletions

View File

@@ -1,58 +0,0 @@
# ABOUTME: Gitea Actions workflow for CI quality gates on pull requests.
# ABOUTME: Runs lint, typecheck, and unit tests before merge is allowed.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linter
run: pnpm lint
- name: Run typecheck
run: pnpm tsc --noEmit
- name: Run unit tests
run: pnpm test:run
env:
# Required env vars for tests (dummy values for CI)
NEXT_PUBLIC_POCKETBASE_URL: http://localhost:8090
RESEND_API_KEY: re_test_key
ENCRYPTION_KEY: 12345678901234567890123456789012
CRON_SECRET: test_cron_secret

191
e2e/garmin.spec.ts Normal file
View 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();
});
});
});

View File

@@ -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",
}, },
}, },
}); });

View File

@@ -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(

View File

@@ -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({

View File

@@ -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,
})), })),
}; };

View File

@@ -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: "",