Compare commits
2 Commits
8c59b3bd67
...
27f084f950
| Author | SHA1 | Date | |
|---|---|---|---|
| 27f084f950 | |||
| 3b9e023736 |
@@ -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
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)
|
||||
NEXT_PUBLIC_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];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -242,6 +293,10 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Add custom fields to users collection
|
||||
console.log("Checking users collection fields...");
|
||||
await addUserFields(pb);
|
||||
|
||||
// Get existing collections
|
||||
const existingNames = await getExistingCollectionNames(pb);
|
||||
console.log(
|
||||
|
||||
@@ -8,7 +8,8 @@ import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin";
|
||||
export const GET = withAuth(async (_request, user, pb) => {
|
||||
// Fetch fresh user data from database (auth store cookie may be stale)
|
||||
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) {
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -12,10 +12,14 @@ let currentMockUser: User | null = null;
|
||||
// Track PocketBase update calls
|
||||
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
|
||||
const mockPb = {
|
||||
collection: vi.fn(() => ({
|
||||
update: mockPbUpdate,
|
||||
getOne: mockPbGetOne,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
|
||||
import { withAuth } from "@/lib/auth-middleware";
|
||||
import { encrypt } from "@/lib/encryption";
|
||||
import { daysUntilExpiry } from "@/lib/garmin";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const POST = withAuth(async (request, user, pb) => {
|
||||
const body = await request.json();
|
||||
@@ -63,6 +64,26 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
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
|
||||
const expiryDays = daysUntilExpiry({
|
||||
oauth1: "",
|
||||
|
||||
Reference in New Issue
Block a user