feat: add E2E journey tests for registration and unauth access (#86)

Adds E2E journey tests (J1: registration/login, J8: unauthenticated access), fixes Dashboard auth protection, adds ProtectedRoute mock auth mode, and fixes Login page a11y.

Reviewed and approved by QA (cartsnitch-qa[bot]) and CTO (cartsnitch-cto[bot]).
This commit is contained in:
cartsnitch-ceo[bot]
2026-03-31 19:01:32 +00:00
committed by GitHub
10 changed files with 150 additions and 37 deletions
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
test.describe('J1: Registration and Login', () => {
test('can register a new account and lands on dashboard', async ({ page }) => {
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
await page.fill('[placeholder="Email"]', uniqueEmail());
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
// With VITE_MOCK_AUTH=true the app navigates to "/" on success
await expect(page).toHaveURL('http://localhost:5173/');
await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible();
});
test('shows validation error when registration fields are empty', async ({ page }) => {
await page.goto('/register');
await page.click('button[type="submit"]');
await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields');
});
test('can navigate from register to login', async ({ page }) => {
await page.goto('/register');
await page.getByRole('link', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('can sign in with credentials and land on dashboard', async ({ page }) => {
// Register first so we have a real account
const email = uniqueEmail();
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Login Betty');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:5173/');
// Sign out by clearing the mock session (reload with no session)
await page.goto('/');
await page.reload();
// Now sign in
await page.goto('/login');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:5173/');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
test.describe('J8: Unauthenticated Access', () => {
test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => {
// No session cookie — start fresh
await page.context().clearCookies();
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /purchases to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/purchases');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /products to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/products');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /coupons to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/coupons');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('shows loading spinner while auth session is pending', async ({ page }) => {
// Intercept but don't respond — session stays pending
await page.context().clearCookies();
await page.request.fetch('/api/auth/session', {
method: 'GET',
});
// Just navigate to a protected route — ProtectedRoute will show spinner while session is pending
await page.goto('/purchases');
// Spinner is visible briefly; once resolved, should redirect to login
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
});
});
+6 -4
View File
@@ -1,6 +1,8 @@
import { test, expect } from "./fixtures";
import { test, expect } from './fixtures';
test("app loads", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/CartSnitch/);
test('app loads', async ({ page }) => {
await page.goto('/');
// Unauthenticated users are redirected to /login
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible();
});
+2 -1
View File
@@ -20,7 +20,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.49.0",
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2",
@@ -34,6 +34,7 @@
"globals": "^17.4.0",
"jsdom": "^25.0.1",
"msw": "^2.12.14",
"playwright": "^1.58.2",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.56.1",
+3 -2
View File
@@ -25,7 +25,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.49.0",
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2",
@@ -39,6 +39,7 @@
"globals": "^17.4.0",
"jsdom": "^25.0.1",
"msw": "^2.12.14",
"playwright": "^1.58.2",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.56.1",
@@ -51,4 +52,4 @@
"flatted": "^3.4.2",
"serialize-javascript": "7.0.5"
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
},
],
webServer: {
command: 'npm run dev',
command: 'VITE_MOCK_AUTH=true npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
+17 -23
View File
@@ -1,23 +1,17 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import App from './App.tsx'
vi.mock('./lib/auth-client.ts', () => ({
authClient: {
useSession: () => ({ data: null, isPending: false }),
},
}))
describe('App', () => {
it('renders the dashboard on the root route', () => {
render(<App />)
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
})
it('renders the bottom navigation', () => {
render(<App />)
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('Purchases')).toBeInTheDocument()
expect(screen.getByText('Products')).toBeInTheDocument()
})
})
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import App from './App.tsx'
vi.mock('./lib/auth-client.ts', () => ({
authClient: {
useSession: () => ({ data: null, isPending: false }),
},
}))
describe('App', () => {
it('redirects unauthenticated users to login', () => {
render(<App />)
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
})
})
+1 -1
View File
@@ -31,8 +31,8 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route element={<ProtectedRoute />}>
<Route index element={<Dashboard />} />
<Route path="purchases" element={<Purchases />} />
<Route path="purchases/:id" element={<PurchaseDetail />} />
<Route path="products" element={<Products />} />
+12 -2
View File
@@ -4,12 +4,22 @@ import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() {
const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true'
const { data: session, isPending } = authClient.useSession()
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
useEffect(() => {
setAuthenticated(!!session)
}, [session, setAuthenticated])
if (!isMockAuth) {
setAuthenticated(!!session)
}
}, [session, setAuthenticated, isMockAuth])
// In mock auth mode, rely on Zustand store (set by Login/Register pages)
if (isMockAuth) {
if (!isAuthenticated) return <Navigate to="/login" replace />
return <Outlet />
}
if (isPending) {
return (
+3 -3
View File
@@ -46,7 +46,7 @@ export function Login() {
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<main className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
@@ -88,10 +88,10 @@ export function Login() {
<p className="mt-6 text-sm text-gray-500">
Don't have an account?{' '}
<Link to="/register" className="text-brand-blue">
<Link to="/register" className="text-brand-blue underline">
Sign up
</Link>
</p>
</div>
</main>
)
}