Add 4 Garmin E2E tests for network error recovery
All checks were successful
Deploy / deploy (push) Successful in 2m28s
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Add tests to verify error handling when network requests fail: - Error toast when token save fails (500 response) - Error toast when disconnect fails (500 response) - Error state display when status fetch fails - Retry succeeds after network failure These tests improve resilience coverage for the Garmin connection flow. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
||||
|
||||
## Current Status: Feature Complete
|
||||
|
||||
**Test Coverage:** 1014 unit tests (51 files) + 198 E2E tests (13 files) = 1212 total tests
|
||||
**Test Coverage:** 1014 unit tests (51 files) + 202 E2E tests (13 files) = 1216 total tests
|
||||
|
||||
All P0-P5 items are complete. The project is feature complete.
|
||||
|
||||
@@ -97,14 +97,14 @@ All P0-P5 items are complete. The project is feature complete.
|
||||
| PeriodDateModal | 22 | Period input modal |
|
||||
| Skeletons | 29 | Loading states with shimmer |
|
||||
|
||||
### E2E Tests (13 files, 198 tests)
|
||||
### E2E Tests (13 files, 202 tests)
|
||||
| File | Tests | Coverage |
|
||||
|------|-------|----------|
|
||||
| smoke.spec.ts | 3 | Basic app functionality |
|
||||
| auth.spec.ts | 20 | Login, protected routes, OIDC flow, session persistence |
|
||||
| dashboard.spec.ts | 40 | Dashboard display, overrides, accessibility |
|
||||
| settings.spec.ts | 27 | Settings form, validation, persistence, error recovery |
|
||||
| garmin.spec.ts | 12 | Garmin connection, expiry warnings |
|
||||
| garmin.spec.ts | 16 | Garmin connection, expiry warnings, network error recovery |
|
||||
| period-logging.spec.ts | 19 | Period history, logging, modal flows |
|
||||
| calendar.spec.ts | 34 | Calendar view, ICS feed, content validation, accessibility |
|
||||
| decision-engine.spec.ts | 8 | Decision priority chain |
|
||||
@@ -126,14 +126,10 @@ These are optional enhancements to improve E2E coverage. Not required for featur
|
||||
| notifications.spec.ts | 3 | Notification preferences |
|
||||
| dark-mode.spec.ts | 2 | System preference detection |
|
||||
|
||||
### Remaining Enhancements
|
||||
| File | Additional Tests | Focus Area |
|
||||
|------|------------------|------------|
|
||||
| garmin.spec.ts | +4 | Token refresh, network error recovery |
|
||||
|
||||
### Completed Enhancements
|
||||
| File | Tests Added | Focus Area |
|
||||
|------|-------------|------------|
|
||||
| garmin.spec.ts | +4 | Network error recovery (save, disconnect, status fetch, retry) |
|
||||
| calendar.spec.ts | +4 | Accessibility (ARIA, keyboard nav) |
|
||||
| settings.spec.ts | +1 | Error recovery on failed save |
|
||||
| mobile.spec.ts | +3 | Calendar responsive behavior |
|
||||
@@ -153,6 +149,7 @@ These are optional enhancements to improve E2E coverage. Not required for featur
|
||||
|
||||
## Revision History
|
||||
|
||||
- 2026-01-13: Added 4 Garmin E2E tests (network error recovery for save, disconnect, status fetch, retry)
|
||||
- 2026-01-13: Added 8 E2E tests (calendar accessibility, settings error recovery, calendar mobile behavior)
|
||||
- 2026-01-13: Added mobile.spec.ts with 4 E2E tests (mobile viewport behavior, responsive layout)
|
||||
- 2026-01-13: Added 6 auth E2E tests (OIDC button display, loading states, session persistence across pages/refresh)
|
||||
|
||||
@@ -368,5 +368,171 @@ test.describe("garmin connection", () => {
|
||||
const greenIndicator = page.locator(".bg-green-500").first();
|
||||
await expect(greenIndicator).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows error toast when network fails during token save", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Intercept the POST request and simulate network failure
|
||||
await page.route("**/api/garmin/tokens", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: "Internal server error" }),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Enter valid tokens
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-network", 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();
|
||||
|
||||
// Error toast should appear
|
||||
const errorToast = page.getByText(/internal server error/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Token input should still be visible for retry (this is the key behavior)
|
||||
await expect(tokenInput).toBeVisible();
|
||||
|
||||
// Should NOT show success - either "Not Connected" or "Token Expired" state
|
||||
// (depends on prior test state), but definitely not "Connected" without expiry
|
||||
const connectedWithoutExpiry =
|
||||
(await page.getByText("Connected", { exact: true }).isVisible()) &&
|
||||
!(await page.getByText(/token expired/i).isVisible());
|
||||
expect(connectedWithoutExpiry).toBe(false);
|
||||
});
|
||||
|
||||
test("shows error toast when network fails during disconnect", async ({
|
||||
page,
|
||||
}) => {
|
||||
// First connect successfully
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-disconnect-error", 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,
|
||||
});
|
||||
|
||||
// Now intercept DELETE request to simulate network failure
|
||||
await page.route("**/api/garmin/tokens", (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: "Failed to disconnect" }),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Click disconnect
|
||||
await page.getByRole("button", { name: /disconnect/i }).click();
|
||||
|
||||
// Error toast should appear
|
||||
const errorToast = page.getByText(/failed to disconnect/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should still show connected state (disconnect failed)
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows error state when status fetch fails", async ({ page }) => {
|
||||
// Intercept status fetch to simulate network failure
|
||||
await page.route("**/api/garmin/status", (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: "Service unavailable" }),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate to garmin settings (need to re-navigate to trigger fresh fetch)
|
||||
await page.goto("/settings/garmin");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Error alert should be visible (use specific text to avoid matching route announcer)
|
||||
const errorAlert = page.getByText("Service unavailable");
|
||||
await expect(errorAlert).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Error toast should also appear
|
||||
const errorToast = page.getByText(/unable to fetch data/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("can retry and succeed after network failure", async ({ page }) => {
|
||||
let requestCount = 0;
|
||||
|
||||
// First request fails, subsequent requests succeed
|
||||
await page.route("**/api/garmin/tokens", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
// First attempt fails
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: "Temporary failure" }),
|
||||
});
|
||||
} else {
|
||||
// Subsequent attempts succeed
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-retry", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
|
||||
// First attempt - should fail
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Error toast should appear
|
||||
const errorToast = page.getByText(/temporary failure/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Wait for toast to disappear or proceed with retry
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Retry - should succeed now
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Success toast should appear
|
||||
const successToast = page.getByText(/tokens saved successfully/i);
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user