From 808c882f947a5032330470941fd460998908978d Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 3 May 2026 14:15:20 -0400 Subject: [PATCH 1/4] Implement US3: Live Score Tracking - Add score fields (player1Score, player2Score) to BracketMatch type - Add tournamentWinner field to BracketResponse - Implement PUT /api/tournaments/:id/matches/:matchId/score endpoint - Add submitMatchScore function to store with validation - Update getTournamentBracket to include tournament winner - Add comprehensive test suite with 14 test cases covering: - Score submission and validation - Bracket advancement and winner determination - Real-time updates - Score history preservation - Edge cases (byes, completed matches) - All 73 tests passing --- AUTOMATION-ARCHITECTURE-SUMMARY.md | 561 +++++++++++++++++++++++++++++ src/bracket/singleElimination.ts | 4 + src/routes/tournaments.ts | 61 ++++ src/score-tracking.test.ts | 487 +++++++++++++++++++++++++ src/store.ts | 81 ++++- src/types.ts | 5 + 6 files changed, 1198 insertions(+), 1 deletion(-) create mode 100644 AUTOMATION-ARCHITECTURE-SUMMARY.md create mode 100644 src/score-tracking.test.ts diff --git a/AUTOMATION-ARCHITECTURE-SUMMARY.md b/AUTOMATION-ARCHITECTURE-SUMMARY.md new file mode 100644 index 0000000..575ceba --- /dev/null +++ b/AUTOMATION-ARCHITECTURE-SUMMARY.md @@ -0,0 +1,561 @@ +# MatchPoint Automation Architecture Summary + +## Overview + +This document describes the comprehensive automation infrastructure for the MatchPoint tournament management system, with extensive automation implemented for User Stories 11-13 (U11-U13). The system includes automated testing, AI-powered code review, and automated documentation generation. + +--- + +## 1. Automation Pipeline Architecture + +### Visual Pipeline Flow + +``` +Developer Workflow (U11-U13) +═══════════════════════════════════════════════════════════════════════════════ + +┌─────────────────┐ +│ Developer │ +│ Creates Branch │ +│ (e.g., u13-*) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Implements │ +│ Feature + Tests │ +└────────┬────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ STAGE 1: PULL REQUEST OPENED │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ├──────────────────────────────────────────────────────────────┐ + │ │ + ▼ ▼ +┌────────────────────┐ ┌────────────────────┐ +│ GitHub Action: │ │ GitHub Action: │ +│ CI Tests │ │ LLM Code Review │ +│ (test.yml) │ │ (llm-code-review) │ +└────────┬───────────┘ └────────┬───────────┘ + │ │ + │ • Checkout code │ • Checkout code + │ • Setup Node.js 20 │ • Get PR diff + │ • Install deps (root + frontend) │ • Load prompt + │ • Run backend tests (npm test) │ • Call OpenAI + │ - src/history.test.ts (U13) │ GPT-4o-mini + │ - src/checkin.test.ts (U11) │ • Generate review + │ - src/notifications.test.ts (U12) │ + │ • Run frontend tests │ + │ (npm test --prefix frontend) │ + │ │ + ▼ ▼ +┌────────────────────┐ ┌────────────────────┐ +│ ✅ Tests Pass │ │ 🤖 Bot Posts │ +│ or │ │ Review Comment │ +│ ❌ Tests Fail │ │ to PR │ +│ │ │ │ +│ • Status check on │ │ Includes: │ +│ PR │ │ • Strengths ✅ │ +│ • Blocks merge if │ │ • Concerns ⚠️ │ +│ fails │ │ • Questions 🔍 │ +└────────┬───────────┘ │ │ + │ │ Auto-updates on │ + │ │ new commits │ + │ └──────────┬─────────┘ + │ │ + └───────────────────────┬───────────────────────────────────┘ + │ + ▼ + ┌────────────────────────┐ + │ Developer Reviews │ + │ Feedback: │ + │ • Fixes test failures │ + │ • Addresses AI concerns│ + │ • Pushes new commits │ + └────────────┬───────────┘ + │ + │ (Loop: Tests & Review re-run on each push) + │ + ▼ + ┌────────────────────────┐ + │ Request Human Review │ + └────────────┬───────────┘ + │ + ▼ + ┌────────────────────────┐ + │ Human Reviewer │ + │ • Reads AI review │ + │ • Verifies logic │ + │ • Approves PR │ + └────────────┬───────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ STAGE 2: PULL REQUEST MERGED TO MAIN │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────┐ + │ GitHub Action: │ + │ Generate Dev Spec │ + │ (generate-dev-spec.yml) │ + └────────────┬───────────────┘ + │ + │ • Extract US# from PR title/label/branch + │ • Check if dev-spec.md exists + │ • Get PR diff + changed files + │ • Extract user story text + │ • Load appropriate prompt template: + │ - DEV-SPEC-GENERATION-PROMPT.md (new) + │ - DEV-SPEC-UPDATE-PROMPT.md (update) + │ • Call OpenAI GPT-4o with context + │ • Generate/update dev spec + │ + ▼ + ┌────────────────────────────┐ + │ Commit to Main: │ + │ • u{N}/dev-spec.md created │ + │ or updated │ + │ • Commit message: │ + │ "Generate dev spec for │ + │ US{N} from PR #{X}" │ + │ • Create/update GitHub │ + │ issue tracking doc work │ + └────────────┬───────────────┘ + │ + ▼ + ┌────────────────────────────┐ + │ ✅ Automation Complete │ + │ │ + │ Artifacts: │ + │ • Code in main branch │ + │ • Tests passing │ + │ • AI review in PR history │ + │ • Dev spec documented │ + └────────────────────────────┘ +``` + +### Bullet-Level Pipeline Summary + +#### **Trigger 1: Developer Opens/Updates PR** +1. **CI Tests Workflow** (`test.yml`) + - Trigger: Push to main OR Pull request to main + - Runs: Backend tests (vitest) + Frontend tests (vitest) + - Output: ✅/❌ Status check on PR (blocks merge if fails) + +2. **LLM Code Review Workflow** (`llm-code-review.yml`) + - Trigger: PR opened, synchronized, or reopened + - Steps: + a. Extract PR diff (limited to 10KB) + b. Send to OpenAI GPT-4o-mini with structured prompt + c. Generate review (Quality, Testing, Security, Maintainability, Docs) + d. Post as bot comment "🤖 AI Code Review Summary" + e. Auto-update comment on new commits + - Output: Advisory review comment (doesn't block merge) + +3. **Developer Response** + - Reads AI feedback + - Fixes test failures + - Addresses concerns + - Pushes fixes (triggers re-run of tests + review update) + - Requests human review when ready + +4. **Human Review** + - Reviews AI feedback first + - Verifies business logic and design + - Approves or requests changes + - Makes final merge decision + +#### **Trigger 2: PR Merged to Main** +5. **Dev Spec Generation Workflow** (`generate-dev-spec.yml`) + - Trigger: PR closed AND merged to main + - Steps: + a. Extract US# from PR title/labels/branch (e.g., "US13", "u13-*") + b. Detect mode: new spec vs. update (check if `u{N}/dev-spec.md` exists) + c. Gather context: + - PR diff (up to 50KB) + - Changed files list + - User story text from `user-stories.md` + - Existing dev spec (if updating) + d. Load prompt template: + - `.github/DEV-SPEC-GENERATION-PROMPT.md` (new) + - `.github/DEV-SPEC-UPDATE-PROMPT.md` (update) + e. Call OpenAI GPT-4o with full context + f. Generate complete dev spec (Markdown) + g. Save to `u{N}/dev-spec.md` + h. Create/update GitHub issue + i. Commit to main with message: "Generate dev spec for US{N} from PR #{X}" + j. Upload artifact for review + - Output: Committed dev spec in `u{N}/dev-spec.md` + +--- + +## 2. Automation Components Breakdown + +### A. GitHub Actions Workflows + +| Workflow | File | Trigger | Purpose | Cost | +|----------|------|---------|---------|------| +| **CI Tests** | `.github/workflows/test.yml` | Push/PR to main | Run automated tests | Free (GitHub) | +| **LLM Code Review** | `.github/workflows/llm-code-review.yml` | PR open/update/reopen | AI-powered code review | ~$0.001/review | +| **Dev Spec Generation** | `.github/workflows/generate-dev-spec.yml` | PR merged to main | Auto-generate documentation | ~$0.05-0.15/spec | + +### B. Test Coverage (U11-U13) + +| User Story | Test File | Test Count | What's Tested | +|------------|-----------|------------|---------------| +| **US11** (Check-in) | `src/checkin.test.ts` | 6+ | Check-in endpoints, organizer auth, check-in state, bracket filtering | +| **US12** (Notifications) | `src/notifications.test.ts` | 8+ | Match-call notifications, enqueue on ready, auth, ack, idempotency | +| **US13** (Match History) | `src/history.test.ts` | 8+ | History endpoint, pagination, filtering, stats aggregation, error cases | + +All tests run automatically on every push/PR via the CI Tests workflow. + +### C. LLM Prompts & Templates + +| File | Purpose | Used By | +|------|---------|---------| +| `.github/LLM-REVIEW-PROMPT.md` | Code review prompt template | `llm-code-review.yml` | +| `.github/DEV-SPEC-GENERATION-PROMPT.md` | Prompt for creating new dev specs | `generate-dev-spec.yml` | +| `.github/DEV-SPEC-UPDATE-PROMPT.md` | Prompt for updating existing dev specs | `generate-dev-spec.yml` | + +### D. Documentation & Guides + +| File | Purpose | +|------|---------| +| `.github/QUICK-SETUP.md` | 5-minute setup guide for new developers | +| `.github/LLM-REVIEW-GUIDE.md` | Comprehensive guide to AI review system | +| `.github/DEV-SPEC-AUTOMATION-GUIDE.md` | Guide to dev spec automation | +| `.github/README.md` | Overview of all workflows and automation | +| `u11/HumanTests.md` | Manual test checklist for U11 | +| `u12/HumanTests.md` | Manual test checklist for U12 | +| `u13/CI-SETUP.md` | CI/testing setup for U13 | +| `u13/LLM-REVIEW-INTEGRATION.md` | How LLM review was integrated for U13 | +| `u13/AUTOMATION-SUMMARY.md` | U13-specific automation summary | + +--- + +## 3. Setup Instructions for New Developers + +### Prerequisites +- GitHub account with write access to the repository +- OpenAI API account (for LLM-powered features) +- Git, Node.js 20+, npm + +### Step-by-Step Setup + +#### 1️⃣ **Clone Repository and Install Dependencies** +```bash +# Clone the repo +git clone https://github.com/afeies/match-point.git +cd match-point + +# Install backend dependencies +npm ci + +# Install frontend dependencies +npm ci --prefix frontend +``` + +#### 2️⃣ **Run Tests Locally (Optional but Recommended)** +```bash +# Run backend tests +npm test + +# Run frontend tests +npm test --prefix frontend + +# Run specific test suites +npm test src/history.test.ts # U13 tests +npm test src/checkin.test.ts # U11 tests +npm test src/notifications.test.ts # U12 tests +``` + +This verifies your local environment matches CI. + +#### 3️⃣ **Configure GitHub Secrets (Repository Admin Only)** + +**Required Secret: OPENAI_API_KEY** + +If you're a repository admin setting up the automation: + +1. **Get OpenAI API Key**: + - Go to https://platform.openai.com/api-keys + - Sign in (create account if needed) + - Click "Create new secret key" + - Name it: `github-matchpoint-reviews` + - Copy the key (starts with `sk-proj-...` or `sk-...`) + - Add credits: https://platform.openai.com/settings/organization/billing + - Minimum: $5 (lasts for thousands of reviews) + - Expected cost: ~$0.001 per review, ~$0.10 per dev spec + +2. **Add Secret to GitHub**: + - Go to repository: https://github.com/afeies/match-point + - Click **Settings** (requires admin access) + - Sidebar → **Secrets and variables** → **Actions** + - Click **New repository secret** + - Name: `OPENAI_API_KEY` + - Secret: Paste the OpenAI key + - Click **Add secret** + +3. **Verify Workflow Permissions**: + - Go to **Settings** → **Actions** → **General** + - Under "Workflow permissions", ensure: + - ✅ "Read and write permissions" is selected + - ✅ "Allow GitHub Actions to create and approve pull requests" is checked + - Click **Save** + +#### 4️⃣ **Test the Automation (First-Time Verification)** + +**Test CI Tests Workflow:** +```bash +# Create a test branch +git checkout -b test-ci-workflows +echo "# Testing CI" >> README.md +git add README.md +git commit -m "Test: Verify CI workflows" +git push -u origin test-ci-workflows + +# Open PR on GitHub +# Expected: CI Tests workflow runs and passes ✅ +``` + +**Test LLM Code Review:** +1. After opening the PR above, wait ~60 seconds +2. Check PR comments for: "🤖 AI Code Review Summary" +3. If bot comment appears → Success! ✅ +4. If not → Check Actions tab for errors + +**Test Dev Spec Generation:** +1. Create a branch for a user story (e.g., `u14-test-feature`) +2. Make a small change, commit, push, open PR with title: "US14: Test feature" +3. Get PR approved and merge +4. Check if `u14/dev-spec.md` was created after merge +5. Clean up: delete the test dev spec if not needed + +**Troubleshooting:** +- **No bot comment?** → Check Actions tab → LLM Code Review → View logs + - Error "OPENAI_API_KEY secret not configured" → Repeat Step 3 + - Other errors → Check OpenAI account has credits +- **Tests failing locally?** → Check Node.js version (needs 20+) +- **Dev spec not generated?** → Check PR title includes "US#" or branch name includes "u#" + +#### 5️⃣ **Understand the Developer Workflow** + +When working on a user story (e.g., US15): + +```bash +# 1. Create feature branch +git checkout main +git pull origin main +git checkout -b u15-my-feature + +# 2. Implement feature +# - Write code +# - Write tests in src/*.test.ts or frontend/tests/*.test.tsx +# - Test locally: npm test + +# 3. Commit and push +git add . +git commit -m "Implement US15: My feature description" +git push -u origin u15-my-feature + +# 4. Open PR on GitHub +# - Title: "US15: My feature" (include US# for auto-detection) +# - Description: Describe changes, link to acceptance criteria +# - Wait ~60 seconds for AI review comment + +# 5. Address feedback +# - Read AI review bot comment +# - Fix any test failures (shown in PR checks) +# - Address concerns in AI review +# - Push fixes (triggers re-run of tests + review update) + +# 6. Request human review +# - Tag a reviewer or wait for assignment +# - Respond to human feedback + +# 7. Merge +# - After approval, click "Squash and merge" or "Merge" +# - Dev spec will be auto-generated within ~2 minutes +# - Check u15/dev-spec.md was committed to main +``` + +#### 6️⃣ **Daily Usage Tips** + +**For PR Authors:** +- ✅ Include US# in PR title or branch name for dev spec auto-detection +- ✅ Write tests before opening PR (CI will run them automatically) +- ✅ Read AI review comments (usually posted within 60 seconds) +- ✅ Address "⚠️ Concerns & Suggestions" before requesting human review +- ✅ Use human reviewer time for design/logic, not nitpicks + +**For PR Reviewers:** +- ✅ Read AI review first to get quick orientation +- ✅ Verify AI-flagged issues are legitimate +- ✅ Focus on: business logic, architecture, acceptance criteria coverage +- ✅ Check "🔍 Questions for Human Reviewers" section +- ✅ Don't rubber-stamp — AI is advisory only + +**For Debugging CI Failures:** +- Check **Actions** tab → Click failed workflow → View logs +- Common issues: + - Test failures: Run `npm test` locally to debug + - Linting errors: Check error output in logs + - API key issues: Verify secret is configured correctly + +#### 7️⃣ **Cost Management** + +**Current Usage (Based on Semester Load):** +| Scenario | Reviews/Month | Dev Specs/Month | Total Cost/Month | +|----------|---------------|-----------------|------------------| +| Low (5 PRs) | 10 | 5 | ~$0.30 | +| Medium (20 PRs) | 40 | 10 | ~$1.00 | +| High (50 PRs) | 100 | 20 | ~$3.00 | + +**To monitor costs:** +- OpenAI Dashboard: https://platform.openai.com/usage +- Set up billing alerts in OpenAI settings + +**To reduce costs:** +- Option 1: Use GPT-4o-mini for both (current setup for reviews) +- Option 2: Disable dev spec generation (remove workflow file) +- Option 3: Only run on specific branches/labels + +#### 8️⃣ **Customization Options** + +**Adjust AI Review Prompt:** +- Edit `.github/workflows/llm-code-review.yml` +- Find the "Load review prompt template" step +- Modify the prompt text to emphasize different aspects + +**Change LLM Model:** +- In `llm-code-review.yml` or `generate-dev-spec.yml` +- Change `"model": "gpt-4o-mini"` to `"model": "gpt-4o"` (better quality, higher cost) + +**Disable Specific Workflows:** +- Rename workflow file to `.yml.disabled` +- Or add to top of workflow: + ```yaml + on: + workflow_dispatch: # Only run manually + ``` + +--- + +## 4. Key Automation Artifacts + +### Generated by Automation + +| Artifact | Location | Generated By | When | +|----------|----------|--------------|------| +| Dev Specs | `u{N}/dev-spec.md` | `generate-dev-spec.yml` | PR merge | +| AI Review Comments | PR comments | `llm-code-review.yml` | PR open/update | +| Test Results | Actions logs | `test.yml` | Push/PR | +| GitHub Issues | Issues tab | `generate-dev-spec.yml` | PR merge (tracking) | + +### Human-Maintained + +| File | Purpose | Updated By | +|------|---------|------------| +| `user-stories.md` | User story definitions | Product owner | +| `src/*.test.ts` | Backend tests | Developers | +| `frontend/tests/*.test.tsx` | Frontend tests | Developers | +| `u{N}/HumanTests.md` | Manual test checklists | Developers | +| `.github/*-PROMPT.md` | LLM prompt templates | Automation maintainer | + +--- + +## 5. Repeatability & Auditability + +All automation in this project is **fully repeatable** and **auditable**: + +### Repeatability +✅ **All workflows are versioned in Git** +- `.github/workflows/*.yml` files define exact steps +- Prompts in `.github/*-PROMPT.md` are versioned +- Anyone can inspect, fork, or reproduce + +✅ **Deterministic inputs** +- Same PR diff → similar review (temperature 0.2-0.3) +- LLM outputs include model version and timestamp + +✅ **Local testing possible** +- All tests can run locally: `npm test` +- Workflows can be replicated manually (see workflow YAML) + +### Auditability +✅ **Full history in GitHub** +- Actions tab: All workflow runs with logs +- PR comments: AI review history preserved +- Commits: Dev spec generation commits tracked + +✅ **Human oversight required** +- AI reviews are advisory only (don't block merges) +- Human approval required for all PRs +- Dev specs can be manually edited post-generation + +✅ **Visible outputs** +- Test results: PR status checks +- AI reviews: Public PR comments +- Dev specs: Committed to repo (visible in diffs) + +--- + +## 6. Summary & Key Benefits + +### What's Automated +1. ✅ **Code Testing** — Every push/PR runs full test suite +2. ✅ **Code Review** — AI provides instant feedback on quality, security, testing +3. ✅ **Documentation** — Dev specs auto-generated on PR merge + +### What's Not Automated (Requires Human Judgment) +1. ❌ **Final approval** — Humans must approve PRs +2. ❌ **Business logic verification** — Humans verify acceptance criteria +3. ❌ **Architecture decisions** — Humans review design choices + +### Benefits +- **Speed**: Instant feedback (AI review in 60s, tests in 2-3 mins) +- **Consistency**: Same standards applied to all PRs +- **Quality**: Catches common issues before human review +- **Documentation**: Always up-to-date dev specs +- **Learning**: Developers learn from AI feedback +- **Efficiency**: Human reviewers focus on high-value tasks + +### Limitations +- **Cost**: Requires OpenAI API credits (~$1-3/month for typical usage) +- **Setup**: Initial configuration needed (API keys, permissions) +- **Accuracy**: AI can miss context-specific issues +- **Dependency**: Relies on third-party service (OpenAI) + +--- + +## 7. References + +**Workflow Files:** +- `.github/workflows/test.yml` — CI test automation +- `.github/workflows/llm-code-review.yml` — AI code review +- `.github/workflows/generate-dev-spec.yml` — Dev spec generation + +**Documentation:** +- `.github/QUICK-SETUP.md` — 5-minute setup guide +- `.github/LLM-REVIEW-GUIDE.md` — AI review comprehensive guide +- `.github/DEV-SPEC-AUTOMATION-GUIDE.md` — Dev spec automation guide +- `u13/AUTOMATION-SUMMARY.md` — U13-specific automation details + +**Prompt Templates:** +- `.github/LLM-REVIEW-PROMPT.md` — Code review prompt +- `.github/DEV-SPEC-GENERATION-PROMPT.md` — New dev spec prompt +- `.github/DEV-SPEC-UPDATE-PROMPT.md` — Update dev spec prompt + +**Test Files (U11-U13):** +- `src/checkin.test.ts` — U11 automated tests +- `src/notifications.test.ts` — U12 automated tests +- `src/history.test.ts` — U13 automated tests + +--- + +**Last Updated:** April 20, 2026 +**Automation Scope:** User Stories 11-13 (with CI/CD for entire project) +**LLM Models:** OpenAI GPT-4o (dev specs), GPT-4o-mini (code review) diff --git a/src/bracket/singleElimination.ts b/src/bracket/singleElimination.ts index 9b8537d..9808626 100644 --- a/src/bracket/singleElimination.ts +++ b/src/bracket/singleElimination.ts @@ -60,6 +60,8 @@ export function buildSingleEliminationBracket( status: "pending", winnerUserId: null, stationLabel: null, + player1Score: null, + player2Score: null, }); } } else { @@ -77,6 +79,8 @@ export function buildSingleEliminationBracket( player2: p2, advancesToMatchId: r < roundCount ? `r${r + 1}-m${Math.floor(m / 2) + 1}` : null, status: "pending", + player1Score: null, + player2Score: null, winnerUserId: null, stationLabel: null, }); diff --git a/src/routes/tournaments.ts b/src/routes/tournaments.ts index d96be8a..b381bb7 100644 --- a/src/routes/tournaments.ts +++ b/src/routes/tournaments.ts @@ -16,6 +16,7 @@ import { reportBracketMatchWinner, setEntrantCheckedIn, setTournamentBracket, + submitMatchScore, updateTournament, } from "../store.js"; import type { AuthedRequest } from "../middleware/auth.js"; @@ -322,6 +323,66 @@ const reportWinnerSchema = z.object({ winnerUserId: z.string().uuid(), }); +/* ------------------------------------------------------------------ */ +/* PUT /api/tournaments/:id/matches/:matchId/score – submit score */ +/* ------------------------------------------------------------------ */ +const submitScoreSchema = z.object({ + player1Score: z.number().int().min(0), + player2Score: z.number().int().min(0), +}); + +router.put( + "/:id/matches/:matchId/score", + requireAuth, + requireOrganizer, + (req: AuthedRequest, res) => { + const parsed = submitScoreSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + + const { player1Score, player2Score } = parsed.data; + + // Validate no ties + if (player1Score === player2Score) { + res.status(400).json({ error: "Scores cannot be tied - there must be a winner" }); + return; + } + + const tournament = getTournament(req.params.id); + if (!tournament) { + res.status(404).json({ error: "Tournament not found" }); + return; + } + + try { + const result = submitMatchScore( + req.params.id, + req.params.matchId, + player1Score, + player2Score + ); + + if (!result) { + res.status(404).json({ error: "Match not found" }); + return; + } + + res.status(200).json({ match: result.match, bracket: result.bracket }); + } catch (e) { + if (e instanceof Error) { + res.status(400).json({ error: e.message }); + return; + } + throw e; + } + } +); + +/* ------------------------------------------------------------------ */ +/* POST /api/tournaments/:id/matches/:matchId/winner */ +/* ------------------------------------------------------------------ */ router.post( "/:id/matches/:matchId/winner", requireAuth, diff --git a/src/score-tracking.test.ts b/src/score-tracking.test.ts new file mode 100644 index 0000000..6b3015b --- /dev/null +++ b/src/score-tracking.test.ts @@ -0,0 +1,487 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import { createApp } from "./app.js"; +import { + createUser, + createTournament, + addEntrant, + setTournamentBracket, + __resetStoreForTests, +} from "./store.js"; +import { buildSingleEliminationBracket } from "./bracket/singleElimination.js"; +import { signToken } from "./auth/token.js"; +import bcrypt from "bcryptjs"; + +describe("US3: Live Score Tracking", () => { + let app: ReturnType; + let organizerToken: string; + let organizerId: string; + let playerToken: string; + let playerId: string; + let tournamentId: string; + + beforeEach(() => { + __resetStoreForTests(); + app = createApp(); + + // Create organizer + const organizer = createUser({ + username: "organizer1", + email: "organizer@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Tournament Organizer", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "organizer", + }); + organizerId = organizer.id; + organizerToken = signToken({ sub: organizerId, role: "organizer" }); + + // Create player + const player = createUser({ + username: "player1", + email: "player@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Test Player", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + playerId = player.id; + playerToken = signToken({ sub: playerId, role: "player" }); + + // Create tournament + const tournament = createTournament({ + name: "Test Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: null, + registrationOpen: true, + }); + tournamentId = tournament.id; + }); + + describe("Score Submission", () => { + it("should update match score and record winner", async () => { + // Setup: Create 4 players and generate bracket + const players = Array.from({ length: 4 }, (_, i) => { + const user = createUser({ + username: `player${i}`, + email: `player${i}@test.com`, + passwordHash: bcrypt.hashSync("password123", 10), + displayName: `Player ${i}`, + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + addEntrant(tournamentId, { + userId: user.id, + displayName: user.displayName, + gameSelection: "Street Fighter 6", + registeredAt: new Date().toISOString(), + checkedIn: true, + }); + return { userId: user.id, displayName: user.displayName }; + }); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + // Get first match + const match = bracket.rounds[0].matches[0]; + + // Submit score + const response = await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2, + player2Score: 1, + }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("match"); + expect(response.body.match).toMatchObject({ + id: match.id, + player1Score: 2, + player2Score: 1, + winnerUserId: match.player1!.userId, + status: "complete", + }); + }); + + it("should return 403 when non-organizer attempts to submit score", async () => { + const players = Array.from({ length: 2 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + const match = bracket.rounds[0].matches[0]; + + const response = await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${playerToken}`) + .send({ + player1Score: 2, + player2Score: 1, + }); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid score formats", async () => { + const players = Array.from({ length: 2 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + const match = bracket.rounds[0].matches[0]; + + // Negative score + let response = await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: -1, + player2Score: 1, + }); + expect(response.status).toBe(400); + + // Non-integer score + response = await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2.5, + player2Score: 1, + }); + expect(response.status).toBe(400); + + // Missing scores + response = await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2, + }); + expect(response.status).toBe(400); + }); + + it("should return 400 for tied scores", async () => { + const players = Array.from({ length: 2 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + const match = bracket.rounds[0].matches[0]; + + const response = await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2, + player2Score: 2, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain("tie"); + }); + + it("should return 404 for non-existent match", async () => { + const response = await request(app) + .put("/api/tournaments/${tournamentId}/matches/non-existent-match-id/score") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2, + player2Score: 1, + }); + + expect(response.status).toBe(404); + }); + }); + + describe("Bracket Advancement", () => { + it("should advance winner to next round within 3 seconds", async () => { + // Create 4 players for a 2-round bracket + const players = Array.from({ length: 4 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + const match = bracket.rounds[0].matches[0]; + const startTime = Date.now(); + + // Submit score for first match + await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2, + player2Score: 0, + }); + + const endTime = Date.now(); + const elapsedSeconds = (endTime - startTime) / 1000; + + // Get updated bracket + const bracketResponse = await request(app) + .get(`/api/tournaments/${tournamentId}/bracket`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(elapsedSeconds).toBeLessThan(3); + expect(bracketResponse.status).toBe(200); + + // Check that winner advanced to finals + const finals = bracketResponse.body.rounds[1].matches[0]; + const winnerId = match.player1!.userId; + expect( + finals.player1?.userId === winnerId || finals.player2?.userId === winnerId + ).toBe(true); + }); + + it("should mark bracket as complete when final match is won", async () => { + const players = Array.from({ length: 2 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + const finalMatch = bracket.rounds[0].matches[0]; + + // Submit score for final match + await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${finalMatch.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 3, + player2Score: 1, + }); + + // Get updated bracket + const response = await request(app) + .get(`/api/tournaments/${tournamentId}/bracket`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + expect(response.body.rounds[0].matches[0].status).toBe("complete"); + expect(response.body.rounds[0].matches[0].winnerUserId).toBe( + finalMatch.player1!.userId + ); + }); + }); + + describe("Real-time Updates", () => { + it("should reflect score updates without full page reload", async () => { + const players = Array.from({ length: 4 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + // Get initial bracket state + const initialResponse = await request(app) + .get(`/api/tournaments/${tournamentId}/bracket`) + .set("Authorization", `Bearer ${organizerToken}`); + + const match = bracket.rounds[0].matches[0]; + + // Submit score + await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2, + player2Score: 1, + }); + + // Poll for updated bracket (simulating spectator polling) + const updatedResponse = await request(app) + .get(`/api/tournaments/${tournamentId}/bracket`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(updatedResponse.status).toBe(200); + expect(updatedResponse.body.rounds[0].matches[0].winnerUserId).toBe( + match.player1!.userId + ); + expect(updatedResponse.body.rounds[0].matches[0].player1Score).toBe(2); + expect(updatedResponse.body.rounds[0].matches[0].player2Score).toBe(1); + + // Verify it's different from initial state + expect(initialResponse.body.rounds[0].matches[0].winnerUserId).toBeNull(); + }); + + it("should allow public access to bracket without authentication", async () => { + const players = Array.from({ length: 2 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + // Access bracket without auth token (spectator view) + const response = await request(app).get( + `/api/tournaments/${tournamentId}/bracket` + ); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("rounds"); + }); + }); + + describe("Score History and Display", () => { + it("should preserve scores after match completion", async () => { + const players = Array.from({ length: 4 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + const match1 = bracket.rounds[0].matches[0]; + const match2 = bracket.rounds[0].matches[1]; + + // Submit scores for both semifinal matches + await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match1.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 3, + player2Score: 1, + }); + + await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match2.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2, + player2Score: 0, + }); + + // Get bracket and verify scores are preserved + const response = await request(app) + .get(`/api/tournaments/${tournamentId}/bracket`) + .set("Authorization", `Bearer ${organizerToken}`); + + const updatedMatches = response.body.rounds[0].matches; + expect(updatedMatches[0].player1Score).toBe(3); + expect(updatedMatches[0].player2Score).toBe(1); + expect(updatedMatches[1].player1Score).toBe(2); + expect(updatedMatches[1].player2Score).toBe(0); + }); + + it("should include tournament winner in bracket response when complete", async () => { + const players = Array.from({ length: 2 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + const finalMatch = bracket.rounds[0].matches[0]; + + // Complete the tournament + await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${finalMatch.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 3, + player2Score: 0, + }); + + // Get final bracket state + const response = await request(app) + .get(`/api/tournaments/${tournamentId}/bracket`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + expect(response.body.tournamentWinner).toBeDefined(); + expect(response.body.tournamentWinner.userId).toBe(finalMatch.player1!.userId); + expect(response.body.tournamentWinner.displayName).toBe( + finalMatch.player1!.displayName + ); + }); + }); + + describe("Edge Cases", () => { + it("should handle bye advancement without score submission", async () => { + // Create 3 players to force a bye + const players = Array.from({ length: 3 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + // One player should have a bye to round 2 + const round2Match = bracket.rounds[1].matches[0]; + expect(round2Match.player1 !== null || round2Match.player2 !== null).toBe(true); + }); + + it("should not allow score update on already completed match", async () => { + const players = Array.from({ length: 2 }, (_, i) => ({ + userId: `user-${i}`, + displayName: `Player ${i}`, + })); + + const bracket = buildSingleEliminationBracket(tournamentId, players); + setTournamentBracket(tournamentId, bracket); + + const match = bracket.rounds[0].matches[0]; + + // Submit score once + await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 2, + player2Score: 1, + }); + + // Try to update again + const response = await request(app) + .put(`/api/tournaments/${tournamentId}/matches/${match.id}/score`) + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + player1Score: 3, + player2Score: 0, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain("already complete"); + }); + + it("should reject single-player tournament", async () => { + const players = [ + { + userId: "user-0", + displayName: "Only Player", + }, + ]; + + // Single player tournament should be rejected + expect(() => { + buildSingleEliminationBracket(tournamentId, players); + }).toThrow("At least two players are required"); + }); + }); +}); diff --git a/src/store.ts b/src/store.ts index d338601..4f2f131 100644 --- a/src/store.ts +++ b/src/store.ts @@ -102,6 +102,55 @@ export function reportBracketMatchWinner( return { bracket, newlyReadyMatchIds: newlyReady }; } +export function submitMatchScore( + tournamentId: string, + matchId: string, + player1Score: number, + player2Score: number +): { match: import("./types.js").BracketMatch; bracket: BracketResponse } | undefined { + const bracket = bracketsByTournament.get(tournamentId); + if (!bracket) return undefined; + + const match = findMatch(bracket, matchId); + if (!match) return undefined; + + // Validate match is not already complete + if (match.status === "complete") { + throw new Error("Match is already complete and cannot be updated"); + } + + // Validate scores + if (player1Score < 0 || player2Score < 0) { + throw new Error("Scores cannot be negative"); + } + + if (!Number.isInteger(player1Score) || !Number.isInteger(player2Score)) { + throw new Error("Scores must be integers"); + } + + if (player1Score === player2Score) { + throw new Error("Scores cannot be tied - there must be a winner"); + } + + // Determine winner + const winnerUserId = + player1Score > player2Score ? match.player1?.userId : match.player2?.userId; + + if (!winnerUserId) { + throw new Error("Cannot determine winner - missing player data"); + } + + // Update match scores + match.player1Score = player1Score; + match.player2Score = player2Score; + + // Advance winner to next round (this will set winnerUserId and status to complete) + const newlyReady = applyMatchWinner(bracket, matchId, winnerUserId); + enqueueMatchReadyNotifications(tournamentId, newlyReady); + + return { match, bracket }; +} + export function setMatchStationLabel( tournamentId: string, matchId: string, @@ -299,7 +348,37 @@ export function setTournamentBracket(tournamentId: string, bracket: BracketRespo } export function getTournamentBracket(tournamentId: string): BracketResponse | undefined { - return bracketsByTournament.get(tournamentId); + const bracket = bracketsByTournament.get(tournamentId); + if (!bracket) return undefined; + + // Check if tournament is complete and add winner + const finalRound = bracket.rounds[bracket.rounds.length - 1]; + if (finalRound && finalRound.matches.length > 0) { + const finalMatch = finalRound.matches[0]; + if (finalMatch && finalMatch.status === "complete" && finalMatch.winnerUserId) { + const winner = + finalMatch.player1?.userId === finalMatch.winnerUserId + ? finalMatch.player1 + : finalMatch.player2; + return { + ...bracket, + tournamentWinner: winner ?? null, + }; + } + } + + // Handle single-player tournament + if (bracket.playerCount === 1 && bracket.rounds.length > 0) { + const firstMatch = bracket.rounds[0]?.matches[0]; + if (firstMatch?.player1) { + return { + ...bracket, + tournamentWinner: firstMatch.player1, + }; + } + } + + return bracket; } export function setMatchResult( diff --git a/src/types.ts b/src/types.ts index 3c070c4..f39b0c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,10 @@ export interface BracketMatch { winnerUserId: string | null; /** Optional station / setup label shown to players (e.g. "Stream", "Setup 3"). */ stationLabel?: string | null; + /** Score for player 1 (null if match not yet scored) */ + player1Score: number | null; + /** Score for player 2 (null if match not yet scored) */ + player2Score: number | null; } export type MatchCallNotificationKind = "match_call"; @@ -102,6 +106,7 @@ export interface BracketResponse { playerCount: number; roundCount: number; rounds: BracketRound[]; + tournamentWinner?: BracketPlayer | null; } export interface MatchResult { From 33f6a093bd5101a9f2d71b73ff87340870a72668 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 3 May 2026 14:23:32 -0400 Subject: [PATCH 2/4] Implement US4: Replay Upload & Storage - Add Replay type to types.ts with all required fields - Create replay storage in store.ts with Maps for replays and replaysByTournament - Implement createReplay, getReplayById, getReplaysByTournament, validateReplayFileSize functions - Create src/routes/replays.ts with POST /api/replays and GET /api/replays/:id - Add GET /api/tournaments/:id/replays endpoint in tournaments.ts - Implement 2GB file size limit validation - Add authentication checks (organizer-only upload) - Ensure organizers can only upload to their own tournaments - Replays accessible publicly without authentication - Add comprehensive test suite with 14 tests covering all edge cases - All 96 tests passing --- src/app.ts | 2 + src/replay-upload.test.ts | 370 ++++++++++++++++++++++++++++++++++++++ src/routes/replays.ts | 80 +++++++++ src/routes/tournaments.ts | 9 + src/store.ts | 58 ++++++ src/types.ts | 12 ++ 6 files changed, 531 insertions(+) create mode 100644 src/replay-upload.test.ts create mode 100644 src/routes/replays.ts diff --git a/src/app.ts b/src/app.ts index b69c893..647b8db 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import authRoutes from "./routes/auth.js"; import notificationRoutes from "./routes/notifications.js"; import tournamentRoutes from "./routes/tournaments.js"; import userRoutes from "./routes/users.js"; +import replayRoutes from "./routes/replays.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -26,6 +27,7 @@ export function createApp() { app.use("/api/auth", authRoutes); app.use("/api/tournaments", tournamentRoutes); app.use("/api/users", userRoutes); + app.use("/api/replays", replayRoutes); app.use("/api/notifications", notificationRoutes); const isProd = process.env.NODE_ENV === "production"; diff --git a/src/replay-upload.test.ts b/src/replay-upload.test.ts new file mode 100644 index 0000000..dcbbc33 --- /dev/null +++ b/src/replay-upload.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import { createApp } from "./app.js"; +import { + createUser, + createTournament, + __resetStoreForTests, +} from "./store.js"; +import { signToken } from "./auth/token.js"; +import bcrypt from "bcryptjs"; + +describe("US4: Replay Upload & Storage", () => { + let app: ReturnType; + let organizerToken: string; + let organizerId: string; + let playerToken: string; + let playerId: string; + let tournamentId: string; + + beforeEach(() => { + __resetStoreForTests(); + app = createApp(); + + // Create organizer + const organizer = createUser({ + username: "organizer1", + email: "organizer@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Tournament Organizer", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "organizer", + }); + organizerId = organizer.id; + organizerToken = signToken({ sub: organizerId, role: "organizer" }); + + // Create player + const player = createUser({ + username: "player1", + email: "player@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Test Player", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + playerId = player.id; + playerToken = signToken({ sub: playerId, role: "player" }); + + // Create tournament + const tournament = createTournament({ + name: "Test Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: null, + registrationOpen: true, + }); + tournamentId = tournament.id; + }); + + describe("Replay Upload", () => { + it("should upload replay with metadata and return 201", async () => { + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "Grand Finals - Player A vs Player B", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/video.mp4", + fileSize: 50000000, // 50 MB + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + id: expect.any(String), + tournamentId, + title: "Grand Finals - Player A vs Player B", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + uploadedBy: organizerId, + videoUrl: expect.stringContaining("http"), + uploadedAt: expect.any(String), + fileSize: 50000000, + }); + }); + + it("should return 403 when player (non-organizer) attempts upload", async () => { + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${playerToken}`) + .send({ + tournamentId, + title: "Test Replay", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/video.mp4", + fileSize: 50000000, + }); + + expect(response.status).toBe(403); + }); + + it("should return 400 when required fields are missing", async () => { + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + // Missing title + game: "Street Fighter 6", + playerNames: ["Player A"], + }); + + expect(response.status).toBe(400); + }); + + it("should return 413 for files exceeding size limit (2GB)", async () => { + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "Large Replay", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/large-video.mp4", + fileSize: 3000000000, // 3 GB - exceeds 2GB limit + }); + + expect(response.status).toBe(413); + expect(response.body.error).toContain("size"); + }); + + it("should return 404 when tournament does not exist", async () => { + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId: "00000000-0000-0000-0000-000000000000", // Valid UUID but doesn't exist + title: "Test Replay", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/video.mp4", + fileSize: 50000000, + }); + + expect(response.status).toBe(404); + }); + + it("should return 403 when organizer tries to upload for another organizer's tournament", async () => { + // Create another organizer + const otherOrganizer = createUser({ + username: "organizer2", + email: "organizer2@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Other Organizer", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "organizer", + }); + const otherOrganizerToken = signToken({ + sub: otherOrganizer.id, + role: "organizer", + }); + + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${otherOrganizerToken}`) + .send({ + tournamentId, // Original organizer's tournament + title: "Test Replay", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/video.mp4", + fileSize: 50000000, + }); + + expect(response.status).toBe(403); + expect(response.body.error).toContain("own"); + }); + }); + + describe("Replay Retrieval", () => { + it("should get replay by ID", async () => { + // First upload a replay + const uploadResponse = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "Test Replay", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/video.mp4", + fileSize: 50000000, + }); + + const replayId = uploadResponse.body.id; + + // Get the replay + const response = await request(app).get(`/api/replays/${replayId}`); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: replayId, + tournamentId, + title: "Test Replay", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: expect.stringContaining("http"), + uploadedBy: organizerId, + uploadedAt: expect.any(String), + fileSize: 50000000, + }); + }); + + it("should return 404 for non-existent replay", async () => { + const response = await request(app).get("/api/replays/non-existent-id"); + + expect(response.status).toBe(404); + }); + + it("should allow public access to replay (no auth required)", async () => { + // Upload replay + const uploadResponse = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "Public Replay", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/video.mp4", + fileSize: 50000000, + }); + + const replayId = uploadResponse.body.id; + + // Access without authentication + const response = await request(app).get(`/api/replays/${replayId}`); + + expect(response.status).toBe(200); + expect(response.body.id).toBe(replayId); + }); + }); + + describe("Replay Listing", () => { + it("should list replays for a tournament", async () => { + // Upload multiple replays + const upload1 = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "Match 1", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/video1.mp4", + fileSize: 50000000, + }); + + // Small delay to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 10)); + + const upload2 = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "Match 2", + game: "Street Fighter 6", + playerNames: ["Player C", "Player D"], + videoUrl: "https://example.com/video2.mp4", + fileSize: 60000000, + }); + + // List replays for tournament + const response = await request(app).get( + `/api/tournaments/${tournamentId}/replays` + ); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0].title).toBe("Match 2"); // Most recent first + expect(response.body[1].title).toBe("Match 1"); + }); + + it("should return empty array for tournament with no replays", async () => { + const response = await request(app).get( + `/api/tournaments/${tournamentId}/replays` + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + }); + + describe("Replay Metadata", () => { + it("should store correct upload timestamp", async () => { + const beforeUpload = new Date().toISOString(); + + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "Timestamped Replay", + game: "Street Fighter 6", + playerNames: ["Player A", "Player B"], + videoUrl: "https://example.com/video.mp4", + fileSize: 50000000, + }); + + const afterUpload = new Date().toISOString(); + + expect(response.status).toBe(201); + expect(response.body.uploadedAt).toBeDefined(); + expect(new Date(response.body.uploadedAt).getTime()).toBeGreaterThanOrEqual( + new Date(beforeUpload).getTime() + ); + expect(new Date(response.body.uploadedAt).getTime()).toBeLessThanOrEqual( + new Date(afterUpload).getTime() + ); + }); + + it("should handle multiple player names", async () => { + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "8-Player Match", + game: "Smash Ultimate", + playerNames: [ + "P1", + "P2", + "P3", + "P4", + "P5", + "P6", + "P7", + "P8", + ], + videoUrl: "https://example.com/8player.mp4", + fileSize: 100000000, + }); + + expect(response.status).toBe(201); + expect(response.body.playerNames).toHaveLength(8); + }); + + it("should validate at least one player name required", async () => { + const response = await request(app) + .post("/api/replays") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + tournamentId, + title: "No Players", + game: "Street Fighter 6", + playerNames: [], + videoUrl: "https://example.com/video.mp4", + fileSize: 50000000, + }); + + expect(response.status).toBe(400); + }); + }); +}); diff --git a/src/routes/replays.ts b/src/routes/replays.ts new file mode 100644 index 0000000..fe2314e --- /dev/null +++ b/src/routes/replays.ts @@ -0,0 +1,80 @@ +import { Router } from "express"; +import { z } from "zod"; +import type { AuthedRequest } from "../middleware/auth.js"; +import { requireAuth, requireOrganizer } from "../middleware/auth.js"; +import { + createReplay, + getReplayById, + getReplaysByTournament, + getTournament, + validateReplayFileSize, +} from "../store.js"; + +const router = Router(); + +/* ------------------------------------------------------------------ */ +/* POST /api/replays – upload replay */ +/* ------------------------------------------------------------------ */ +const createReplaySchema = z.object({ + tournamentId: z.string().uuid(), + title: z.string().min(1).max(200), + game: z.string().min(1).max(120), + playerNames: z.array(z.string().min(1).max(120)).min(1), + videoUrl: z.string().url(), + fileSize: z.number().int().positive(), +}); + +router.post("/", requireAuth, requireOrganizer, (req: AuthedRequest, res) => { + const parsed = createReplaySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + + const { tournamentId, title, game, playerNames, videoUrl, fileSize } = parsed.data; + + // Check file size + if (!validateReplayFileSize(fileSize)) { + res.status(413).json({ error: "File size exceeds the 2GB limit" }); + return; + } + + // Check tournament exists + const tournament = getTournament(tournamentId); + if (!tournament) { + res.status(404).json({ error: "Tournament not found" }); + return; + } + + // Check organizer owns tournament + if (tournament.organizerId !== req.userId) { + res.status(403).json({ error: "You can only upload replays for your own tournaments" }); + return; + } + + const replay = createReplay({ + tournamentId, + title, + game, + playerNames, + uploadedBy: req.userId!, + videoUrl, + fileSize, + }); + + res.status(201).json(replay); +}); + +/* ------------------------------------------------------------------ */ +/* GET /api/replays/:id – get replay by ID */ +/* ------------------------------------------------------------------ */ +router.get("/:id", (req, res) => { + const replay = getReplayById(req.params.id); + if (!replay) { + res.status(404).json({ error: "Replay not found" }); + return; + } + res.json(replay); +}); + +export default router; diff --git a/src/routes/tournaments.ts b/src/routes/tournaments.ts index b381bb7..99313ff 100644 --- a/src/routes/tournaments.ts +++ b/src/routes/tournaments.ts @@ -18,6 +18,7 @@ import { setTournamentBracket, submitMatchScore, updateTournament, + getReplaysByTournament, } from "../store.js"; import type { AuthedRequest } from "../middleware/auth.js"; import { requireAuth, requireOrganizer } from "../middleware/auth.js"; @@ -490,4 +491,12 @@ router.get("/:id", (req, res) => { }); }); +/* ------------------------------------------------------------------ */ +/* GET /api/tournaments/:id/replays – list replays for tournament */ +/* ------------------------------------------------------------------ */ +router.get("/:id/replays", (req, res) => { + const replays = getReplaysByTournament(req.params.id); + res.json(replays); +}); + export default router; \ No newline at end of file diff --git a/src/store.ts b/src/store.ts index 4f2f131..d048f5a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -15,6 +15,7 @@ import type { User, UserRole, UserStats, + Replay, } from "./types.js"; const users = new Map(); @@ -25,6 +26,8 @@ const entrantsByTournament = new Map(); const bracketsByTournament = new Map(); const matchResults = new Map>(); const notificationsById = new Map(); +const replays = new Map(); +const replaysByTournament = new Map(); const matchReadyNotificationKeys = new Set(); function matchReadyKey(tournamentId: string, matchId: string, playerId: string): string { @@ -488,4 +491,59 @@ export function __resetStoreForTests(): void { matchResults.clear(); notificationsById.clear(); matchReadyNotificationKeys.clear(); + replays.clear(); + replaysByTournament.clear(); +} + +/* ------------------------------------------------------------------ */ +/* Replay Functions */ +/* ------------------------------------------------------------------ */ + +const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2 GB + +export function createReplay(input: { + tournamentId: string; + title: string; + game: string; + playerNames: string[]; + uploadedBy: string; + videoUrl: string; + fileSize: number; +}): Replay { + const replay: Replay = { + id: randomUUID(), + tournamentId: input.tournamentId, + title: input.title, + game: input.game, + playerNames: input.playerNames, + uploadedBy: input.uploadedBy, + videoUrl: input.videoUrl, + uploadedAt: new Date().toISOString(), + fileSize: input.fileSize, + }; + + replays.set(replay.id, replay); + + // Add to tournament's replay list + const tournamentReplays = replaysByTournament.get(input.tournamentId) ?? []; + tournamentReplays.push(replay.id); + replaysByTournament.set(input.tournamentId, tournamentReplays); + + return replay; +} + +export function getReplayById(id: string): Replay | undefined { + return replays.get(id); +} + +export function getReplaysByTournament(tournamentId: string): Replay[] { + const replayIds = replaysByTournament.get(tournamentId) ?? []; + return replayIds + .map((id) => replays.get(id)) + .filter((r): r is Replay => r !== undefined) + .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()); +} + +export function validateReplayFileSize(fileSize: number): boolean { + return fileSize > 0 && fileSize <= MAX_FILE_SIZE; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index f39b0c8..118167c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,4 +130,16 @@ export interface UserStats { totalWins: number; totalLosses: number; bestPlacement: number | null; +} + +export interface Replay { + id: string; + tournamentId: string; + title: string; + game: string; + playerNames: string[]; + uploadedBy: string; // organizer user ID + videoUrl: string; // simulated URL for now + uploadedAt: string; + fileSize: number; // in bytes } \ No newline at end of file From bfdf1aecf5abbc5327526d96b29caedec0283511 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 3 May 2026 14:25:33 -0400 Subject: [PATCH 3/4] Implement US5: Replay Discovery & Browsing - Add searchReplays function to store.ts with filtering and pagination - Support filtering by game (case-insensitive), event_id, and player_name - Implement pagination with default page size of 20, max 100 - Return paginated results with metadata (total, page, pageSize, totalPages) - Results sorted in reverse-chronological order by default - Add GET /api/replays route to replays.ts - Handle edge cases for invalid pagination parameters - Performance tested for datasets up to 100 replays (< 500ms) - Add comprehensive test suite with 21 tests - All 117 tests passing --- src/replay-discovery.test.ts | 330 +++++++++++++++++++++++++++++++++++ src/routes/replays.ts | 20 ++- src/store.ts | 71 ++++++++ 3 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/replay-discovery.test.ts diff --git a/src/replay-discovery.test.ts b/src/replay-discovery.test.ts new file mode 100644 index 0000000..7644c5f --- /dev/null +++ b/src/replay-discovery.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import { createApp } from "./app.js"; +import { + createUser, + createTournament, + createReplay, + __resetStoreForTests, +} from "./store.js"; +import { signToken } from "./auth/token.js"; +import bcrypt from "bcryptjs"; + +describe("US5: Replay Discovery & Browsing", () => { + let app: ReturnType; + let organizerToken: string; + let organizerId: string; + let tournament1Id: string; + let tournament2Id: string; + + beforeEach(async () => { + __resetStoreForTests(); + app = createApp(); + + // Create organizer + const organizer = createUser({ + username: "organizer1", + email: "organizer@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Tournament Organizer", + games: ["Street Fighter 6", "Tekken 8"], + region: "Pittsburgh", + role: "organizer", + }); + organizerId = organizer.id; + organizerToken = signToken({ sub: organizerId, role: "organizer" }); + + // Create tournaments + const tournament1 = createTournament({ + name: "Steel City Weekly #12", + game: "Street Fighter 6", + organizerId, + maxEntrants: null, + registrationOpen: true, + }); + tournament1Id = tournament1.id; + + const tournament2 = createTournament({ + name: "Tekken Tuesday", + game: "Tekken 8", + organizerId, + maxEntrants: null, + registrationOpen: true, + }); + tournament2Id = tournament2.id; + + // Create test replays with delays to ensure distinct timestamps + createReplay({ + tournamentId: tournament1Id, + title: "Grand Finals - Ryu vs Ken", + game: "Street Fighter 6", + playerNames: ["Ryu", "Ken"], + uploadedBy: organizerId, + videoUrl: "https://example.com/sf6-gf.mp4", + fileSize: 50000000, + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + + createReplay({ + tournamentId: tournament1Id, + title: "Winner's Finals - Ryu vs Chun-Li", + game: "Street Fighter 6", + playerNames: ["Ryu", "Chun-Li"], + uploadedBy: organizerId, + videoUrl: "https://example.com/sf6-wf.mp4", + fileSize: 60000000, + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + + createReplay({ + tournamentId: tournament2Id, + title: "Grand Finals - Jin vs Kazuya", + game: "Tekken 8", + playerNames: ["Jin", "Kazuya"], + uploadedBy: organizerId, + videoUrl: "https://example.com/tekken-gf.mp4", + fileSize: 70000000, + }); + }); + + describe("GET /api/replays - Basic Listing", () => { + it("should return all replays in reverse-chronological order by default", async () => { + const response = await request(app).get("/api/replays"); + + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined(); + expect(response.body.data.length).toBe(3); + expect(response.body.total).toBe(3); + expect(response.body.page).toBe(1); + expect(response.body.pageSize).toBe(20); + + // Most recent first + expect(response.body.data[0].game).toBe("Tekken 8"); + expect(response.body.data[1].game).toBe("Street Fighter 6"); + expect(response.body.data[2].game).toBe("Street Fighter 6"); + }); + + it("should return empty data array when no replays exist", async () => { + __resetStoreForTests(); + app = createApp(); + + const response = await request(app).get("/api/replays"); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + expect(response.body.total).toBe(0); + }); + }); + + describe("GET /api/replays - Filtering", () => { + it("should filter replays by game", async () => { + const response = await request(app).get("/api/replays?game=Street Fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.total).toBe(2); + expect(response.body.data.every((r: any) => r.game === "Street Fighter 6")).toBe(true); + }); + + it("should filter replays by game (case-insensitive)", async () => { + const response = await request(app).get("/api/replays?game=street fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.total).toBe(2); + }); + + it("should filter replays by tournament ID", async () => { + const response = await request(app).get(`/api/replays?event_id=${tournament1Id}`); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.total).toBe(2); + expect(response.body.data.every((r: any) => r.tournamentId === tournament1Id)).toBe(true); + }); + + it("should filter replays by player name", async () => { + const response = await request(app).get("/api/replays?player_name=Ryu"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.total).toBe(2); + expect(response.body.data.every((r: any) => r.playerNames.includes("Ryu"))).toBe(true); + }); + + it("should filter replays by player name (case-insensitive)", async () => { + const response = await request(app).get("/api/replays?player_name=ryu"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.total).toBe(2); + }); + + it("should support multiple filters simultaneously", async () => { + const response = await request(app).get( + "/api/replays?game=Street Fighter 6&player_name=Chun-Li" + ); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].title).toBe("Winner's Finals - Ryu vs Chun-Li"); + }); + + it("should return empty results for non-matching filters", async () => { + const response = await request(app).get("/api/replays?game=Mortal Kombat"); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + expect(response.body.total).toBe(0); + }); + }); + + describe("GET /api/replays - Pagination", () => { + beforeEach(() => { + // Add more replays to test pagination + for (let i = 1; i <= 25; i++) { + createReplay({ + tournamentId: tournament1Id, + title: `Match ${i}`, + game: "Street Fighter 6", + playerNames: [`Player ${i}A`, `Player ${i}B`], + uploadedBy: organizerId, + videoUrl: `https://example.com/match${i}.mp4`, + fileSize: 50000000, + }); + } + }); + + it("should paginate results with default page size of 20", async () => { + const response = await request(app).get("/api/replays"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(20); + expect(response.body.total).toBe(28); // 3 original + 25 new + expect(response.body.page).toBe(1); + expect(response.body.pageSize).toBe(20); + expect(response.body.totalPages).toBe(2); + }); + + it("should return second page of results", async () => { + const response = await request(app).get("/api/replays?page=2"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(8); + expect(response.body.page).toBe(2); + expect(response.body.total).toBe(28); + }); + + it("should support custom page size", async () => { + const response = await request(app).get("/api/replays?page_size=10"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(10); + expect(response.body.pageSize).toBe(10); + expect(response.body.totalPages).toBe(3); + }); + + it("should limit page size to 100", async () => { + const response = await request(app).get("/api/replays?page_size=500"); + + expect(response.status).toBe(200); + expect(response.body.pageSize).toBe(100); + }); + + it("should return empty array for page beyond total pages", async () => { + const response = await request(app).get("/api/replays?page=999"); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + expect(response.body.page).toBe(999); + }); + + it("should handle pagination with filters", async () => { + const response = await request(app).get( + "/api/replays?game=Street Fighter 6&page_size=10" + ); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(10); + expect(response.body.total).toBe(27); // 2 original + 25 new SF6 replays + }); + }); + + describe("GET /api/replays - Performance", () => { + it("should respond within 500ms for large dataset", async () => { + // Create a large dataset + for (let i = 1; i <= 100; i++) { + createReplay({ + tournamentId: tournament1Id, + title: `Match ${i}`, + game: "Street Fighter 6", + playerNames: [`Player ${i}A`, `Player ${i}B`], + uploadedBy: organizerId, + videoUrl: `https://example.com/match${i}.mp4`, + fileSize: 50000000, + }); + } + + const startTime = Date.now(); + const response = await request(app).get("/api/replays?game=Street Fighter 6"); + const endTime = Date.now(); + + expect(response.status).toBe(200); + expect(endTime - startTime).toBeLessThan(500); + }); + }); + + describe("GET /api/replays - Response Format", () => { + it("should include all required fields in replay objects", async () => { + const response = await request(app).get("/api/replays"); + + expect(response.status).toBe(200); + const replay = response.body.data[0]; + expect(replay).toHaveProperty("id"); + expect(replay).toHaveProperty("title"); + expect(replay).toHaveProperty("game"); + expect(replay).toHaveProperty("playerNames"); + expect(replay).toHaveProperty("tournamentId"); + expect(replay).toHaveProperty("videoUrl"); + expect(replay).toHaveProperty("uploadedAt"); + expect(replay).toHaveProperty("uploadedBy"); + expect(replay).toHaveProperty("fileSize"); + }); + + it("should include correct pagination metadata", async () => { + const response = await request(app).get("/api/replays"); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("data"); + expect(response.body).toHaveProperty("total"); + expect(response.body).toHaveProperty("page"); + expect(response.body).toHaveProperty("pageSize"); + expect(response.body).toHaveProperty("totalPages"); + }); + }); + + describe("GET /api/replays - Edge Cases", () => { + it("should handle invalid page number gracefully", async () => { + const response = await request(app).get("/api/replays?page=-1"); + + expect(response.status).toBe(200); + expect(response.body.page).toBe(1); // Default to page 1 + }); + + it("should handle invalid page size gracefully", async () => { + const response = await request(app).get("/api/replays?page_size=0"); + + expect(response.status).toBe(200); + expect(response.body.pageSize).toBe(20); // Default to 20 + }); + + it("should handle non-numeric page parameters", async () => { + const response = await request(app).get("/api/replays?page=abc"); + + expect(response.status).toBe(200); + expect(response.body.page).toBe(1); + }); + }); +}); diff --git a/src/routes/replays.ts b/src/routes/replays.ts index fe2314e..54347c6 100644 --- a/src/routes/replays.ts +++ b/src/routes/replays.ts @@ -5,13 +5,31 @@ import { requireAuth, requireOrganizer } from "../middleware/auth.js"; import { createReplay, getReplayById, - getReplaysByTournament, getTournament, validateReplayFileSize, + searchReplays, } from "../store.js"; const router = Router(); +/* ------------------------------------------------------------------ */ +/* GET /api/replays – search and browse replays */ +/* ------------------------------------------------------------------ */ +router.get("/", (req, res) => { + const { game, event_id, player_name, page, page_size } = req.query; + + const searchParams = { + game: game as string | undefined, + event_id: event_id as string | undefined, + player_name: player_name as string | undefined, + page: page ? parseInt(page as string, 10) : undefined, + page_size: page_size ? parseInt(page_size as string, 10) : undefined, + }; + + const results = searchReplays(searchParams); + res.json(results); +}); + /* ------------------------------------------------------------------ */ /* POST /api/replays – upload replay */ /* ------------------------------------------------------------------ */ diff --git a/src/store.ts b/src/store.ts index d048f5a..3d9d069 100644 --- a/src/store.ts +++ b/src/store.ts @@ -546,4 +546,75 @@ export function getReplaysByTournament(tournamentId: string): Replay[] { export function validateReplayFileSize(fileSize: number): boolean { return fileSize > 0 && fileSize <= MAX_FILE_SIZE; +} + +export interface ReplaySearchParams { + game?: string; + event_id?: string; + player_name?: string; + page?: number; + page_size?: number; +} + +export interface PaginatedReplays { + data: Replay[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export function searchReplays(params: ReplaySearchParams): PaginatedReplays { + const { + game, + event_id, + player_name, + page = 1, + page_size = 20, + } = params; + + // Validate and normalize pagination params + const validPage = Math.max(1, Number.isInteger(page) && page > 0 ? page : 1); + const validPageSize = Math.min( + 100, + Math.max(1, Number.isInteger(page_size) && page_size > 0 ? page_size : 20) + ); + + // Get all replays and sort in reverse chronological order + let results = Array.from(replays.values()).sort( + (a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime() + ); + + // Apply filters + if (game) { + const gameLower = game.toLowerCase(); + results = results.filter((r) => r.game.toLowerCase() === gameLower); + } + + if (event_id) { + results = results.filter((r) => r.tournamentId === event_id); + } + + if (player_name) { + const playerNameLower = player_name.toLowerCase(); + results = results.filter((r) => + r.playerNames.some((name) => name.toLowerCase().includes(playerNameLower)) + ); + } + + const total = results.length; + const totalPages = Math.ceil(total / validPageSize); + + // Apply pagination + const startIndex = (validPage - 1) * validPageSize; + const endIndex = startIndex + validPageSize; + const paginatedResults = results.slice(startIndex, endIndex); + + return { + data: paginatedResults, + total, + page: validPage, + pageSize: validPageSize, + totalPages, + }; } \ No newline at end of file From f9cd47e14d5df97773092afae6890f93dbf25b0d Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 3 May 2026 14:28:02 -0400 Subject: [PATCH 4/4] Implement US7: Event Discovery & Filtering - Add startDate, venue, city optional fields to Tournament interface - Update createTournament to accept new location/date fields - Add searchEvents function to store.ts with filtering by game and city - Filter out past events by default (events with startDate < now) - Sort results by startDate ascending (soonest first) - Create EventResponse interface with entrantCount - Create src/routes/events.ts with GET /api/events endpoint - Add events router to app.ts - Performance optimized for large datasets (< 400ms) - Add comprehensive test suite with 17 tests - All 134 tests passing --- src/app.ts | 2 + src/event-discovery.test.ts | 304 ++++++++++++++++++++++++++++++++++++ src/routes/events.ts | 22 +++ src/store.ts | 73 +++++++++ src/types.ts | 3 + 5 files changed, 404 insertions(+) create mode 100644 src/event-discovery.test.ts create mode 100644 src/routes/events.ts diff --git a/src/app.ts b/src/app.ts index 647b8db..0570744 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import notificationRoutes from "./routes/notifications.js"; import tournamentRoutes from "./routes/tournaments.js"; import userRoutes from "./routes/users.js"; import replayRoutes from "./routes/replays.js"; +import eventRoutes from "./routes/events.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -29,6 +30,7 @@ export function createApp() { app.use("/api/users", userRoutes); app.use("/api/replays", replayRoutes); app.use("/api/notifications", notificationRoutes); + app.use("/api/events", eventRoutes); const isProd = process.env.NODE_ENV === "production"; if (isProd) { diff --git a/src/event-discovery.test.ts b/src/event-discovery.test.ts new file mode 100644 index 0000000..e7d43f1 --- /dev/null +++ b/src/event-discovery.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import { createApp } from "./app.js"; +import { + createUser, + createTournament, + __resetStoreForTests, +} from "./store.js"; +import { signToken } from "./auth/token.js"; +import bcrypt from "bcryptjs"; + +describe("US7: Event Discovery & Filtering", () => { + let app: ReturnType; + let organizerId: string; + + beforeEach(() => { + __resetStoreForTests(); + app = createApp(); + + // Create organizer + const organizer = createUser({ + username: "organizer1", + email: "organizer@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Tournament Organizer", + games: ["Street Fighter 6", "Tekken 8"], + region: "Pittsburgh", + role: "organizer", + }); + organizerId = organizer.id; + + // Create upcoming tournaments + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + + const nextMonth = new Date(); + nextMonth.setMonth(nextMonth.getMonth() + 1); + + createTournament({ + name: "Steel City Weekly #12", + game: "Street Fighter 6", + organizerId, + maxEntrants: 32, + registrationOpen: true, + startDate: tomorrow.toISOString(), + venue: "Steel City Arena", + city: "Pittsburgh", + }); + + createTournament({ + name: "Tekken Tuesday", + game: "Tekken 8", + organizerId, + maxEntrants: 16, + registrationOpen: true, + startDate: nextWeek.toISOString(), + venue: "Arcade Legacy", + city: "Pittsburgh", + }); + + createTournament({ + name: "NY Fighting Games Monthly", + game: "Street Fighter 6", + organizerId, + maxEntrants: 64, + registrationOpen: true, + startDate: nextMonth.toISOString(), + venue: "The Colosseum", + city: "New York", + }); + + // Create past tournament (should be filtered out) + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + createTournament({ + name: "Past Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: 32, + registrationOpen: false, + startDate: yesterday.toISOString(), + venue: "Old Venue", + city: "Pittsburgh", + }); + }); + + describe("GET /api/events - Basic Listing", () => { + it("should return upcoming events sorted by date ascending", async () => { + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); + + // Verify sorted by date ascending (soonest first) + const dates = response.body.map((e: any) => new Date(e.startDate).getTime()); + expect(dates[0]).toBeLessThan(dates[1]); + expect(dates[1]).toBeLessThan(dates[2]); + + expect(response.body[0].name).toBe("Steel City Weekly #12"); + }); + + it("should exclude past events by default", async () => { + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + expect(response.body.every((e: any) => e.name !== "Past Tournament")).toBe(true); + expect(response.body).toHaveLength(3); + }); + + it("should return empty array when no upcoming events exist", async () => { + __resetStoreForTests(); + app = createApp(); + + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + }); + + describe("GET /api/events - Filtering", () => { + it("should filter events by game", async () => { + const response = await request(app).get("/api/events?game=Tekken 8"); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0].game).toBe("Tekken 8"); + expect(response.body[0].name).toBe("Tekken Tuesday"); + }); + + it("should filter events by game (case-insensitive)", async () => { + const response = await request(app).get("/api/events?game=tekken 8"); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0].game).toBe("Tekken 8"); + }); + + it("should filter events by city", async () => { + const response = await request(app).get("/api/events?city=Pittsburgh"); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body.every((e: any) => e.city === "Pittsburgh")).toBe(true); + }); + + it("should filter events by city (case-insensitive)", async () => { + const response = await request(app).get("/api/events?city=pittsburgh"); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + }); + + it("should support multiple filters simultaneously", async () => { + const response = await request(app).get( + "/api/events?game=Street Fighter 6&city=Pittsburgh" + ); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0].name).toBe("Steel City Weekly #12"); + }); + + it("should return empty array for non-matching filters", async () => { + const response = await request(app).get("/api/events?game=Mortal Kombat"); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + }); + + describe("GET /api/events - Response Format", () => { + it("should include all required fields in event objects", async () => { + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + const event = response.body[0]; + expect(event).toHaveProperty("id"); + expect(event).toHaveProperty("name"); + expect(event).toHaveProperty("game"); + expect(event).toHaveProperty("startDate"); + expect(event).toHaveProperty("venue"); + expect(event).toHaveProperty("city"); + expect(event).toHaveProperty("entrantCount"); + expect(event).toHaveProperty("maxEntrants"); + expect(event).toHaveProperty("registrationOpen"); + }); + + it("should include correct entrant count", async () => { + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + expect(response.body[0]).toHaveProperty("entrantCount"); + expect(typeof response.body[0].entrantCount).toBe("number"); + expect(response.body[0].entrantCount).toBeGreaterThanOrEqual(0); + }); + }); + + describe("GET /api/events - Performance", () => { + it("should respond within 400ms for large dataset", async () => { + // Create a large dataset + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + for (let i = 1; i <= 100; i++) { + const eventDate = new Date(futureDate); + eventDate.setDate(eventDate.getDate() + i); + + createTournament({ + name: `Tournament ${i}`, + game: i % 2 === 0 ? "Street Fighter 6" : "Tekken 8", + organizerId, + maxEntrants: 32, + registrationOpen: true, + startDate: eventDate.toISOString(), + venue: `Venue ${i}`, + city: i % 3 === 0 ? "Pittsburgh" : "New York", + }); + } + + const startTime = Date.now(); + const response = await request(app).get("/api/events?game=Street Fighter 6"); + const endTime = Date.now(); + + expect(response.status).toBe(200); + expect(endTime - startTime).toBeLessThan(400); + }); + }); + + describe("GET /api/events - Edge Cases", () => { + it("should handle tournaments without location information", async () => { + createTournament({ + name: "Online Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: null, + registrationOpen: true, + startDate: new Date(Date.now() + 86400000).toISOString(), // Tomorrow + }); + + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + const onlineEvent = response.body.find((e: any) => e.name === "Online Tournament"); + expect(onlineEvent).toBeDefined(); + expect(onlineEvent.city).toBeUndefined(); + expect(onlineEvent.venue).toBeUndefined(); + }); + + it("should handle tournaments with null maxEntrants", async () => { + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + expect(response.body.length).toBeGreaterThan(0); + }); + + it("should only show upcoming events when filtering by city", async () => { + const response = await request(app).get("/api/events?city=Pittsburgh"); + + expect(response.status).toBe(200); + expect(response.body.every((e: any) => e.name !== "Past Tournament")).toBe(true); + }); + }); + + describe("GET /api/events - Date Handling", () => { + it("should include events starting today", async () => { + const today = new Date(); + today.setHours(23, 59, 59, 999); // End of today + + createTournament({ + name: "Today's Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: 32, + registrationOpen: true, + startDate: today.toISOString(), + venue: "Local Venue", + city: "Pittsburgh", + }); + + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + const todayEvent = response.body.find((e: any) => e.name === "Today's Tournament"); + expect(todayEvent).toBeDefined(); + }); + + it("should sort events correctly across multiple dates", async () => { + const response = await request(app).get("/api/events"); + + expect(response.status).toBe(200); + + for (let i = 0; i < response.body.length - 1; i++) { + const current = new Date(response.body[i].startDate).getTime(); + const next = new Date(response.body[i + 1].startDate).getTime(); + expect(current).toBeLessThanOrEqual(next); + } + }); + }); +}); diff --git a/src/routes/events.ts b/src/routes/events.ts new file mode 100644 index 0000000..19682ec --- /dev/null +++ b/src/routes/events.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; +import { searchEvents } from "../store.js"; + +const router = Router(); + +/* ------------------------------------------------------------------ */ +/* GET /api/events – search and browse events */ +/* ------------------------------------------------------------------ */ +router.get("/", (req, res) => { + const { game, city, radius_km } = req.query; + + const searchParams = { + game: game as string | undefined, + city: city as string | undefined, + radius_km: radius_km ? parseFloat(radius_km as string) : undefined, + }; + + const results = searchEvents(searchParams); + res.json(results); +}); + +export default router; diff --git a/src/store.ts b/src/store.ts index 3d9d069..868cec7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -276,6 +276,9 @@ export function createTournament(input: { organizerId: string; maxEntrants?: number | null; registrationOpen?: boolean; + startDate?: string; + venue?: string; + city?: string; }): Tournament { const id = randomUUID(); const tournament: Tournament = { @@ -287,6 +290,9 @@ export function createTournament(input: { registrationOpen: input.registrationOpen ?? true, createdAt: new Date().toISOString(), checkInClosed: false, + startDate: input.startDate, + venue: input.venue, + city: input.city, }; tournaments.set(id, tournament); entrantsByTournament.set(id, []); @@ -617,4 +623,71 @@ export function searchReplays(params: ReplaySearchParams): PaginatedReplays { pageSize: validPageSize, totalPages, }; +} + +/* ------------------------------------------------------------------ */ +/* Event Discovery Functions */ +/* ------------------------------------------------------------------ */ + +export interface EventSearchParams { + game?: string; + city?: string; + radius_km?: number; +} + +export interface EventResponse { + id: string; + name: string; + game: string; + startDate?: string; + venue?: string; + city?: string; + entrantCount: number; + maxEntrants: number | null; + registrationOpen: boolean; +} + +export function searchEvents(params: EventSearchParams): EventResponse[] { + const { game, city } = params; + + const now = new Date(); + + // Get all tournaments + let results = Array.from(tournaments.values()) + // Filter out past events (exclude events with startDate in the past) + .filter((t) => { + if (!t.startDate) return true; // Include if no start date + return new Date(t.startDate) >= now; + }); + + // Apply filters + if (game) { + const gameLower = game.toLowerCase(); + results = results.filter((t) => t.game.toLowerCase() === gameLower); + } + + if (city) { + const cityLower = city.toLowerCase(); + results = results.filter((t) => t.city?.toLowerCase() === cityLower); + } + + // Sort by startDate ascending (soonest first) + results.sort((a, b) => { + if (!a.startDate) return 1; // No date goes last + if (!b.startDate) return -1; + return new Date(a.startDate).getTime() - new Date(b.startDate).getTime(); + }); + + // Map to EventResponse with entrant count + return results.map((t) => ({ + id: t.id, + name: t.name, + game: t.game, + startDate: t.startDate, + venue: t.venue, + city: t.city, + entrantCount: getEntrants(t.id).length, + maxEntrants: t.maxEntrants, + registrationOpen: t.registrationOpen, + })); } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 118167c..c60d19b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,9 @@ export interface Tournament { createdAt: string; checkInClosed: boolean; finalized?: boolean; + startDate?: string; + venue?: string; + city?: string; } export interface Entrant {