diff --git a/e2e/journeys/j1-registration-login.spec.ts b/e2e/journeys/j1-registration-login.spec.ts new file mode 100644 index 0000000..ec116ab --- /dev/null +++ b/e2e/journeys/j1-registration-login.spec.ts @@ -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/'); + }); + +}); diff --git a/e2e/journeys/j8-unauth-access.spec.ts b/e2e/journeys/j8-unauth-access.spec.ts new file mode 100644 index 0000000..9ed40da --- /dev/null +++ b/e2e/journeys/j8-unauth-access.spec.ts @@ -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 }); + }); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 996c72f..2819d15 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -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(); }); diff --git a/package-lock.json b/package-lock.json index 6c43a12..04163c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c6cc5d0..6f11531 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts index a2d7b0b..b22d74a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, }, diff --git a/src/App.test.tsx b/src/App.test.tsx index 27e040d..4eeddd3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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() - expect(screen.getByText('CartSnitch')).toBeInTheDocument() - }) - - it('renders the bottom navigation', () => { - render() - 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() + expect(screen.getByText('CartSnitch')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument() + }) +}) diff --git a/src/App.tsx b/src/App.tsx index bfd515a..ee4c2dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,8 +31,8 @@ export default function App() { }> - } /> }> + } /> } /> } /> } /> diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 6c3df87..cf92831 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -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 + return + } if (isPending) { return ( diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 214dcd4..bf6b215 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -46,7 +46,7 @@ export function Login() { } return ( -
+

CartSnitch

Track prices. Save money.

@@ -88,10 +88,10 @@ export function Login() {

Don't have an account?{' '} - + Sign up

-
+ ) }