diff --git a/admin-web/app/login/page.test.tsx b/admin-web/app/login/page.test.tsx new file mode 100644 index 0000000..a6b5c66 --- /dev/null +++ b/admin-web/app/login/page.test.tsx @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import LoginPage from './page' + +// Mock next/navigation +const mockPush = vi.fn() +const mockRefresh = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + refresh: mockRefresh, + }), +})) + +// Mock sonner toast +const mockToastSuccess = vi.fn() +vi.mock('sonner', () => ({ + toast: { + success: (...args: any[]) => mockToastSuccess(...args), + }, +})) + +// Mock supabase +const mockSignInWithPassword = vi.fn() +const mockSignOut = vi.fn() +const mockFrom = vi.fn() + +vi.mock('@/lib/supabase', () => ({ + supabase: { + auth: { + signInWithPassword: (...args: any[]) => mockSignInWithPassword(...args), + signOut: () => mockSignOut(), + }, + from: (...args: any[]) => mockFrom(...args), + }, +})) + +describe('LoginPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render login form', () => { + render() + + expect(screen.getByRole('heading', { name: /tabatafit admin/i })).toBeInTheDocument() + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + expect(screen.getByLabelText(/password/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument() + }) + + it('should update form fields on input', async () => { + render() + + const emailInput = screen.getByLabelText(/email/i) + const passwordInput = screen.getByLabelText(/password/i) + + await userEvent.type(emailInput, 'admin@example.com') + await userEvent.type(passwordInput, 'password123') + + expect(emailInput).toHaveValue('admin@example.com') + expect(passwordInput).toHaveValue('password123') + }) + + it('should show validation errors for empty fields', async () => { + render() + + const submitButton = screen.getByRole('button', { name: /sign in/i }) + + // HTML5 validation should prevent submission + expect(screen.getByLabelText(/email/i)).toBeRequired() + expect(screen.getByLabelText(/password/i)).toBeRequired() + }) + + it('should handle successful login', async () => { + const mockUser = { id: 'user-123', email: 'admin@example.com' } + mockSignInWithPassword.mockResolvedValue({ + data: { user: mockUser }, + error: null + }) + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ + data: { id: 'user-123', email: 'admin@example.com', role: 'admin' }, + error: null + }), + }), + }), + }) + + render() + + await userEvent.type(screen.getByLabelText(/email/i), 'admin@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'password123') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(mockSignInWithPassword).toHaveBeenCalledWith({ + email: 'admin@example.com', + password: 'password123', + }) + }) + + await waitFor(() => { + expect(mockToastSuccess).toHaveBeenCalledWith('Welcome back!', { + description: 'You have successfully signed in.', + }) + }) + }) + + it('should show error for invalid credentials', async () => { + mockSignInWithPassword.mockResolvedValue({ + data: { user: null }, + error: { message: 'Invalid login credentials' } + }) + + render() + + await userEvent.type(screen.getByLabelText(/email/i), 'wrong@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'wrongpassword') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.getByText(/invalid login credentials/i)).toBeInTheDocument() + }) + }) + + it('should handle non-admin user', async () => { + const mockUser = { id: 'user-123', email: 'user@example.com' } + mockSignInWithPassword.mockResolvedValue({ + data: { user: mockUser }, + error: null + }) + + mockFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ data: null, error: null }), + }), + }), + }) + + mockSignOut.mockResolvedValue({ error: null }) + + render() + + await userEvent.type(screen.getByLabelText(/email/i), 'user@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'password123') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.getByText(/not authorized as admin/i)).toBeInTheDocument() + }) + + expect(mockSignOut).toHaveBeenCalled() + }) + + it('should show loading state during login', async () => { + mockSignInWithPassword.mockImplementation(() => new Promise(() => {})) // Never resolves + + render() + + await userEvent.type(screen.getByLabelText(/email/i), 'admin@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'password123') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.getByText(/signing in/i)).toBeInTheDocument() + }) + + const button = screen.getByRole('button', { name: /signing in/i }) + expect(button).toBeDisabled() + }) + + it('should have proper input types', () => { + render() + + expect(screen.getByLabelText(/email/i)).toHaveAttribute('type', 'email') + expect(screen.getByLabelText(/password/i)).toHaveAttribute('type', 'password') + }) + + it('should render logo and branding', () => { + render() + + expect(screen.getByText('TabataFit Admin')).toBeInTheDocument() + expect(screen.getByText(/sign in to manage your content/i)).toBeInTheDocument() + }) + + it('should handle network errors', async () => { + mockSignInWithPassword.mockRejectedValue(new Error('Network error')) + + render() + + await userEvent.type(screen.getByLabelText(/email/i), 'admin@example.com') + await userEvent.type(screen.getByLabelText(/password/i), 'password123') + await userEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.getByText(/network error/i)).toBeInTheDocument() + }) + }) +}) \ No newline at end of file diff --git a/admin-web/app/page.test.tsx b/admin-web/app/page.test.tsx new file mode 100644 index 0000000..760986a --- /dev/null +++ b/admin-web/app/page.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import DashboardPage from './page' + +// Mock next/link +vi.mock('next/link', () => { + return { + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), + } +}) + +// Mock supabase +const mockFrom = vi.fn() + +vi.mock('@/lib/supabase', () => ({ + supabase: { + from: (...args: any[]) => mockFrom(...args), + }, +})) + +describe('DashboardPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render dashboard header', () => { + mockFrom.mockReturnValue({ + select: () => Promise.resolve({ count: 0, error: null }), + }) + + render() + + expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument() + expect(screen.getByText(/overview of your tabatafit content/i)).toBeInTheDocument() + }) + + it('should render stat cards', async () => { + mockFrom + .mockReturnValueOnce({ + select: () => Promise.resolve({ count: 15, error: null }), + }) + .mockReturnValueOnce({ + select: () => Promise.resolve({ count: 5, error: null }), + }) + .mockReturnValueOnce({ + select: () => Promise.resolve({ count: 3, error: null }), + }) + + render() + + await waitFor(() => { + expect(screen.getByText('15')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + expect(screen.getByText('Workouts')).toBeInTheDocument() + expect(screen.getByText('Trainers')).toBeInTheDocument() + expect(screen.getByText('Collections')).toBeInTheDocument() + }) + + it('should show loading state initially', () => { + mockFrom.mockReturnValue({ + select: () => new Promise(() => {}), // Never resolves + }) + + render() + + const statValues = screen.getAllByText('-') + expect(statValues.length).toBeGreaterThan(0) + }) + + it('should render quick actions section', () => { + mockFrom.mockReturnValue({ + select: () => Promise.resolve({ count: 0, error: null }), + }) + + render() + + expect(screen.getByText(/quick actions/i)).toBeInTheDocument() + expect(screen.getByText(/manage workouts/i)).toBeInTheDocument() + expect(screen.getByText(/manage trainers/i)).toBeInTheDocument() + expect(screen.getByText(/upload media/i)).toBeInTheDocument() + }) + + it('should render getting started section', () => { + mockFrom.mockReturnValue({ + select: () => Promise.resolve({ count: 0, error: null }), + }) + + render() + + expect(screen.getByText(/getting started/i)).toBeInTheDocument() + expect(screen.getByText(/create and edit tabata workouts/i)).toBeInTheDocument() + expect(screen.getByText(/manage trainer profiles/i)).toBeInTheDocument() + }) + + it('should link to correct routes', () => { + mockFrom.mockReturnValue({ + select: () => Promise.resolve({ count: 0, error: null }), + }) + + render() + + expect(screen.getByRole('link', { name: /workouts \d+/i })).toHaveAttribute('href', '/workouts') + expect(screen.getByRole('link', { name: /trainers \d+/i })).toHaveAttribute('href', '/trainers') + expect(screen.getByRole('link', { name: /collections \d+/i })).toHaveAttribute('href', '/collections') + }) + + it('should handle stats fetch errors gracefully', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + mockFrom.mockReturnValue({ + select: () => Promise.resolve({ count: null, error: new Error('Database error') }), + }) + + render() + + await waitFor(() => { + expect(consoleError).toHaveBeenCalled() + }) + + consoleError.mockRestore() + }) + + it('should render correct icons', () => { + mockFrom.mockReturnValue({ + select: () => Promise.resolve({ count: 0, error: null }), + }) + + render() + + // All cards should have icons (lucide icons render as svg) + const cards = screen.getAllByRole('article') + cards.forEach(card => { + const icon = card.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) + + it('should apply correct color classes to stats', async () => { + mockFrom + .mockReturnValueOnce({ + select: () => Promise.resolve({ count: 10, error: null }), + }) + .mockReturnValueOnce({ + select: () => Promise.resolve({ count: 5, error: null }), + }) + .mockReturnValueOnce({ + select: () => Promise.resolve({ count: 3, error: null }), + }) + + render() + + await waitFor(() => { + const workoutCard = screen.getByText('Workouts').closest('article') + expect(workoutCard?.querySelector('.text-orange-500')).toBeInTheDocument() + + const trainerCard = screen.getByText('Trainers').closest('article') + expect(trainerCard?.querySelector('.text-blue-500')).toBeInTheDocument() + + const collectionCard = screen.getByText('Collections').closest('article') + expect(collectionCard?.querySelector('.text-green-500')).toBeInTheDocument() + }) + }) + + it('should fetch stats on mount', () => { + mockFrom.mockReturnValue({ + select: () => Promise.resolve({ count: 0, error: null }), + }) + + render() + + expect(mockFrom).toHaveBeenCalledWith('workouts') + expect(mockFrom).toHaveBeenCalledWith('trainers') + expect(mockFrom).toHaveBeenCalledWith('collections') + }) +}) \ No newline at end of file diff --git a/admin-web/components/sidebar.test.tsx b/admin-web/components/sidebar.test.tsx new file mode 100644 index 0000000..2863498 --- /dev/null +++ b/admin-web/components/sidebar.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Sidebar } from './sidebar' + +// Mock next/navigation +const mockPush = vi.fn() +const mockPathname = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), + usePathname: () => mockPathname(), +})) + +// Mock supabase +const mockSignOut = vi.fn() +vi.mock('@/lib/supabase', () => ({ + supabase: { + auth: { + signOut: () => mockSignOut(), + }, + }, +})) + +describe('Sidebar', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPathname.mockReturnValue('/') + }) + + it('should render sidebar with logo and navigation', () => { + render() + + expect(screen.getByText('TabataFit')).toBeInTheDocument() + expect(screen.getByText('Admin Dashboard')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument() + }) + + it('should render all navigation items', () => { + render() + + expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /workouts/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /trainers/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /collections/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /media/i })).toBeInTheDocument() + }) + + it('should highlight active navigation item', () => { + mockPathname.mockReturnValue('/workouts') + render() + + const workoutsLink = screen.getByRole('link', { name: /workouts/i }) + expect(workoutsLink).toHaveClass('text-orange-500') + expect(workoutsLink).toHaveClass('bg-orange-500/10') + }) + + it('should not highlight inactive navigation items', () => { + mockPathname.mockReturnValue('/workouts') + render() + + const dashboardLink = screen.getByRole('link', { name: /dashboard/i }) + expect(dashboardLink).toHaveClass('text-neutral-400') + expect(dashboardLink).not.toHaveClass('bg-orange-500/10') + }) + + it('should navigate to correct routes when clicking links', () => { + render() + + const workoutsLink = screen.getByRole('link', { name: /workouts/i }) + expect(workoutsLink).toHaveAttribute('href', '/workouts') + + const trainersLink = screen.getByRole('link', { name: /trainers/i }) + expect(trainersLink).toHaveAttribute('href', '/trainers') + }) + + it('should handle logout', async () => { + mockSignOut.mockResolvedValue({ error: null }) + render() + + const logoutButton = screen.getByRole('button', { name: /logout/i }) + await userEvent.click(logoutButton) + + await waitFor(() => { + expect(mockSignOut).toHaveBeenCalled() + }) + + expect(mockPush).toHaveBeenCalledWith('/login') + }) + + it('should handle logout errors gracefully', async () => { + mockSignOut.mockRejectedValueOnce(new Error('Network error')) + + render() + + const logoutButton = screen.getByRole('button', { name: /logout/i }) + await userEvent.click(logoutButton) + + await waitFor(() => { + expect(mockSignOut).toHaveBeenCalled() + }) + }) + + it('should have correct styling for navigation', () => { + render() + + const nav = screen.getByRole('navigation') + expect(nav).toBeInTheDocument() + + const links = screen.getAllByRole('link') + links.forEach(link => { + expect(link).toHaveClass('flex', 'items-center', 'gap-3') + }) + }) + + it('should display icons for navigation items', () => { + render() + + // All nav links should have icons (lucide icons render as svg) + const links = screen.getAllByRole('link') + links.forEach(link => { + const icon = link.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) + + it('should be sticky positioned', () => { + render() + + const sidebar = screen.getByRole('complementary') || document.querySelector('aside') + expect(sidebar).toHaveClass('sticky', 'top-0') + }) +}) \ No newline at end of file diff --git a/admin-web/components/ui/dialog.test.tsx b/admin-web/components/ui/dialog.test.tsx new file mode 100644 index 0000000..3ef52e3 --- /dev/null +++ b/admin-web/components/ui/dialog.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from './dialog' +import { Button } from './button' + +describe('Dialog', () => { + it('should render dialog trigger', () => { + render( + + + + + + + Test Title + + + + ) + + expect(screen.getByRole('button', { name: /open dialog/i })).toBeInTheDocument() + }) + + it('should open dialog when trigger is clicked', async () => { + render( + + + + + + + Test Title + Test Description + + + + ) + + await userEvent.click(screen.getByRole('button', { name: /open dialog/i })) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test Description')).toBeInTheDocument() + }) + + it('should close dialog when clicking outside', async () => { + render( + + + + + + + Test Title + + + + ) + + await userEvent.click(screen.getByRole('button', { name: /open dialog/i })) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + // Click on the backdrop (outside the dialog content) + const backdrop = document.querySelector('[data-state="open"]') + if (backdrop) { + await userEvent.click(backdrop) + } + }) + + it('should close dialog when pressing escape', async () => { + render( + + + + + + + Test Title + + + + ) + + await userEvent.click(screen.getByRole('button', { name: /open dialog/i })) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + await userEvent.keyboard('{Escape}') + }) + + it('should render dialog with footer', async () => { + render( + + + + + + + Delete Item + + + + + + + + ) + + await userEvent.click(screen.getByRole('button', { name: /open dialog/i })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument() + }) + }) + + it('should support custom styling', async () => { + render( + + + + + + + Styled Dialog + + + + ) + + await userEvent.click(screen.getByRole('button', { name: /open dialog/i })) + + await waitFor(() => { + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveClass('bg-neutral-900') + expect(dialog).toHaveClass('border-neutral-800') + expect(dialog).toHaveClass('text-white') + }) + }) + + it('should be accessible', async () => { + render( + + + + + + + Accessible Dialog + This is a description + + + + ) + + const trigger = screen.getByRole('button', { name: /open dialog/i }) + expect(trigger).toHaveAttribute('aria-haspopup', 'dialog') + expect(trigger).toHaveAttribute('aria-expanded', 'false') + + await userEvent.click(trigger) + + await waitFor(() => { + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + }) +}) \ No newline at end of file diff --git a/admin-web/e2e/auth.spec.ts b/admin-web/e2e/auth.spec.ts new file mode 100644 index 0000000..782dab9 --- /dev/null +++ b/admin-web/e2e/auth.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test' + +test.describe('Authentication', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login') + }) + + test('should display login form', async ({ page }) => { + await expect(page.getByRole('heading', { name: /tabatafit admin/i })).toBeVisible() + await expect(page.getByLabel(/email/i)).toBeVisible() + await expect(page.getByLabel(/password/i)).toBeVisible() + await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible() + }) + + test('should show validation errors', async ({ page }) => { + await page.getByRole('button', { name: /sign in/i }).click() + + // Check HTML5 validation + const emailInput = page.getByLabel(/email/i) + await expect(emailInput).toHaveAttribute('required', '') + }) + + test('should redirect unauthenticated users to login', async ({ page }) => { + await page.goto('/') + await expect(page).toHaveURL(/.*login/) + }) + + test('should redirect authenticated users away from login', async ({ page }) => { + // This test would need actual auth setup + // For now, just verify the route protection exists + await page.goto('/login') + await expect(page.getByRole('heading', { name: /tabatafit admin/i })).toBeVisible() + }) +}) + +test.describe('Dashboard', () => { + test('should display dashboard stats', async ({ page }) => { + // Navigate to dashboard (will redirect to login if not authenticated) + await page.goto('/') + + // If authenticated, should show dashboard + // If not, should redirect to login + await expect(page.getByRole('heading', { name: /dashboard|tabatafit admin/i })).toBeVisible() + }) + + test('should have working navigation', async ({ page }) => { + await page.goto('/') + + // Check if we can see the sidebar navigation + const sidebar = page.locator('aside, nav').first() + await expect(sidebar).toBeVisible() + + // Check for navigation links + await expect(page.getByRole('link', { name: /workouts/i })).toBeVisible() + await expect(page.getByRole('link', { name: /trainers/i })).toBeVisible() + }) +}) + +test.describe('Navigation', () => { + test('should navigate between pages', async ({ page }) => { + await page.goto('/') + + // Try to navigate to workouts + await page.getByRole('link', { name: /workouts/i }).click() + await expect(page).toHaveURL(/.*workouts/) + + // Navigate to trainers + await page.getByRole('link', { name: /trainers/i }).click() + await expect(page).toHaveURL(/.*trainers/) + }) +}) \ No newline at end of file diff --git a/admin-web/package.json b/admin-web/package.json index 4919669..d41597b 100644 --- a/admin-web/package.json +++ b/admin-web/package.json @@ -9,7 +9,11 @@ "lint": "eslint", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:all": "npm run test && npm run test:e2e" }, "dependencies": { "@supabase/ssr": "^0.9.0", @@ -24,6 +28,7 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/admin-web/playwright.config.ts b/admin-web/playwright.config.ts new file mode 100644 index 0000000..ba1baab --- /dev/null +++ b/admin-web/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], + + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}) \ No newline at end of file