ci: add test job to GitHub workflow and setup testing dependencies
This commit is contained in:
parent
cf90bf52ec
commit
0d4ce1d339
21 changed files with 5509 additions and 3 deletions
42
.github/workflows/deploy.yml
vendored
42
.github/workflows/deploy.yml
vendored
|
|
@ -10,6 +10,46 @@ permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
name: 🧪 Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: brackt_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🛑 Cancel Previous Runs
|
||||||
|
uses: styfle/cancel-workflow-action@0.12.1
|
||||||
|
|
||||||
|
- name: ⬇️ Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: ⎔ Setup node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: 📥 Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: 🧪 Run unit tests
|
||||||
|
run: npm run test:run
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: ʦ TypeScript
|
name: ʦ TypeScript
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -87,7 +127,7 @@ jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: 🚀 Deploy
|
name: 🚀 Deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [typecheck, build]
|
needs: [test, typecheck, build]
|
||||||
# only deploy main/dev branch on pushes
|
# only deploy main/dev branch on pushes
|
||||||
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||||
|
|
||||||
|
|
|
||||||
90
.github/workflows/test.yml
vendored
Normal file
90
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: brackt_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run type check
|
||||||
|
run: npm run typecheck
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test:run
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
||||||
|
|
||||||
|
- name: Upload test coverage
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
files: ./coverage/coverage-final.json
|
||||||
|
flags: unittests
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Comment test results on PR
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
if: github.event_name == 'pull_request' && always()
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
let coverageComment = '## ✅ Tests Passed\n\n';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const coverage = JSON.parse(fs.readFileSync('./coverage/coverage-summary.json', 'utf8'));
|
||||||
|
const total = coverage.total;
|
||||||
|
|
||||||
|
coverageComment += '**Coverage:**\n';
|
||||||
|
coverageComment += `- Lines: ${total.lines.pct}%\n`;
|
||||||
|
coverageComment += `- Statements: ${total.statements.pct}%\n`;
|
||||||
|
coverageComment += `- Functions: ${total.functions.pct}%\n`;
|
||||||
|
coverageComment += `- Branches: ${total.branches.pct}%\n`;
|
||||||
|
} catch (error) {
|
||||||
|
coverageComment += 'Coverage report not available.\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: coverageComment
|
||||||
|
});
|
||||||
|
continue-on-error: true
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -9,3 +9,7 @@
|
||||||
|
|
||||||
# Compiled server output
|
# Compiled server output
|
||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos
|
||||||
|
/cypress/screenshots
|
||||||
|
|
|
||||||
237
TESTING.md
Normal file
237
TESTING.md
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
This document explains how to run and write tests for Brackt.com.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
We use two testing frameworks:
|
||||||
|
- **Vitest + React Testing Library** - Unit and component tests
|
||||||
|
- **Cypress** - End-to-end integration tests
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests in watch mode (interactive)
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run tests once (CI mode)
|
||||||
|
npm run test:run
|
||||||
|
|
||||||
|
# Run tests with UI
|
||||||
|
npm run test:ui
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open Cypress interactive mode
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run Cypress headless (CI mode)
|
||||||
|
npm run test:e2e:headless
|
||||||
|
|
||||||
|
# Run all tests (unit + E2E)
|
||||||
|
npm run test:all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Unit tests go in `__tests__` directories next to the code they test:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
lib/
|
||||||
|
utils.ts
|
||||||
|
__tests__/
|
||||||
|
utils.test.ts
|
||||||
|
models/
|
||||||
|
league.ts
|
||||||
|
__tests__/
|
||||||
|
league.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Example unit test:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { myFunction } from '../myFunction';
|
||||||
|
|
||||||
|
describe('myFunction', () => {
|
||||||
|
it('should do something', () => {
|
||||||
|
const result = myFunction('input');
|
||||||
|
expect(result).toBe('expected output');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Tests
|
||||||
|
|
||||||
|
Component tests also go in `__tests__` directories:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
components/
|
||||||
|
DraftGrid.tsx
|
||||||
|
__tests__/
|
||||||
|
DraftGrid.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Example component test:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MyComponent } from '../MyComponent';
|
||||||
|
|
||||||
|
describe('MyComponent', () => {
|
||||||
|
it('should render correctly', () => {
|
||||||
|
render(<MyComponent title="Test" />);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
E2E tests go in the `cypress/e2e` directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
cypress/
|
||||||
|
e2e/
|
||||||
|
league-creation.cy.ts
|
||||||
|
league-settings.cy.ts
|
||||||
|
draft-room.cy.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Example E2E test:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('Feature Name', () => {
|
||||||
|
it('should do something', () => {
|
||||||
|
cy.visit('/some-page');
|
||||||
|
cy.get('button').click();
|
||||||
|
cy.findByText('Expected Text').should('exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Fixtures
|
||||||
|
|
||||||
|
Reusable test data is stored in `app/test/fixtures/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/test/fixtures/league.ts
|
||||||
|
export const mockLeague = {
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'Test League',
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Import and use in tests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mockLeague } from '~/test/fixtures/league';
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
Tests run automatically on:
|
||||||
|
- Every push to `main` or `develop`
|
||||||
|
- Every pull request
|
||||||
|
|
||||||
|
### GitHub Actions Workflow
|
||||||
|
|
||||||
|
1. **Test Job** - Runs unit tests with PostgreSQL service
|
||||||
|
2. **Typecheck Job** - Runs TypeScript type checking
|
||||||
|
3. **Build Job** - Builds Docker image (only on main branch)
|
||||||
|
4. **Deploy Job** - Deploys to server (only after all tests pass)
|
||||||
|
|
||||||
|
**Tests must pass before deployment!**
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
Coverage reports are generated automatically:
|
||||||
|
- HTML report: `coverage/index.html`
|
||||||
|
- JSON report: `coverage/coverage-final.json`
|
||||||
|
|
||||||
|
View coverage locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
open coverage/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- Test one thing per test
|
||||||
|
- Use descriptive test names
|
||||||
|
- Mock external dependencies
|
||||||
|
- Aim for 80%+ coverage
|
||||||
|
|
||||||
|
### Component Tests
|
||||||
|
- Test user interactions, not implementation
|
||||||
|
- Use `screen.getByRole()` when possible
|
||||||
|
- Test accessibility
|
||||||
|
- Keep tests focused
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
- Test critical user flows
|
||||||
|
- Use `data-testid` sparingly
|
||||||
|
- Prefer semantic queries
|
||||||
|
- Keep tests independent
|
||||||
|
|
||||||
|
## Debugging Tests
|
||||||
|
|
||||||
|
### Vitest
|
||||||
|
```bash
|
||||||
|
# Run specific test file
|
||||||
|
npm test -- path/to/test.test.ts
|
||||||
|
|
||||||
|
# Run tests matching pattern
|
||||||
|
npm test -- --grep "pattern"
|
||||||
|
|
||||||
|
# Debug in VS Code
|
||||||
|
# Add breakpoint and use "Debug Test" in test file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cypress
|
||||||
|
```bash
|
||||||
|
# Open Cypress UI for debugging
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npx cypress run --spec "cypress/e2e/specific-test.cy.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Tests fail locally but pass in CI
|
||||||
|
- Check Node version matches CI (v20)
|
||||||
|
- Ensure database is running locally
|
||||||
|
- Check environment variables
|
||||||
|
|
||||||
|
### Cypress tests timeout
|
||||||
|
- Increase timeout in `cypress.config.ts`
|
||||||
|
- Check if app is running on correct port
|
||||||
|
- Verify selectors are correct
|
||||||
|
|
||||||
|
### Coverage not generating
|
||||||
|
- Ensure `@vitest/coverage-v8` is installed
|
||||||
|
- Check `vitest.config.ts` coverage settings
|
||||||
|
- Run with `npm run test:coverage`
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Vitest Documentation](https://vitest.dev/)
|
||||||
|
- [React Testing Library](https://testing-library.com/react)
|
||||||
|
- [Cypress Documentation](https://docs.cypress.io/)
|
||||||
|
- [Testing Best Practices](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)
|
||||||
170
app/components/__tests__/DraftGrid.test.tsx
Normal file
170
app/components/__tests__/DraftGrid.test.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { DraftGrid } from '../DraftGrid';
|
||||||
|
import { mockDraftSlots } from '~/test/fixtures/team';
|
||||||
|
|
||||||
|
describe('DraftGrid Component', () => {
|
||||||
|
const mockGrid = [
|
||||||
|
[
|
||||||
|
{ participant: { name: 'Player 1' }, sport: { name: 'NFL' } },
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
null,
|
||||||
|
{ participant: { name: 'Player 2' }, sport: { name: 'NBA' } },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render team names in headers', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Team 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render draft grid with correct structure', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have cells for all picks
|
||||||
|
const cells = screen.getAllByTitle(/Overall Pick/i);
|
||||||
|
expect(cells.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display picked players', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Player 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('NFL')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Player 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('NBA')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Current Pick Highlighting', () => {
|
||||||
|
it('should highlight current pick with "On Clock" text', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('On Clock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply correct styling to current pick', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPickCell = screen.getByText('On Clock').parentElement;
|
||||||
|
expect(currentPickCell).toHaveClass('border-blue-500');
|
||||||
|
expect(currentPickCell).toHaveClass('bg-blue-50');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pick Numbering', () => {
|
||||||
|
it('should display round and pick numbers', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show format like "1.01", "1.02", etc.
|
||||||
|
expect(screen.getByText(/1\.01/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Optional Features', () => {
|
||||||
|
it('should render with title when provided', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={1}
|
||||||
|
title="Draft Board"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Draft Board')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display timers when provided', () => {
|
||||||
|
const formatTime = (seconds: number | undefined) => {
|
||||||
|
if (seconds === undefined) return '--:--';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={1}
|
||||||
|
teamTimers={{ 'team-1': 120, 'team-2': 90 }}
|
||||||
|
formatTime={formatTime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('2:00')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1:30')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Snake Draft Order', () => {
|
||||||
|
it('should reverse picks on even rounds', () => {
|
||||||
|
const snakeGrid = [
|
||||||
|
[
|
||||||
|
{ participant: { name: 'Pick 1' }, sport: { name: 'NFL' } },
|
||||||
|
{ participant: { name: 'Pick 2' }, sport: { name: 'NBA' } },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ participant: { name: 'Pick 4' }, sport: { name: 'MLB' } },
|
||||||
|
{ participant: { name: 'Pick 3' }, sport: { name: 'NHL' } },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockDraftSlots}
|
||||||
|
draftGrid={snakeGrid}
|
||||||
|
currentPick={5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// All picks should be visible
|
||||||
|
expect(screen.getByText('Pick 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pick 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pick 3')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pick 4')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
125
app/lib/__tests__/utils.test.ts
Normal file
125
app/lib/__tests__/utils.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { cn } from '../utils';
|
||||||
|
|
||||||
|
describe('Utils', () => {
|
||||||
|
describe('cn (className merger)', () => {
|
||||||
|
it('should merge class names', () => {
|
||||||
|
const result = cn('foo', 'bar');
|
||||||
|
expect(result).toContain('foo');
|
||||||
|
expect(result).toContain('bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle conditional classes', () => {
|
||||||
|
const result = cn('foo', false && 'bar', 'baz');
|
||||||
|
expect(result).toContain('foo');
|
||||||
|
expect(result).toContain('baz');
|
||||||
|
expect(result).not.toContain('bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined and null', () => {
|
||||||
|
const result = cn('foo', undefined, null, 'bar');
|
||||||
|
expect(result).toContain('foo');
|
||||||
|
expect(result).toContain('bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge tailwind classes correctly', () => {
|
||||||
|
const result = cn('px-2 py-1', 'px-4');
|
||||||
|
// Should prefer the last px value
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Draft Time Formatting', () => {
|
||||||
|
const formatTime = (seconds: number | undefined): string => {
|
||||||
|
if (seconds === undefined) return '--:--';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should format time correctly', () => {
|
||||||
|
expect(formatTime(120)).toBe('2:00');
|
||||||
|
expect(formatTime(90)).toBe('1:30');
|
||||||
|
expect(formatTime(0)).toBe('0:00');
|
||||||
|
expect(formatTime(3661)).toBe('61:01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined', () => {
|
||||||
|
expect(formatTime(undefined)).toBe('--:--');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pad seconds with zero', () => {
|
||||||
|
expect(formatTime(65)).toBe('1:05');
|
||||||
|
expect(formatTime(5)).toBe('0:05');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('League Name Validation', () => {
|
||||||
|
const isValidLeagueName = (name: string): boolean => {
|
||||||
|
return name.trim().length >= 3 && name.trim().length <= 50;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should accept valid league names', () => {
|
||||||
|
expect(isValidLeagueName('My League')).toBe(true);
|
||||||
|
expect(isValidLeagueName('Test League 2025')).toBe(true);
|
||||||
|
expect(isValidLeagueName('ABC')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject names that are too short', () => {
|
||||||
|
expect(isValidLeagueName('ab')).toBe(false);
|
||||||
|
expect(isValidLeagueName('a')).toBe(false);
|
||||||
|
expect(isValidLeagueName('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject names that are too long', () => {
|
||||||
|
const longName = 'a'.repeat(51);
|
||||||
|
expect(isValidLeagueName(longName)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace before validation', () => {
|
||||||
|
expect(isValidLeagueName(' abc ')).toBe(true);
|
||||||
|
expect(isValidLeagueName(' ab ')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Draft Speed Presets', () => {
|
||||||
|
type DraftSpeed = 'fast' | 'standard' | 'slow' | 'very-slow';
|
||||||
|
|
||||||
|
const getDraftTimes = (speed: DraftSpeed): { initial: number; increment: number } => {
|
||||||
|
switch (speed) {
|
||||||
|
case 'fast':
|
||||||
|
return { initial: 60, increment: 10 };
|
||||||
|
case 'standard':
|
||||||
|
return { initial: 120, increment: 15 };
|
||||||
|
case 'slow':
|
||||||
|
return { initial: 28800, increment: 3600 };
|
||||||
|
case 'very-slow':
|
||||||
|
return { initial: 43200, increment: 3600 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return correct times for fast speed', () => {
|
||||||
|
const times = getDraftTimes('fast');
|
||||||
|
expect(times.initial).toBe(60);
|
||||||
|
expect(times.increment).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct times for standard speed', () => {
|
||||||
|
const times = getDraftTimes('standard');
|
||||||
|
expect(times.initial).toBe(120);
|
||||||
|
expect(times.increment).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct times for slow speed', () => {
|
||||||
|
const times = getDraftTimes('slow');
|
||||||
|
expect(times.initial).toBe(28800); // 8 hours
|
||||||
|
expect(times.increment).toBe(3600); // 1 hour
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct times for very-slow speed', () => {
|
||||||
|
const times = getDraftTimes('very-slow');
|
||||||
|
expect(times.initial).toBe(43200); // 12 hours
|
||||||
|
expect(times.increment).toBe(3600); // 1 hour
|
||||||
|
});
|
||||||
|
});
|
||||||
22
app/test/fixtures/league.ts
vendored
Normal file
22
app/test/fixtures/league.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
export const mockLeague = {
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'Test League',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
currentSeasonId: 'season-1',
|
||||||
|
isPublicDraftBoard: false,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockLeagues = [
|
||||||
|
mockLeague,
|
||||||
|
{
|
||||||
|
id: 'league-2',
|
||||||
|
name: 'Another League',
|
||||||
|
createdBy: 'user-2',
|
||||||
|
currentSeasonId: 'season-2',
|
||||||
|
isPublicDraftBoard: true,
|
||||||
|
createdAt: new Date('2025-01-02'),
|
||||||
|
updatedAt: new Date('2025-01-02'),
|
||||||
|
},
|
||||||
|
];
|
||||||
33
app/test/fixtures/season.ts
vendored
Normal file
33
app/test/fixtures/season.ts
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export const mockSeason = {
|
||||||
|
id: 'season-1',
|
||||||
|
leagueId: 'league-1',
|
||||||
|
year: 2025,
|
||||||
|
status: 'pre_draft' as const,
|
||||||
|
templateId: null,
|
||||||
|
draftRounds: 20,
|
||||||
|
flexSpots: 0,
|
||||||
|
draftDateTime: new Date('2025-12-01T19:00:00'),
|
||||||
|
draftInitialTime: 120,
|
||||||
|
draftIncrementTime: 15,
|
||||||
|
currentPickNumber: 1,
|
||||||
|
draftStartedAt: null,
|
||||||
|
draftPaused: false,
|
||||||
|
inviteCode: 'TEST123',
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockActiveSeason = {
|
||||||
|
...mockSeason,
|
||||||
|
id: 'season-2',
|
||||||
|
status: 'active' as const,
|
||||||
|
draftStartedAt: new Date('2025-12-01T19:00:00'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockDraftSeason = {
|
||||||
|
...mockSeason,
|
||||||
|
id: 'season-3',
|
||||||
|
status: 'draft' as const,
|
||||||
|
draftStartedAt: new Date('2025-12-01T19:00:00'),
|
||||||
|
currentPickNumber: 5,
|
||||||
|
};
|
||||||
48
app/test/fixtures/team.ts
vendored
Normal file
48
app/test/fixtures/team.ts
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
export const mockTeam = {
|
||||||
|
id: 'team-1',
|
||||||
|
name: 'Team 1',
|
||||||
|
seasonId: 'season-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
draftPosition: 1,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockTeams = [
|
||||||
|
mockTeam,
|
||||||
|
{
|
||||||
|
id: 'team-2',
|
||||||
|
name: 'Team 2',
|
||||||
|
seasonId: 'season-1',
|
||||||
|
ownerId: 'user-2',
|
||||||
|
draftPosition: 2,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'team-3',
|
||||||
|
name: 'Team 3',
|
||||||
|
seasonId: 'season-1',
|
||||||
|
ownerId: null,
|
||||||
|
draftPosition: 3,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockDraftSlots = [
|
||||||
|
{
|
||||||
|
id: 'slot-1',
|
||||||
|
seasonId: 'season-1',
|
||||||
|
teamId: 'team-1',
|
||||||
|
draftOrder: 1,
|
||||||
|
team: mockTeams[0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slot-2',
|
||||||
|
seasonId: 'season-1',
|
||||||
|
teamId: 'team-2',
|
||||||
|
draftOrder: 2,
|
||||||
|
team: mockTeams[1],
|
||||||
|
},
|
||||||
|
];
|
||||||
29
app/test/setup.ts
Normal file
29
app/test/setup.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Cleanup after each test
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock environment variables
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
// Mock Clerk
|
||||||
|
vi.mock('@clerk/react-router/server', () => ({
|
||||||
|
getAuth: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })),
|
||||||
|
clerkMiddleware: vi.fn(() => (req: any, res: any, next: any) => next()),
|
||||||
|
rootAuthLoader: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock database context
|
||||||
|
vi.mock('~/database/context', () => ({
|
||||||
|
database: vi.fn(() => ({
|
||||||
|
query: {},
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
18
cypress.config.js
Normal file
18
cypress.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig } from 'cypress';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: {
|
||||||
|
baseUrl: 'http://localhost:3000',
|
||||||
|
setupNodeEvents(_on, _config) {
|
||||||
|
// implement node event listeners here
|
||||||
|
},
|
||||||
|
supportFile: 'cypress/support/e2e.ts',
|
||||||
|
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
|
||||||
|
video: true,
|
||||||
|
screenshotOnRunFailure: true,
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
TEST_USER_EMAIL: 'test@example.com',
|
||||||
|
TEST_USER_PASSWORD: 'testpassword123',
|
||||||
|
},
|
||||||
|
});
|
||||||
124
cypress/e2e/draft-room.cy.ts
Normal file
124
cypress/e2e/draft-room.cy.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
describe('Draft Room', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Note: You'll need to implement proper authentication for real tests
|
||||||
|
// cy.login(Cypress.env('TEST_USER_EMAIL'), Cypress.env('TEST_USER_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: These tests require authentication and a test league/season to exist
|
||||||
|
it.skip('should display draft grid with all teams', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Verify team headers are visible
|
||||||
|
cy.findByText('Team 1').should('exist');
|
||||||
|
cy.findByText('Team 2').should('exist');
|
||||||
|
|
||||||
|
// Verify draft grid is rendered
|
||||||
|
cy.get('[title*="Overall Pick"]').should('have.length.at.least', 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should show current pick highlighted', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Current pick should have special styling
|
||||||
|
cy.findByText('On Clock').should('exist');
|
||||||
|
cy.findByText('On Clock')
|
||||||
|
.parent()
|
||||||
|
.should('have.class', 'border-blue-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should display search functionality', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Search input should exist
|
||||||
|
cy.get('input[placeholder*="Search"]').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should filter by sport', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Sport filter should exist
|
||||||
|
cy.get('select').contains(/all sports/i).should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should allow commissioner to start draft', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Click start draft button
|
||||||
|
cy.findByRole('button', { name: /start draft/i }).click();
|
||||||
|
|
||||||
|
// Verify draft started (timers should appear)
|
||||||
|
cy.get('[class*="font-mono"]').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should make a pick', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Search for a player
|
||||||
|
cy.get('input[placeholder*="Search"]').type('Aaron Judge');
|
||||||
|
|
||||||
|
// Click on player to select
|
||||||
|
cy.findByText('Aaron Judge').click();
|
||||||
|
|
||||||
|
// Confirm pick
|
||||||
|
cy.findByRole('button', { name: /confirm pick/i }).click();
|
||||||
|
|
||||||
|
// Verify pick appears in grid
|
||||||
|
cy.findByText('Aaron Judge').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should add player to queue', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Find a player and add to queue
|
||||||
|
cy.findByText('Mike Trout')
|
||||||
|
.parent()
|
||||||
|
.findByRole('button', { name: /add to queue/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Verify in queue section
|
||||||
|
cy.findByText(/your queue/i)
|
||||||
|
.parent()
|
||||||
|
.findByText('Mike Trout')
|
||||||
|
.should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should display timers for all teams', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Start draft first
|
||||||
|
cy.findByRole('button', { name: /start draft/i }).click();
|
||||||
|
|
||||||
|
// Verify timers are displayed (format: MM:SS)
|
||||||
|
cy.get('[class*="font-mono"]').should('contain.text', ':');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should allow commissioner to pause/resume draft', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Start draft first
|
||||||
|
cy.findByRole('button', { name: /start draft/i }).click();
|
||||||
|
|
||||||
|
// Pause draft
|
||||||
|
cy.findByRole('button', { name: /pause/i }).click();
|
||||||
|
|
||||||
|
// Resume draft
|
||||||
|
cy.findByRole('button', { name: /resume/i }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should show live connection status', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Should show connection indicator
|
||||||
|
cy.findByText(/live|connected/i).should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should toggle hide drafted players', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Find the toggle
|
||||||
|
cy.get('input[type="checkbox"]').first().click();
|
||||||
|
|
||||||
|
// Verify state changed
|
||||||
|
cy.get('input[type="checkbox"]').first().should('be.checked');
|
||||||
|
});
|
||||||
|
});
|
||||||
95
cypress/e2e/league-creation.cy.ts
Normal file
95
cypress/e2e/league-creation.cy.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
describe('League Creation Flow', () => {
|
||||||
|
// Note: All tests that require authentication are skipped
|
||||||
|
// The /leagues/new route requires Clerk authentication
|
||||||
|
|
||||||
|
it.skip('should display the league creation form', () => {
|
||||||
|
// Requires authentication
|
||||||
|
cy.visit('/leagues/new');
|
||||||
|
|
||||||
|
// Check that form elements exist
|
||||||
|
cy.get('input[name="name"]').should('exist');
|
||||||
|
cy.get('#teamCount').should('exist'); // Radix Select trigger
|
||||||
|
cy.get('#draftRounds').should('exist'); // Radix Select trigger
|
||||||
|
cy.get('select[name="draftSpeed"]').should('exist'); // Native select
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should validate required fields', () => {
|
||||||
|
// Requires authentication
|
||||||
|
cy.visit('/leagues/new');
|
||||||
|
|
||||||
|
// Try to submit without filling anything
|
||||||
|
cy.get('button[type="submit"]').click();
|
||||||
|
|
||||||
|
// Should show validation or stay on page
|
||||||
|
cy.url().should('include', '/leagues/new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should show draft speed options', () => {
|
||||||
|
// Requires authentication
|
||||||
|
cy.visit('/leagues/new');
|
||||||
|
|
||||||
|
// Native select for draft speed
|
||||||
|
cy.get('select[name="draftSpeed"]').select('fast');
|
||||||
|
cy.get('select[name="draftSpeed"]').should('have.value', 'fast');
|
||||||
|
|
||||||
|
cy.get('select[name="draftSpeed"]').select('standard');
|
||||||
|
cy.get('select[name="draftSpeed"]').should('have.value', 'standard');
|
||||||
|
|
||||||
|
cy.get('select[name="draftSpeed"]').select('slow');
|
||||||
|
cy.get('select[name="draftSpeed"]').should('have.value', 'slow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should allow selecting team count', () => {
|
||||||
|
// Requires authentication
|
||||||
|
cy.visit('/leagues/new');
|
||||||
|
|
||||||
|
// Click Radix Select trigger
|
||||||
|
cy.get('#teamCount').click();
|
||||||
|
|
||||||
|
// Select from dropdown
|
||||||
|
cy.contains('[role="option"]', '12 Teams').click();
|
||||||
|
|
||||||
|
// Verify selection
|
||||||
|
cy.get('#teamCount').should('contain', '12 Teams');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should allow selecting draft rounds', () => {
|
||||||
|
// Requires authentication
|
||||||
|
cy.visit('/leagues/new');
|
||||||
|
|
||||||
|
// Click Radix Select trigger
|
||||||
|
cy.get('#draftRounds').click();
|
||||||
|
|
||||||
|
// Select from dropdown
|
||||||
|
cy.contains('[role="option"]', '20').click();
|
||||||
|
|
||||||
|
// Verify selection
|
||||||
|
cy.get('#draftRounds').should('contain', '20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should create a new league with all settings', () => {
|
||||||
|
// Requires authentication and database setup
|
||||||
|
cy.visit('/leagues/new');
|
||||||
|
|
||||||
|
// Fill in league details
|
||||||
|
cy.get('input[name="name"]').type('My Test League');
|
||||||
|
|
||||||
|
// Select team count (Radix Select)
|
||||||
|
cy.get('#teamCount').click();
|
||||||
|
cy.contains('[role="option"]', '12 Teams').click();
|
||||||
|
|
||||||
|
// Select draft rounds (Radix Select)
|
||||||
|
cy.get('#draftRounds').click();
|
||||||
|
cy.contains('[role="option"]', '20').click();
|
||||||
|
|
||||||
|
// Set draft speed (native select)
|
||||||
|
cy.get('select[name="draftSpeed"]').select('standard');
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
cy.get('button[type="submit"]').click();
|
||||||
|
|
||||||
|
// Verify redirect to league page
|
||||||
|
cy.url().should('include', '/leagues/');
|
||||||
|
cy.findByText('My Test League').should('exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
77
cypress/e2e/league-settings.cy.ts
Normal file
77
cypress/e2e/league-settings.cy.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
describe('League Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Note: You'll need to implement proper authentication for real tests
|
||||||
|
// cy.login(Cypress.env('TEST_USER_EMAIL'), Cypress.env('TEST_USER_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: These tests require authentication and a test league to exist
|
||||||
|
it.skip('should display league settings page', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
cy.get('input[name="name"]').should('exist');
|
||||||
|
cy.get('select[name="teamCount"]').should('exist');
|
||||||
|
cy.get('select[name="draftSpeed"]').should('exist');
|
||||||
|
cy.get('input[name="isPublicDraftBoard"]').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should update league name', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
cy.get('input[name="name"]').clear().type('Updated League Name');
|
||||||
|
cy.get('button[type="submit"]').contains(/save/i).click();
|
||||||
|
|
||||||
|
// Verify success
|
||||||
|
cy.findByDisplayValue('Updated League Name').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should toggle public draft board', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
// Check the public draft board checkbox
|
||||||
|
cy.get('input[name="isPublicDraftBoard"]').check();
|
||||||
|
cy.get('button[type="submit"]').contains(/save/i).click();
|
||||||
|
|
||||||
|
// Verify it's checked after save
|
||||||
|
cy.get('input[name="isPublicDraftBoard"]').should('be.checked');
|
||||||
|
|
||||||
|
// Uncheck it
|
||||||
|
cy.get('input[name="isPublicDraftBoard"]').uncheck();
|
||||||
|
cy.get('button[type="submit"]').contains(/save/i).click();
|
||||||
|
|
||||||
|
// Verify it's unchecked
|
||||||
|
cy.get('input[name="isPublicDraftBoard"]').should('not.be.checked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should change draft speed', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
cy.get('select[name="draftSpeed"]').select('fast');
|
||||||
|
cy.get('button[type="submit"]').contains(/save/i).click();
|
||||||
|
|
||||||
|
// Verify selection persists
|
||||||
|
cy.get('select[name="draftSpeed"]').should('have.value', 'fast');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should update team count', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
cy.get('select[name="teamCount"]').select('10');
|
||||||
|
cy.get('button[type="submit"]').contains(/save/i).click();
|
||||||
|
|
||||||
|
// Verify selection persists
|
||||||
|
cy.get('select[name="teamCount"]').should('have.value', '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should delete league with confirmation', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
// Click delete button
|
||||||
|
cy.findByRole('button', { name: /delete league/i }).click();
|
||||||
|
|
||||||
|
// Confirm deletion in dialog
|
||||||
|
cy.findByRole('button', { name: /confirm/i }).click();
|
||||||
|
|
||||||
|
// Should redirect to home
|
||||||
|
cy.url().should('eq', Cypress.config().baseUrl + '/');
|
||||||
|
});
|
||||||
|
});
|
||||||
15
cypress/e2e/smoke.cy.ts
Normal file
15
cypress/e2e/smoke.cy.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
describe('Smoke Tests', () => {
|
||||||
|
it('should pass a basic test', () => {
|
||||||
|
expect(true).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should perform basic math', () => {
|
||||||
|
expect(2 + 2).to.equal(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays', () => {
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(arr).to.have.length(3);
|
||||||
|
expect(arr).to.include(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
3
cypress/support/commands.ts
Normal file
3
cypress/support/commands.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
// Additional custom commands can be added here
|
||||||
22
cypress/support/e2e.ts
Normal file
22
cypress/support/e2e.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import '@testing-library/cypress/add-commands';
|
||||||
|
|
||||||
|
// Custom commands
|
||||||
|
Cypress.Commands.add('login', (email: string, password: string) => {
|
||||||
|
cy.session([email, password], () => {
|
||||||
|
cy.visit('/sign-in');
|
||||||
|
cy.get('input[name="email"]').type(email);
|
||||||
|
cy.get('input[name="password"]').type(password);
|
||||||
|
cy.get('button[type="submit"]').click();
|
||||||
|
cy.url().should('not.include', '/sign-in');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
login(email: string, password: string): Chainable<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
3556
package-lock.json
generated
3556
package-lock.json
generated
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
|
@ -11,6 +11,13 @@
|
||||||
"dev": "dotenv -- tsx watch server.ts",
|
"dev": "dotenv -- tsx watch server.ts",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"start:production": "drizzle-kit migrate && node dist/server.js",
|
"start:production": "drizzle-kit migrate && node dist/server.js",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:e2e": "cypress open",
|
||||||
|
"test:e2e:headless": "cypress run",
|
||||||
|
"test:all": "npm run test:run && npm run test:e2e:headless",
|
||||||
"typecheck": "react-router typegen && tsc -b && tsc -p tsconfig.server.json --noEmit"
|
"typecheck": "react-router typegen && tsc -b && tsc -p tsconfig.server.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -52,6 +59,10 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.7.1",
|
"@react-router/dev": "^7.7.1",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
|
"@testing-library/cypress": "^10.1.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/express-serve-static-core": "^5.0.6",
|
"@types/express-serve-static-core": "^5.0.6",
|
||||||
|
|
@ -60,13 +71,19 @@
|
||||||
"@types/pg": "^8.11.14",
|
"@types/pg": "^8.11.14",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"@vitest/ui": "^3.2.4",
|
||||||
|
"cypress": "^14.5.4",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"esbuild": "^0.25.11",
|
"esbuild": "^0.25.11",
|
||||||
|
"jsdom": "^27.0.1",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.3.3",
|
"vite": "^6.3.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
751
plans/testing-implementation.md
Normal file
751
plans/testing-implementation.md
Normal file
|
|
@ -0,0 +1,751 @@
|
||||||
|
# 🧪 Comprehensive Testing Plan for Brackt.com
|
||||||
|
|
||||||
|
**Created:** October 20, 2025
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
## **Overview**
|
||||||
|
|
||||||
|
This plan sets up a complete testing infrastructure with:
|
||||||
|
- **Vitest + React Testing Library** - Unit and component tests
|
||||||
|
- **Cypress** - End-to-end integration tests
|
||||||
|
- **Test fixtures** - Reusable test data
|
||||||
|
- **CI/CD Integration** - Automated testing in GitHub Actions
|
||||||
|
- **CI-ready** - Scripts for automated testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Phase 1: Vitest + React Testing Library Setup**
|
||||||
|
|
||||||
|
### **1.1 Installation**
|
||||||
|
```bash
|
||||||
|
npm install -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
|
||||||
|
```
|
||||||
|
|
||||||
|
### **1.2 Configuration Files**
|
||||||
|
|
||||||
|
**`vitest.config.ts`**
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./app/test/setup.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: ['node_modules/', 'app/test/'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': path.resolve(__dirname, './app'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**`app/test/setup.ts`**
|
||||||
|
```typescript
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### **1.3 Example Unit Tests**
|
||||||
|
|
||||||
|
**`app/models/__tests__/league.test.ts`**
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { createLeague, updateLeague, findLeagueById } from '../league';
|
||||||
|
|
||||||
|
// Mock database
|
||||||
|
vi.mock('~/database/context', () => ({
|
||||||
|
database: vi.fn(() => ({
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnValue({
|
||||||
|
returning: vi.fn().mockResolvedValue([{
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'Test League',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
isPublicDraftBoard: false,
|
||||||
|
}]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
query: {
|
||||||
|
leagues: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('League Model', () => {
|
||||||
|
it('should create a new league', async () => {
|
||||||
|
const league = await createLeague({
|
||||||
|
name: 'Test League',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(league).toMatchObject({
|
||||||
|
name: 'Test League',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate league name length', async () => {
|
||||||
|
// Test validation logic
|
||||||
|
expect(() => {
|
||||||
|
if ('ab'.length < 3) throw new Error('Name too short');
|
||||||
|
}).toThrow('Name too short');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### **1.4 Component Tests**
|
||||||
|
|
||||||
|
**`app/components/__tests__/DraftGrid.test.tsx`**
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { DraftGrid } from '../DraftGrid';
|
||||||
|
|
||||||
|
describe('DraftGrid Component', () => {
|
||||||
|
const mockSlots = [
|
||||||
|
{ id: '1', draftOrder: 1, team: { id: 't1', name: 'Team 1' } },
|
||||||
|
{ id: '2', draftOrder: 2, team: { id: 't2', name: 'Team 2' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockGrid = [
|
||||||
|
[
|
||||||
|
{ participant: { name: 'Player 1' }, sport: { name: 'NFL' } },
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should render team names', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Team 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should highlight current pick', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('On Clock')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display picked players', () => {
|
||||||
|
render(
|
||||||
|
<DraftGrid
|
||||||
|
draftSlots={mockSlots}
|
||||||
|
draftGrid={mockGrid}
|
||||||
|
currentPick={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Player 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('NFL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Phase 2: Cypress E2E Testing Setup**
|
||||||
|
|
||||||
|
### **2.1 Installation**
|
||||||
|
```bash
|
||||||
|
npm install -D cypress @testing-library/cypress
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2.2 Configuration**
|
||||||
|
|
||||||
|
**`cypress.config.ts`**
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'cypress';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: {
|
||||||
|
baseUrl: 'http://localhost:3000',
|
||||||
|
setupNodeEvents(on, config) {
|
||||||
|
// implement node event listeners here
|
||||||
|
},
|
||||||
|
supportFile: 'cypress/support/e2e.ts',
|
||||||
|
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
TEST_USER_EMAIL: 'test@example.com',
|
||||||
|
TEST_USER_PASSWORD: 'testpassword123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cypress/support/e2e.ts`**
|
||||||
|
```typescript
|
||||||
|
import '@testing-library/cypress/add-commands';
|
||||||
|
|
||||||
|
// Custom commands
|
||||||
|
Cypress.Commands.add('login', (email: string, password: string) => {
|
||||||
|
cy.session([email, password], () => {
|
||||||
|
cy.visit('/sign-in');
|
||||||
|
cy.get('input[name="email"]').type(email);
|
||||||
|
cy.get('input[name="password"]').type(password);
|
||||||
|
cy.get('button[type="submit"]').click();
|
||||||
|
cy.url().should('not.include', '/sign-in');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
login(email: string, password: string): Chainable<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2.3 E2E Test Examples**
|
||||||
|
|
||||||
|
**`cypress/e2e/league-creation.cy.ts`**
|
||||||
|
```typescript
|
||||||
|
describe('League Creation Flow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login(Cypress.env('TEST_USER_EMAIL'), Cypress.env('TEST_USER_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new league with all settings', () => {
|
||||||
|
cy.visit('/leagues/new');
|
||||||
|
|
||||||
|
// Fill in league details
|
||||||
|
cy.get('input[name="name"]').type('My Test League');
|
||||||
|
cy.get('select[name="teamCount"]').select('12');
|
||||||
|
cy.get('input[name="draftRounds"]').clear().type('20');
|
||||||
|
|
||||||
|
// Set draft speed
|
||||||
|
cy.get('select[name="draftSpeed"]').select('standard');
|
||||||
|
|
||||||
|
// Set draft date/time
|
||||||
|
cy.get('input[name="draftDate"]').type('2025-12-01');
|
||||||
|
cy.get('input[name="draftTime"]').type('19:00');
|
||||||
|
|
||||||
|
// Select sports (assuming checkboxes)
|
||||||
|
cy.findByLabelText(/NFL/i).check();
|
||||||
|
cy.findByLabelText(/NBA/i).check();
|
||||||
|
cy.findByLabelText(/MLB/i).check();
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
cy.get('button[type="submit"]').click();
|
||||||
|
|
||||||
|
// Verify redirect to league page
|
||||||
|
cy.url().should('include', '/leagues/');
|
||||||
|
cy.findByText('My Test League').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate required fields', () => {
|
||||||
|
cy.visit('/leagues/new');
|
||||||
|
|
||||||
|
// Try to submit without filling anything
|
||||||
|
cy.get('button[type="submit"]').click();
|
||||||
|
|
||||||
|
// Should show validation errors
|
||||||
|
cy.findByText(/league name is required/i).should('exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cypress/e2e/league-settings.cy.ts`**
|
||||||
|
```typescript
|
||||||
|
describe('League Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login(Cypress.env('TEST_USER_EMAIL'), Cypress.env('TEST_USER_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update league name', () => {
|
||||||
|
// Assuming we have a test league
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
cy.get('input[name="name"]').clear().type('Updated League Name');
|
||||||
|
cy.get('button[type="submit"]').contains('Save').click();
|
||||||
|
|
||||||
|
// Verify success
|
||||||
|
cy.findByText(/settings updated/i).should('exist');
|
||||||
|
cy.findByDisplayValue('Updated League Name').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle public draft board', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
// Check the public draft board checkbox
|
||||||
|
cy.get('input[name="isPublicDraftBoard"]').check();
|
||||||
|
cy.get('button[type="submit"]').contains('Save').click();
|
||||||
|
|
||||||
|
// Verify it's checked after save
|
||||||
|
cy.get('input[name="isPublicDraftBoard"]').should('be.checked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change draft speed', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/settings');
|
||||||
|
|
||||||
|
cy.get('select[name="draftSpeed"]').select('fast');
|
||||||
|
cy.get('button[type="submit"]').contains('Save').click();
|
||||||
|
|
||||||
|
// Verify selection persists
|
||||||
|
cy.get('select[name="draftSpeed"]').should('have.value', 'fast');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cypress/e2e/draft-room.cy.ts`**
|
||||||
|
```typescript
|
||||||
|
describe('Draft Room', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login(Cypress.env('TEST_USER_EMAIL'), Cypress.env('TEST_USER_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display draft grid with all teams', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Verify team headers are visible
|
||||||
|
cy.findByText('Team 1').should('exist');
|
||||||
|
cy.findByText('Team 2').should('exist');
|
||||||
|
|
||||||
|
// Verify draft grid is rendered
|
||||||
|
cy.get('[title*="Overall Pick"]').should('have.length.at.least', 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show current pick highlighted', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Current pick should have special styling
|
||||||
|
cy.findByText('On Clock').should('exist');
|
||||||
|
cy.findByText('On Clock')
|
||||||
|
.parent()
|
||||||
|
.should('have.class', 'border-blue-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow commissioner to start draft', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Click start draft button
|
||||||
|
cy.findByRole('button', { name: /start draft/i }).click();
|
||||||
|
|
||||||
|
// Verify draft started
|
||||||
|
cy.findByText(/draft started/i).should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make a pick', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Search for a player
|
||||||
|
cy.get('input[placeholder*="Search"]').type('Aaron Judge');
|
||||||
|
|
||||||
|
// Click on player to select
|
||||||
|
cy.findByText('Aaron Judge').click();
|
||||||
|
|
||||||
|
// Confirm pick
|
||||||
|
cy.findByRole('button', { name: /confirm pick/i }).click();
|
||||||
|
|
||||||
|
// Verify pick appears in grid
|
||||||
|
cy.findByText('Aaron Judge').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add player to queue', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Find a player and add to queue
|
||||||
|
cy.findByText('Mike Trout').parent().findByRole('button', { name: /add to queue/i }).click();
|
||||||
|
|
||||||
|
// Verify in queue section
|
||||||
|
cy.findByText(/your queue/i).parent().findByText('Mike Trout').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display timers for all teams', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Start draft first
|
||||||
|
cy.findByRole('button', { name: /start draft/i }).click();
|
||||||
|
|
||||||
|
// Verify timers are displayed (format: MM:SS)
|
||||||
|
cy.get('[class*="font-mono"]').should('contain.text', ':');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow commissioner to pause/resume draft', () => {
|
||||||
|
cy.visit('/leagues/test-league-id/draft/test-season-id');
|
||||||
|
|
||||||
|
// Pause draft
|
||||||
|
cy.findByRole('button', { name: /pause draft/i }).click();
|
||||||
|
cy.findByText(/draft paused/i).should('exist');
|
||||||
|
|
||||||
|
// Resume draft
|
||||||
|
cy.findByRole('button', { name: /resume draft/i }).click();
|
||||||
|
cy.findByText(/draft resumed/i).should('exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Phase 3: Test Fixtures & Helpers**
|
||||||
|
|
||||||
|
**`app/test/fixtures/league.ts`**
|
||||||
|
```typescript
|
||||||
|
export const mockLeague = {
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'Test League',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
isPublicDraftBoard: false,
|
||||||
|
createdAt: new Date('2025-01-01'),
|
||||||
|
updatedAt: new Date('2025-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockSeason = {
|
||||||
|
id: 'season-1',
|
||||||
|
leagueId: 'league-1',
|
||||||
|
year: 2025,
|
||||||
|
status: 'pre_draft' as const,
|
||||||
|
draftRounds: 20,
|
||||||
|
draftInitialTime: 120,
|
||||||
|
draftIncrementTime: 15,
|
||||||
|
currentPickNumber: 1,
|
||||||
|
draftPaused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockTeams = [
|
||||||
|
{ id: 'team-1', name: 'Team 1', seasonId: 'season-1', ownerId: 'user-1' },
|
||||||
|
{ id: 'team-2', name: 'Team 2', seasonId: 'season-1', ownerId: 'user-2' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cypress/support/commands/database.ts`**
|
||||||
|
```typescript
|
||||||
|
// Helper to seed test database
|
||||||
|
Cypress.Commands.add('seedDatabase', () => {
|
||||||
|
cy.task('db:seed');
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('cleanDatabase', () => {
|
||||||
|
cy.task('db:clean');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Phase 4: Package.json Scripts**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:e2e": "cypress open",
|
||||||
|
"test:e2e:headless": "cypress run",
|
||||||
|
"test:all": "npm run test && npm run test:e2e:headless"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Phase 5: GitHub Actions CI/CD Integration**
|
||||||
|
|
||||||
|
### **5.1 Update Deploy Workflow**
|
||||||
|
|
||||||
|
**`.github/workflows/deploy.yml`** (Updated)
|
||||||
|
```yaml
|
||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Run tests first
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: brackt_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
run: npm run db:migrate
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test -- --run
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: npm run test:e2e:headless
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
||||||
|
CYPRESS_BASE_URL: http://localhost:3000
|
||||||
|
|
||||||
|
- name: Upload test coverage
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
files: ./coverage/coverage-final.json
|
||||||
|
flags: unittests
|
||||||
|
|
||||||
|
- name: Upload Cypress videos
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: cypress-videos
|
||||||
|
path: cypress/videos
|
||||||
|
|
||||||
|
# Build and deploy only if tests pass
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Netlify
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
|
||||||
|
- name: Deploy to Netlify
|
||||||
|
uses: nwtgck/actions-netlify@v2
|
||||||
|
with:
|
||||||
|
publish-dir: './build/client'
|
||||||
|
production-branch: main
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
|
env:
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **5.2 Add PR Check Workflow**
|
||||||
|
|
||||||
|
**`.github/workflows/pr-checks.yml`** (New)
|
||||||
|
```yaml
|
||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
name: Lint and Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: brackt_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: npm run lint
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run type check
|
||||||
|
run: npm run typecheck
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test -- --run
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@localhost:5432/brackt_test
|
||||||
|
|
||||||
|
- name: Comment test results
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const coverage = JSON.parse(fs.readFileSync('./coverage/coverage-summary.json', 'utf8'));
|
||||||
|
const total = coverage.total;
|
||||||
|
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: `## Test Results\n\n` +
|
||||||
|
`✅ Tests passed!\n\n` +
|
||||||
|
`**Coverage:**\n` +
|
||||||
|
`- Lines: ${total.lines.pct}%\n` +
|
||||||
|
`- Statements: ${total.statements.pct}%\n` +
|
||||||
|
`- Functions: ${total.functions.pct}%\n` +
|
||||||
|
`- Branches: ${total.branches.pct}%`
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### **5.3 Required Status Checks**
|
||||||
|
|
||||||
|
In GitHub repository settings, configure branch protection rules:
|
||||||
|
|
||||||
|
1. Go to **Settings** → **Branches** → **Branch protection rules**
|
||||||
|
2. Add rule for `main` branch
|
||||||
|
3. Enable:
|
||||||
|
- ✅ Require status checks to pass before merging
|
||||||
|
- ✅ Require branches to be up to date before merging
|
||||||
|
4. Select required checks:
|
||||||
|
- `test` (from deploy.yml)
|
||||||
|
- `lint-and-test` (from pr-checks.yml)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Implementation Checklist**
|
||||||
|
|
||||||
|
### **Phase 1: Vitest Setup**
|
||||||
|
- [ ] Install Vitest and React Testing Library dependencies
|
||||||
|
- [ ] Create `vitest.config.ts`
|
||||||
|
- [ ] Create `app/test/setup.ts`
|
||||||
|
- [ ] Create example unit tests for league model
|
||||||
|
- [ ] Create example component tests for DraftGrid
|
||||||
|
- [ ] Add test scripts to package.json
|
||||||
|
|
||||||
|
### **Phase 2: Cypress Setup**
|
||||||
|
- [ ] Install Cypress dependencies
|
||||||
|
- [ ] Create `cypress.config.ts`
|
||||||
|
- [ ] Create `cypress/support/e2e.ts` with custom commands
|
||||||
|
- [ ] Create league creation E2E tests
|
||||||
|
- [ ] Create league settings E2E tests
|
||||||
|
- [ ] Create draft room E2E tests
|
||||||
|
|
||||||
|
### **Phase 3: Test Fixtures**
|
||||||
|
- [ ] Create `app/test/fixtures/league.ts`
|
||||||
|
- [ ] Create `app/test/fixtures/season.ts`
|
||||||
|
- [ ] Create `app/test/fixtures/team.ts`
|
||||||
|
- [ ] Create Cypress database helpers
|
||||||
|
|
||||||
|
### **Phase 4: CI/CD Integration**
|
||||||
|
- [ ] Update `.github/workflows/deploy.yml` with test jobs
|
||||||
|
- [ ] Create `.github/workflows/pr-checks.yml`
|
||||||
|
- [ ] Configure GitHub branch protection rules
|
||||||
|
- [ ] Add required status checks
|
||||||
|
- [ ] Test CI pipeline with a PR
|
||||||
|
|
||||||
|
### **Phase 5: Documentation**
|
||||||
|
- [ ] Document how to run tests locally
|
||||||
|
- [ ] Document how to write new tests
|
||||||
|
- [ ] Document CI/CD pipeline
|
||||||
|
- [ ] Add testing best practices guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Test Coverage Goals**
|
||||||
|
|
||||||
|
- **Unit Tests:** 80%+ coverage for models and utilities
|
||||||
|
- **Component Tests:** All major UI components tested
|
||||||
|
- **E2E Tests:** Critical user flows covered
|
||||||
|
- League creation and editing
|
||||||
|
- Draft room functionality
|
||||||
|
- Commissioner controls
|
||||||
|
- Public draft board access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Success Criteria**
|
||||||
|
|
||||||
|
✅ All tests pass locally
|
||||||
|
✅ CI/CD pipeline runs tests automatically
|
||||||
|
✅ PRs cannot merge without passing tests
|
||||||
|
✅ Coverage reports generated and tracked
|
||||||
|
✅ E2E tests run in headless mode
|
||||||
|
✅ Test failures block deployments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **Future Enhancements**
|
||||||
|
|
||||||
|
- **Visual Regression Testing** - Percy or Chromatic
|
||||||
|
- **Performance Testing** - Lighthouse CI
|
||||||
|
- **Load Testing** - k6 for draft room Socket.IO
|
||||||
|
- **Accessibility Testing** - axe-core integration
|
||||||
|
- **Mutation Testing** - Stryker for test quality
|
||||||
32
vitest.config.ts
Normal file
32
vitest.config.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./app/test/setup.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'app/test/',
|
||||||
|
'**/*.test.{ts,tsx}',
|
||||||
|
'**/*.spec.{ts,tsx}',
|
||||||
|
'**/types.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': path.resolve(__dirname, './app'),
|
||||||
|
'app': path.resolve(__dirname, './app'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue