From 808c882f947a5032330470941fd460998908978d Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 3 May 2026 14:15:20 -0400 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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 { From fdb081c13537e78c100051c479bfe44b85aafcd4 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 3 May 2026 14:30:14 -0400 Subject: [PATCH 5/7] Implement US8: Player Leaderboard - Add getPointsForPlacement function with tiered points system (1st=100, 2nd=75, 3-4th=50, 5-8th=25, participation=10) - Create getLeaderboard function to aggregate stats across finalized tournaments - Filter by game (required, case-insensitive) - Support player_id query to return specific player's rank - Implement pagination with default page size 20, max 100 - Sort by points descending with tiebreakers (total wins, total tournaments, userId) - Only include finalized tournaments in calculations - Create LeaderboardEntry and LeaderboardResponse interfaces - Create src/routes/leaderboard.ts with GET /api/leaderboard endpoint - Add leaderboard router to app.ts - Add comprehensive test suite with 14 tests - All 148 tests passing --- src/app.ts | 2 + src/leaderboard.test.ts | 397 ++++++++++++++++++++++++++++++++++++++ src/routes/leaderboard.ts | 40 ++++ src/store.ts | 132 +++++++++++++ 4 files changed, 571 insertions(+) create mode 100644 src/leaderboard.test.ts create mode 100644 src/routes/leaderboard.ts diff --git a/src/app.ts b/src/app.ts index 0570744..e396971 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import tournamentRoutes from "./routes/tournaments.js"; import userRoutes from "./routes/users.js"; import replayRoutes from "./routes/replays.js"; import eventRoutes from "./routes/events.js"; +import leaderboardRoutes from "./routes/leaderboard.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -31,6 +32,7 @@ export function createApp() { app.use("/api/replays", replayRoutes); app.use("/api/notifications", notificationRoutes); app.use("/api/events", eventRoutes); + app.use("/api/leaderboard", leaderboardRoutes); const isProd = process.env.NODE_ENV === "production"; if (isProd) { diff --git a/src/leaderboard.test.ts b/src/leaderboard.test.ts new file mode 100644 index 0000000..dbc7723 --- /dev/null +++ b/src/leaderboard.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import { createApp } from "./app.js"; +import { + createUser, + createTournament, + addEntrant, + setMatchResult, + finalizeTournament, + __resetStoreForTests, +} from "./store.js"; +import bcrypt from "bcryptjs"; + +describe("US8: Player Leaderboard", () => { + let app: ReturnType; + let organizerId: string; + let player1Id: string; + let player2Id: string; + let player3Id: string; + let player4Id: 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; + + // Create players + const player1 = createUser({ + username: "player1", + email: "player1@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Player One", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + player1Id = player1.id; + + const player2 = createUser({ + username: "player2", + email: "player2@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Player Two", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + player2Id = player2.id; + + const player3 = createUser({ + username: "player3", + email: "player3@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Player Three", + games: ["Tekken 8"], + region: "Pittsburgh", + role: "player", + }); + player3Id = player3.id; + + const player4 = createUser({ + username: "player4", + email: "player4@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Player Four", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + player4Id = player4.id; + }); + + describe("GET /api/leaderboard - Basic Functionality", () => { + it("should return empty leaderboard when no finalized tournaments exist", async () => { + const response = await request(app).get("/api/leaderboard?game=Street Fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + expect(response.body.total).toBe(0); + }); + + it("should require game parameter", async () => { + const response = await request(app).get("/api/leaderboard"); + + expect(response.status).toBe(400); + expect(response.body.error).toBeDefined(); + }); + + it("should return leaderboard ranked by points descending", async () => { + // Create and finalize tournament with results + const tournament = createTournament({ + name: "SF6 Weekly", + game: "Street Fighter 6", + organizerId, + maxEntrants: 4, + registrationOpen: false, + }); + + // Set match results (player1 = 1st, player2 = 2nd, player4 = 3rd-4th) + setMatchResult(tournament.id, player1Id, { placement: 1, wins: 3, losses: 0 }); + setMatchResult(tournament.id, player2Id, { placement: 2, wins: 2, losses: 1 }); + setMatchResult(tournament.id, player4Id, { placement: 3, wins: 1, losses: 2 }); + + finalizeTournament(tournament.id); + + const response = await request(app).get("/api/leaderboard?game=Street Fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(3); + expect(response.body.data[0].userId).toBe(player1Id); + expect(response.body.data[0].points).toBeGreaterThan(response.body.data[1].points); + expect(response.body.data[1].userId).toBe(player2Id); + expect(response.body.data[1].points).toBeGreaterThan(response.body.data[2].points); + }); + }); + + describe("GET /api/leaderboard - Filtering", () => { + it("should filter leaderboard by game", async () => { + // Create SF6 tournament + const sf6Tournament = createTournament({ + name: "SF6 Weekly", + game: "Street Fighter 6", + organizerId, + maxEntrants: 2, + registrationOpen: false, + }); + setMatchResult(sf6Tournament.id, player1Id, { placement: 1, wins: 1, losses: 0 }); + setMatchResult(sf6Tournament.id, player2Id, { placement: 2, wins: 0, losses: 1 }); + finalizeTournament(sf6Tournament.id); + + // Create Tekken tournament + const tekkenTournament = createTournament({ + name: "Tekken Tuesday", + game: "Tekken 8", + organizerId, + maxEntrants: 2, + registrationOpen: false, + }); + setMatchResult(tekkenTournament.id, player3Id, { placement: 1, wins: 1, losses: 0 }); + finalizeTournament(tekkenTournament.id); + + const sf6Response = await request(app).get("/api/leaderboard?game=Street Fighter 6"); + const tekkenResponse = await request(app).get("/api/leaderboard?game=Tekken 8"); + + expect(sf6Response.status).toBe(200); + expect(sf6Response.body.data).toHaveLength(2); + expect(sf6Response.body.data.every((p: any) => [player1Id, player2Id].includes(p.userId))).toBe(true); + + expect(tekkenResponse.status).toBe(200); + expect(tekkenResponse.body.data).toHaveLength(1); + expect(tekkenResponse.body.data[0].userId).toBe(player3Id); + }); + + it("should be case-insensitive for game filter", async () => { + const tournament = createTournament({ + name: "SF6 Weekly", + game: "Street Fighter 6", + organizerId, + maxEntrants: 2, + registrationOpen: false, + }); + setMatchResult(tournament.id, player1Id, { placement: 1, wins: 1, losses: 0 }); + finalizeTournament(tournament.id); + + const response = await request(app).get("/api/leaderboard?game=street fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + }); + }); + + describe("GET /api/leaderboard - Player Rank Query", () => { + beforeEach(() => { + // Create tournament with multiple players + const tournament = createTournament({ + name: "Big Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: 4, + registrationOpen: false, + }); + + setMatchResult(tournament.id, player1Id, { placement: 1, wins: 3, losses: 0 }); + setMatchResult(tournament.id, player2Id, { placement: 2, wins: 2, losses: 1 }); + setMatchResult(tournament.id, player4Id, { placement: 3, wins: 1, losses: 2 }); + finalizeTournament(tournament.id); + }); + + it("should return player rank when player_id is provided", async () => { + const response = await request(app).get( + `/api/leaderboard?game=Street Fighter 6&player_id=${player2Id}` + ); + + expect(response.status).toBe(200); + expect(response.body.playerRank).toBeDefined(); + expect(response.body.playerRank.rank).toBe(2); + expect(response.body.playerRank.userId).toBe(player2Id); + }); + + it("should return 404 when player_id is not on leaderboard", async () => { + const nonParticipant = createUser({ + username: "newplayer", + email: "new@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "New Player", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + + const response = await request(app).get( + `/api/leaderboard?game=Street Fighter 6&player_id=${nonParticipant.id}` + ); + + expect(response.status).toBe(404); + expect(response.body.error).toContain("not found"); + }); + }); + + describe("GET /api/leaderboard - Pagination", () => { + beforeEach(() => { + // Create tournament with many players + const tournament = createTournament({ + name: "Large Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: null, + registrationOpen: false, + }); + + // Create 25 players with different placements + for (let i = 1; i <= 25; i++) { + const player = createUser({ + username: `player${i + 10}`, + email: `player${i + 10}@test.com`, + passwordHash: bcrypt.hashSync("password123", 10), + displayName: `Player ${i + 10}`, + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + setMatchResult(tournament.id, player.id, { placement: i, wins: 25 - i, losses: i }); + } + + finalizeTournament(tournament.id); + }); + + it("should paginate results with default page size of 20", async () => { + const response = await request(app).get("/api/leaderboard?game=Street Fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(20); + expect(response.body.total).toBe(25); + 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/leaderboard?game=Street Fighter 6&page=2"); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(5); + expect(response.body.page).toBe(2); + }); + + it("should support custom page size", async () => { + const response = await request(app).get("/api/leaderboard?game=Street Fighter 6&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); + }); + }); + + describe("GET /api/leaderboard - Points System", () => { + it("should only include finalized tournaments in points calculation", async () => { + // Finalized tournament + const finalizedTournament = createTournament({ + name: "Finalized Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: 2, + registrationOpen: false, + }); + setMatchResult(finalizedTournament.id, player1Id, { placement: 1, wins: 1, losses: 0 }); + finalizeTournament(finalizedTournament.id); + + // Non-finalized tournament + const ongoingTournament = createTournament({ + name: "Ongoing Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: 2, + registrationOpen: false, + }); + setMatchResult(ongoingTournament.id, player1Id, { placement: 1, wins: 1, losses: 0 }); + // Don't finalize + + const response = await request(app).get("/api/leaderboard?game=Street Fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + // Points should only come from finalized tournament + expect(response.body.data[0].points).toBe(100); // 1st place = 100 points + }); + + it("should aggregate points across multiple tournaments", async () => { + // Tournament 1 + const tournament1 = createTournament({ + name: "Tournament 1", + game: "Street Fighter 6", + organizerId, + maxEntrants: 2, + registrationOpen: false, + }); + setMatchResult(tournament1.id, player1Id, { placement: 1, wins: 1, losses: 0 }); + finalizeTournament(tournament1.id); + + // Tournament 2 + const tournament2 = createTournament({ + name: "Tournament 2", + game: "Street Fighter 6", + organizerId, + maxEntrants: 2, + registrationOpen: false, + }); + setMatchResult(tournament2.id, player1Id, { placement: 2, wins: 0, losses: 1 }); + finalizeTournament(tournament2.id); + + const response = await request(app).get("/api/leaderboard?game=Street Fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data[0].points).toBe(175); // 100 + 75 + }); + }); + + describe("GET /api/leaderboard - Tiebreaker Logic", () => { + it("should use total wins as first tiebreaker", async () => { + const tournament = createTournament({ + name: "Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: null, + registrationOpen: false, + }); + + // Both players get 3rd place (same points) but different wins + setMatchResult(tournament.id, player1Id, { placement: 3, wins: 5, losses: 2 }); + setMatchResult(tournament.id, player2Id, { placement: 3, wins: 3, losses: 2 }); + finalizeTournament(tournament.id); + + const response = await request(app).get("/api/leaderboard?game=Street Fighter 6"); + + expect(response.status).toBe(200); + expect(response.body.data[0].userId).toBe(player1Id); // More wins + expect(response.body.data[1].userId).toBe(player2Id); + }); + }); + + describe("GET /api/leaderboard - Response Format", () => { + it("should include all required fields", async () => { + const tournament = createTournament({ + name: "Tournament", + game: "Street Fighter 6", + organizerId, + maxEntrants: 2, + registrationOpen: false, + }); + setMatchResult(tournament.id, player1Id, { placement: 1, wins: 1, losses: 0 }); + finalizeTournament(tournament.id); + + const response = await request(app).get("/api/leaderboard?game=Street Fighter 6"); + + expect(response.status).toBe(200); + const entry = response.body.data[0]; + expect(entry).toHaveProperty("rank"); + expect(entry).toHaveProperty("userId"); + expect(entry).toHaveProperty("displayName"); + expect(entry).toHaveProperty("points"); + expect(entry).toHaveProperty("totalWins"); + expect(entry).toHaveProperty("totalTournaments"); + }); + }); +}); diff --git a/src/routes/leaderboard.ts b/src/routes/leaderboard.ts new file mode 100644 index 0000000..2a7c819 --- /dev/null +++ b/src/routes/leaderboard.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { getLeaderboard } from "../store.js"; + +const router = Router(); + +/* ------------------------------------------------------------------ */ +/* GET /api/leaderboard – get ranked leaderboard */ +/* ------------------------------------------------------------------ */ +router.get("/", (req, res) => { + const { game, player_id, page, page_size } = req.query; + + if (!game) { + res.status(400).json({ error: "game parameter is required" }); + return; + } + + const leaderboardParams = { + game: game as string, + player_id: player_id as string | undefined, + page: page ? parseInt(page as string, 10) : undefined, + page_size: page_size ? parseInt(page_size as string, 10) : undefined, + }; + + const result = getLeaderboard(leaderboardParams); + + if (!result) { + res.status(400).json({ error: "Invalid leaderboard parameters" }); + return; + } + + // If player_id was requested but not found, return 404 + if (player_id && !result.playerRank) { + res.status(404).json({ error: "Player not found on leaderboard" }); + return; + } + + res.json(result); +}); + +export default router; diff --git a/src/store.ts b/src/store.ts index 868cec7..cd77fcf 100644 --- a/src/store.ts +++ b/src/store.ts @@ -690,4 +690,136 @@ export function searchEvents(params: EventSearchParams): EventResponse[] { maxEntrants: t.maxEntrants, registrationOpen: t.registrationOpen, })); +} + +/* ------------------------------------------------------------------ */ +/* Leaderboard Functions */ +/* ------------------------------------------------------------------ */ + +// Points system based on placement +function getPointsForPlacement(placement: number): number { + if (placement === 1) return 100; + if (placement === 2) return 75; + if (placement <= 4) return 50; + if (placement <= 8) return 25; + return 10; // Participation points +} + +export interface LeaderboardEntry { + rank: number; + userId: string; + displayName: string; + points: number; + totalWins: number; + totalTournaments: number; +} + +export interface LeaderboardResponse { + data: LeaderboardEntry[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + playerRank?: LeaderboardEntry; +} + +export function getLeaderboard(params: { + game: string; + player_id?: string; + page?: number; + page_size?: number; +}): LeaderboardResponse | null { + const { game, player_id, page = 1, page_size = 20 } = params; + + if (!game) return null; + + const gameLower = game.toLowerCase(); + + // Validate pagination + 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) + ); + + // Aggregate player stats across finalized tournaments + const playerStats = new Map< + string, + { points: number; wins: number; tournaments: number; displayName: string } + >(); + + for (const tournament of tournaments.values()) { + if (!tournament.finalized) continue; + if (tournament.game.toLowerCase() !== gameLower) continue; + + const results = matchResults.get(tournament.id); + if (!results) continue; + + for (const [userId, result] of results) { + const user = users.get(userId); + if (!user) continue; + + const stats = playerStats.get(userId) ?? { + points: 0, + wins: 0, + tournaments: 0, + displayName: user.displayName, + }; + + stats.points += getPointsForPlacement(result.placement); + stats.wins += result.wins; + stats.tournaments += 1; + + playerStats.set(userId, stats); + } + } + + // Convert to array and sort + const entries: LeaderboardEntry[] = Array.from(playerStats.entries()) + .map(([userId, stats]) => ({ + rank: 0, // Will be assigned after sorting + userId, + displayName: stats.displayName, + points: stats.points, + totalWins: stats.wins, + totalTournaments: stats.tournaments, + })) + .sort((a, b) => { + // Sort by points descending + if (b.points !== a.points) return b.points - a.points; + // Tiebreaker 1: total wins descending + if (b.totalWins !== a.totalWins) return b.totalWins - a.totalWins; + // Tiebreaker 2: total tournaments descending + if (b.totalTournaments !== a.totalTournaments) return b.totalTournaments - a.totalTournaments; + // Tiebreaker 3: userId alphabetically + return a.userId.localeCompare(b.userId); + }); + + // Assign ranks + entries.forEach((entry, index) => { + entry.rank = index + 1; + }); + + const total = entries.length; + const totalPages = Math.ceil(total / validPageSize); + + // Find player rank if requested + let playerRank: LeaderboardEntry | undefined; + if (player_id) { + playerRank = entries.find((e) => e.userId === player_id); + } + + // Apply pagination + const startIndex = (validPage - 1) * validPageSize; + const endIndex = startIndex + validPageSize; + const paginatedEntries = entries.slice(startIndex, endIndex); + + return { + data: paginatedEntries, + total, + page: validPage, + pageSize: validPageSize, + totalPages, + playerRank, + }; } \ No newline at end of file From ff7020fa2f024e37aef935dc31b1c163e0ac8b79 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 3 May 2026 14:34:24 -0400 Subject: [PATCH 6/7] Implement US9: Follow Players - Added Follow interface to types.ts with followerId, followingId, createdAt - Implemented follow storage with Maps for follows, followersByUser, followingByUser in store.ts - Created store functions: createFollow, deleteFollow, getUserFollowing, getUserFollowers, isFollowing - Added POST /api/follows endpoint to create follow relationships - Added DELETE /api/follows/:id endpoint to remove follows with proper authorization - Added GET /api/users/:id/following endpoint with pagination - Added GET /api/users/:id/followers endpoint with pagination - Prevents self-follows and duplicate follow attempts (409 status) - All endpoints require authentication - All 20 test cases passing - Full test suite: 168 tests passing --- src/app.ts | 2 + src/follow.test.ts | 329 ++++++++++++++++++++++++++++++++++++++++++ src/routes/follows.ts | 81 +++++++++++ src/routes/users.ts | 34 +++++ src/store.ts | 138 ++++++++++++++++++ src/types.ts | 7 + 6 files changed, 591 insertions(+) create mode 100644 src/follow.test.ts create mode 100644 src/routes/follows.ts diff --git a/src/app.ts b/src/app.ts index e396971..a52b57d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,6 +9,7 @@ import userRoutes from "./routes/users.js"; import replayRoutes from "./routes/replays.js"; import eventRoutes from "./routes/events.js"; import leaderboardRoutes from "./routes/leaderboard.js"; +import followRoutes from "./routes/follows.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -33,6 +34,7 @@ export function createApp() { app.use("/api/notifications", notificationRoutes); app.use("/api/events", eventRoutes); app.use("/api/leaderboard", leaderboardRoutes); + app.use("/api/follows", followRoutes); const isProd = process.env.NODE_ENV === "production"; if (isProd) { diff --git a/src/follow.test.ts b/src/follow.test.ts new file mode 100644 index 0000000..4d9bb14 --- /dev/null +++ b/src/follow.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import { createApp } from "./app.js"; +import { + createUser, + __resetStoreForTests, +} from "./store.js"; +import { signToken } from "./auth/token.js"; +import bcrypt from "bcryptjs"; + +describe("US9: Follow Players", () => { + let app: ReturnType; + let player1Token: string; + let player1Id: string; + let player2Token: string; + let player2Id: string; + let player3Id: string; + + beforeEach(() => { + __resetStoreForTests(); + app = createApp(); + + // Create players + const player1 = createUser({ + username: "player1", + email: "player1@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Player One", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "player", + }); + player1Id = player1.id; + player1Token = signToken({ sub: player1Id, role: "player" }); + + const player2 = createUser({ + username: "player2", + email: "player2@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Player Two", + games: ["Tekken 8"], + region: "Pittsburgh", + role: "player", + }); + player2Id = player2.id; + player2Token = signToken({ sub: player2Id, role: "player" }); + + const player3 = createUser({ + username: "player3", + email: "player3@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Player Three", + games: ["Street Fighter 6"], + region: "New York", + role: "player", + }); + player3Id = player3.id; + }); + + describe("POST /api/follows - Create Follow", () => { + it("should create a follow relationship and return 201", async () => { + const response = await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player2Id }); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty("id"); + expect(response.body.followerId).toBe(player1Id); + expect(response.body.followingId).toBe(player2Id); + expect(response.body).toHaveProperty("createdAt"); + }); + + it("should require authentication", async () => { + const response = await request(app) + .post("/api/follows") + .send({ targetUserId: player2Id }); + + expect(response.status).toBe(401); + }); + + it("should return 400 when targetUserId is missing", async () => { + const response = await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({}); + + expect(response.status).toBe(400); + }); + + it("should return 404 when target user does not exist", async () => { + const response = await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: "00000000-0000-0000-0000-000000000000" }); + + expect(response.status).toBe(404); + }); + + it("should return 409 on duplicate follow attempt", async () => { + // First follow + await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player2Id }); + + // Duplicate follow + const response = await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player2Id }); + + expect(response.status).toBe(409); + expect(response.body.error).toContain("already following"); + }); + + it("should not allow following yourself", async () => { + const response = await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player1Id }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain("yourself"); + }); + }); + + describe("DELETE /api/follows/:id - Remove Follow", () => { + let followId: string; + + beforeEach(async () => { + const response = await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player2Id }); + followId = response.body.id; + }); + + it("should remove follow relationship and return 200", async () => { + const response = await request(app) + .delete(`/api/follows/${followId}`) + .set("Authorization", `Bearer ${player1Token}`); + + expect(response.status).toBe(200); + expect(response.body.message).toBeDefined(); + }); + + it("should require authentication", async () => { + const response = await request(app).delete(`/api/follows/${followId}`); + + expect(response.status).toBe(401); + }); + + it("should return 404 when follow does not exist", async () => { + const response = await request(app) + .delete("/api/follows/00000000-0000-0000-0000-000000000000") + .set("Authorization", `Bearer ${player1Token}`); + + expect(response.status).toBe(404); + }); + + it("should only allow the follower to unfollow", async () => { + const response = await request(app) + .delete(`/api/follows/${followId}`) + .set("Authorization", `Bearer ${player2Token}`); // Different user + + expect(response.status).toBe(403); + }); + + it("should verify follow is removed from following list", async () => { + await request(app) + .delete(`/api/follows/${followId}`) + .set("Authorization", `Bearer ${player1Token}`); + + const response = await request(app) + .get(`/api/users/${player1Id}/following`) + .set("Authorization", `Bearer ${player1Token}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(0); + }); + }); + + describe("GET /api/users/:id/following - List Following", () => { + beforeEach(async () => { + // Player 1 follows Player 2 and Player 3 + await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player2Id }); + + await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player3Id }); + }); + + it("should return list of followed users", async () => { + const response = await request(app) + .get(`/api/users/${player1Id}/following`) + .set("Authorization", `Bearer ${player1Token}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(2); + expect(response.body.total).toBe(2); + + const userIds = response.body.data.map((u: any) => u.id); + expect(userIds).toContain(player2Id); + expect(userIds).toContain(player3Id); + }); + + it("should return empty list when not following anyone", async () => { + const response = await request(app) + .get(`/api/users/${player2Id}/following`) + .set("Authorization", `Bearer ${player2Token}`); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + expect(response.body.total).toBe(0); + }); + + it("should include user profile information in results", async () => { + const response = await request(app) + .get(`/api/users/${player1Id}/following`) + .set("Authorization", `Bearer ${player1Token}`); + + expect(response.status).toBe(200); + const user = response.body.data[0]; + expect(user).toHaveProperty("id"); + expect(user).toHaveProperty("displayName"); + expect(user).toHaveProperty("username"); + expect(user).toHaveProperty("games"); + expect(user).toHaveProperty("region"); + }); + + it("should support pagination", async () => { + const response = await request(app) + .get(`/api/users/${player1Id}/following?page_size=1`) + .set("Authorization", `Bearer ${player1Token}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.totalPages).toBe(2); + }); + }); + + describe("GET /api/users/:id/followers - List Followers", () => { + beforeEach(async () => { + // Player 1 and Player 3 follow Player 2 + await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player2Id }); + + const player3Token = signToken({ sub: player3Id, role: "player" }); + await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player3Token}`) + .send({ targetUserId: player2Id }); + }); + + it("should return list of followers", async () => { + const response = await request(app) + .get(`/api/users/${player2Id}/followers`) + .set("Authorization", `Bearer ${player2Token}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(2); + expect(response.body.total).toBe(2); + + const userIds = response.body.data.map((u: any) => u.id); + expect(userIds).toContain(player1Id); + expect(userIds).toContain(player3Id); + }); + + it("should return empty list when no followers", async () => { + const response = await request(app) + .get(`/api/users/${player1Id}/followers`) + .set("Authorization", `Bearer ${player1Token}`); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + expect(response.body.total).toBe(0); + }); + + it("should include user profile information in results", async () => { + const response = await request(app) + .get(`/api/users/${player2Id}/followers`) + .set("Authorization", `Bearer ${player2Token}`); + + expect(response.status).toBe(200); + const user = response.body.data[0]; + expect(user).toHaveProperty("id"); + expect(user).toHaveProperty("displayName"); + expect(user).toHaveProperty("username"); + }); + + it("should support pagination", async () => { + const response = await request(app) + .get(`/api/users/${player2Id}/followers?page_size=1`) + .set("Authorization", `Bearer ${player2Token}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.totalPages).toBe(2); + }); + }); + + describe("Follow Counts", () => { + it("should track correct following and follower counts", async () => { + // Player 1 follows Player 2 + await request(app) + .post("/api/follows") + .set("Authorization", `Bearer ${player1Token}`) + .send({ targetUserId: player2Id }); + + const following = await request(app) + .get(`/api/users/${player1Id}/following`) + .set("Authorization", `Bearer ${player1Token}`); + + const followers = await request(app) + .get(`/api/users/${player2Id}/followers`) + .set("Authorization", `Bearer ${player2Token}`); + + expect(following.body.total).toBe(1); + expect(followers.body.total).toBe(1); + }); + }); +}); diff --git a/src/routes/follows.ts b/src/routes/follows.ts new file mode 100644 index 0000000..a88a386 --- /dev/null +++ b/src/routes/follows.ts @@ -0,0 +1,81 @@ +import { Router } from "express"; +import { z } from "zod"; +import type { AuthedRequest } from "../middleware/auth.js"; +import { requireAuth } from "../middleware/auth.js"; +import { + createFollow, + deleteFollow, + getUserById, + isFollowing, +} from "../store.js"; + +const router = Router(); + +/* ------------------------------------------------------------------ */ +/* POST /api/follows – create follow relationship */ +/* ------------------------------------------------------------------ */ +const createFollowSchema = z.object({ + targetUserId: z.string().uuid(), +}); + +router.post("/", requireAuth, (req: AuthedRequest, res) => { + const parsed = createFollowSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + + const { targetUserId } = parsed.data; + const followerId = req.userId!; + + // Check if trying to follow yourself + if (followerId === targetUserId) { + res.status(400).json({ error: "You cannot follow yourself" }); + return; + } + + // Check if target user exists + const targetUser = getUserById(targetUserId); + if (!targetUser) { + res.status(404).json({ error: "Target user not found" }); + return; + } + + // Check if already following + if (isFollowing(followerId, targetUserId)) { + res.status(409).json({ error: "You are already following this user" }); + return; + } + + const follow = createFollow(followerId, targetUserId); + if (!follow) { + res.status(500).json({ error: "Failed to create follow relationship" }); + return; + } + + res.status(201).json(follow); +}); + +/* ------------------------------------------------------------------ */ +/* DELETE /api/follows/:id – remove follow relationship */ +/* ------------------------------------------------------------------ */ +router.delete("/:id", requireAuth, (req: AuthedRequest, res) => { + const followId = req.params.id; + const requesterId = req.userId!; + + const result = deleteFollow(followId, requesterId); + + if (result === "not_found") { + res.status(404).json({ error: "Follow relationship not found" }); + return; + } + + if (result === "forbidden") { + res.status(403).json({ error: "You can only unfollow your own follows" }); + return; + } + + res.json({ message: "Successfully unfollowed" }); +}); + +export default router; diff --git a/src/routes/users.ts b/src/routes/users.ts index eafb0a1..1a7d857 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -14,6 +14,8 @@ import { softDeleteUser, toPublicUserProfile, updateUserProfile, + getUserFollowing, + getUserFollowers, } from "../store.js"; const router = Router(); @@ -180,4 +182,36 @@ router.delete("/:id", requireAuth, (req: AuthedRequest, res) => { res.status(204).send(); }); +/* ------------------------------------------------------------------ */ +/* GET /api/users/:id/following – list users being followed */ +/* ------------------------------------------------------------------ */ +router.get("/:id/following", requireAuth, (req, res) => { + const userId = req.params.id; + const { page, page_size } = req.query; + + const result = getUserFollowing( + userId, + page ? parseInt(page as string, 10) : undefined, + page_size ? parseInt(page_size as string, 10) : undefined + ); + + res.json(result); +}); + +/* ------------------------------------------------------------------ */ +/* GET /api/users/:id/followers – list followers */ +/* ------------------------------------------------------------------ */ +router.get("/:id/followers", requireAuth, (req, res) => { + const userId = req.params.id; + const { page, page_size } = req.query; + + const result = getUserFollowers( + userId, + page ? parseInt(page as string, 10) : undefined, + page_size ? parseInt(page_size as string, 10) : undefined + ); + + res.json(result); +}); + export default router; diff --git a/src/store.ts b/src/store.ts index cd77fcf..c8a46c4 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,6 +16,7 @@ import type { UserRole, UserStats, Replay, + Follow, } from "./types.js"; const users = new Map(); @@ -28,6 +29,9 @@ const matchResults = new Map>(); const notificationsById = new Map(); const replays = new Map(); const replaysByTournament = new Map(); +const follows = new Map(); +const followersByUser = new Map>(); // userId -> Set of follower IDs +const followingByUser = new Map>(); // userId -> Set of following IDs const matchReadyNotificationKeys = new Set(); function matchReadyKey(tournamentId: string, matchId: string, playerId: string): string { @@ -498,6 +502,9 @@ export function __resetStoreForTests(): void { notificationsById.clear(); matchReadyNotificationKeys.clear(); replays.clear(); + follows.clear(); + followersByUser.clear(); + followingByUser.clear(); replaysByTournament.clear(); } @@ -822,4 +829,135 @@ export function getLeaderboard(params: { totalPages, playerRank, }; +} + +/* ------------------------------------------------------------------ */ +/* Follow Functions */ +/* ------------------------------------------------------------------ */ + +export function createFollow(followerId: string, followingId: string): Follow | null { + // Validate users exist + if (!users.has(followerId) || !users.has(followingId)) { + return null; + } + + // Check if already following + const existingFollows = followingByUser.get(followerId) ?? new Set(); + if (existingFollows.has(followingId)) { + return null; // Already following + } + + const follow: Follow = { + id: randomUUID(), + followerId, + followingId, + createdAt: new Date().toISOString(), + }; + + follows.set(follow.id, follow); + + // Update indexes + const following = followingByUser.get(followerId) ?? new Set(); + following.add(followingId); + followingByUser.set(followerId, following); + + const followers = followersByUser.get(followingId) ?? new Set(); + followers.add(followerId); + followersByUser.set(followingId, followers); + + return follow; +} + +export function deleteFollow(followId: string, requesterId: string): "ok" | "not_found" | "forbidden" { + const follow = follows.get(followId); + if (!follow) return "not_found"; + + // Only the follower can unfollow + if (follow.followerId !== requesterId) return "forbidden"; + + follows.delete(followId); + + // Update indexes + const following = followingByUser.get(follow.followerId); + if (following) { + following.delete(follow.followingId); + } + + const followers = followersByUser.get(follow.followingId); + if (followers) { + followers.delete(follow.followerId); + } + + return "ok"; +} + +export interface PaginatedUsers { + data: PublicUserProfile[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export function getUserFollowing(userId: string, page = 1, page_size = 20): PaginatedUsers { + 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) + ); + + const followingIds = Array.from(followingByUser.get(userId) ?? []); + const followingUsers = followingIds + .map((id) => users.get(id)) + .filter((u): u is User => u !== undefined && !u.deletedAt) + .map((u) => toPublicUserProfile(u)); + + const total = followingUsers.length; + const totalPages = Math.ceil(total / validPageSize); + + const startIndex = (validPage - 1) * validPageSize; + const endIndex = startIndex + validPageSize; + const paginatedUsers = followingUsers.slice(startIndex, endIndex); + + return { + data: paginatedUsers, + total, + page: validPage, + pageSize: validPageSize, + totalPages, + }; +} + +export function getUserFollowers(userId: string, page = 1, page_size = 20): PaginatedUsers { + 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) + ); + + const followerIds = Array.from(followersByUser.get(userId) ?? []); + const followerUsers = followerIds + .map((id) => users.get(id)) + .filter((u): u is User => u !== undefined && !u.deletedAt) + .map((u) => toPublicUserProfile(u)); + + const total = followerUsers.length; + const totalPages = Math.ceil(total / validPageSize); + + const startIndex = (validPage - 1) * validPageSize; + const endIndex = startIndex + validPageSize; + const paginatedUsers = followerUsers.slice(startIndex, endIndex); + + return { + data: paginatedUsers, + total, + page: validPage, + pageSize: validPageSize, + totalPages, + }; +} + +export function isFollowing(followerId: string, followingId: string): boolean { + const following = followingByUser.get(followerId); + return following ? following.has(followingId) : false; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index c60d19b..8de2b47 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,4 +145,11 @@ export interface Replay { videoUrl: string; // simulated URL for now uploadedAt: string; fileSize: number; // in bytes +} + +export interface Follow { + id: string; + followerId: string; // User who is following + followingId: string; // User being followed + createdAt: string; } \ No newline at end of file From 00c2b9cf2f8657a7365d6911df2fcb2aec997cd4 Mon Sep 17 00:00:00 2001 From: Andre Miller Date: Sun, 3 May 2026 14:37:52 -0400 Subject: [PATCH 7/7] Implement US10: Premium Subscription - Added Subscription interface to types.ts with status, priceId, expiryDate, clientSecret - Implemented subscription storage with Maps for subscriptions and subscriptionsByUser in store.ts - Created store functions: createSubscription, getSubscriptionById, getUserSubscription, activateSubscription, cancelSubscription, hasActivePremiumSubscription - Added POST /api/subscriptions endpoint to create subscriptions (organizer only) - Added POST /api/subscriptions/webhook endpoint for Stripe webhooks (payment success/failure, cancellation) - Added GET /api/subscriptions/:userId endpoint to view subscription status - Added DELETE /api/subscriptions/:id endpoint to cancel subscription - Added requirePremium middleware to gate premium features - Added /api/premium/features test endpoint demonstrating premium feature gating - Prevents duplicate active/pending subscriptions (409 status) - Handles subscription expiry automatically - Mock Stripe payment intent format for testing - All 20 test cases passing - Full test suite: 188 tests passing --- src/app.ts | 4 + src/middleware/auth.ts | 23 +- src/routes/premium.ts | 22 ++ src/routes/subscriptions.ts | 130 ++++++++++ src/store.ts | 96 +++++++ src/subscription.test.ts | 484 ++++++++++++++++++++++++++++++++++++ src/types.ts | 12 + 7 files changed, 769 insertions(+), 2 deletions(-) create mode 100644 src/routes/premium.ts create mode 100644 src/routes/subscriptions.ts create mode 100644 src/subscription.test.ts diff --git a/src/app.ts b/src/app.ts index a52b57d..a9ee206 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,8 @@ import replayRoutes from "./routes/replays.js"; import eventRoutes from "./routes/events.js"; import leaderboardRoutes from "./routes/leaderboard.js"; import followRoutes from "./routes/follows.js"; +import subscriptionRoutes from "./routes/subscriptions.js"; +import premiumRoutes from "./routes/premium.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -35,6 +37,8 @@ export function createApp() { app.use("/api/events", eventRoutes); app.use("/api/leaderboard", leaderboardRoutes); app.use("/api/follows", followRoutes); + app.use("/api/subscriptions", subscriptionRoutes); + app.use("/api/premium", premiumRoutes); const isProd = process.env.NODE_ENV === "production"; if (isProd) { diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index f5bbd2f..b82a860 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,6 +1,6 @@ import type { NextFunction, Request, Response } from "express"; import { verifyToken } from "../auth/token.js"; -import { getUserById } from "../store.js"; +import { getUserById, hasActivePremiumSubscription } from "../store.js"; export interface AuthedRequest extends Request { userId?: string; @@ -31,8 +31,27 @@ export function requireAuth(req: AuthedRequest, res: Response, next: NextFunctio export function requireOrganizer(req: AuthedRequest, res: Response, next: NextFunction): void { if (req.userRole !== "organizer") { - res.status(403).json({ error: "Organizer role required" }); + res.status(403).json({ error: "Must be an organizer to access this resource" }); return; } next(); } + +export function requirePremium( + req: AuthedRequest, + res: Response, + next: NextFunction +): void { + const userId = req.userId; + if (!userId) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + if (!hasActivePremiumSubscription(userId)) { + res.status(403).json({ error: "This feature requires an active premium subscription" }); + return; + } + + next(); +} diff --git a/src/routes/premium.ts b/src/routes/premium.ts new file mode 100644 index 0000000..4a40c19 --- /dev/null +++ b/src/routes/premium.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; +import type { AuthedRequest } from "../middleware/auth.js"; +import { requireAuth, requirePremium } from "../middleware/auth.js"; + +const router = Router(); + +/* ------------------------------------------------------------------ */ +/* GET /api/premium/features – test premium feature access */ +/* ------------------------------------------------------------------ */ +router.get("/features", requireAuth, requirePremium, (req: AuthedRequest, res) => { + res.json({ + features: [ + "stream_overlays", + "automated_video_uploads", + "ad_free_pages", + "advanced_analytics", + "custom_branding", + ], + }); +}); + +export default router; diff --git a/src/routes/subscriptions.ts b/src/routes/subscriptions.ts new file mode 100644 index 0000000..9db5344 --- /dev/null +++ b/src/routes/subscriptions.ts @@ -0,0 +1,130 @@ +import { Router } from "express"; +import { z } from "zod"; +import type { AuthedRequest } from "../middleware/auth.js"; +import { requireAuth, requireOrganizer } from "../middleware/auth.js"; +import { + createSubscription, + getUserSubscription, + activateSubscription, + cancelSubscription, + getSubscriptionById, +} from "../store.js"; + +const router = Router(); + +/* ------------------------------------------------------------------ */ +/* POST /api/subscriptions – create subscription */ +/* ------------------------------------------------------------------ */ +const createSubscriptionSchema = z.object({ + priceId: z.string().min(1), +}); + +router.post("/", requireAuth, requireOrganizer, (req: AuthedRequest, res) => { + const parsed = createSubscriptionSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + + const { priceId } = parsed.data; + const userId = req.userId!; + + const subscription = createSubscription(userId, priceId); + if (!subscription) { + res.status(409).json({ error: "You already have an active subscription" }); + return; + } + + res.status(201).json({ + subscriptionId: subscription.id, + clientSecret: subscription.clientSecret, + }); +}); + +/* ------------------------------------------------------------------ */ +/* POST /api/subscriptions/webhook – Stripe webhook */ +/* ------------------------------------------------------------------ */ +router.post("/webhook", (req, res) => { + const { type, data } = req.body; + + if (!type || !data || !data.object) { + res.status(400).json({ error: "Invalid webhook payload" }); + return; + } + + const metadata = data.object.metadata; + if (!metadata || !metadata.subscriptionId) { + res.status(400).json({ error: "Missing subscription metadata" }); + return; + } + + const { subscriptionId, userId, expiryDate } = metadata; + + switch (type) { + case "payment_intent.succeeded": + activateSubscription(subscriptionId, expiryDate); + break; + case "customer.subscription.deleted": + { + const sub = getSubscriptionById(subscriptionId); + if (sub) { + cancelSubscription(subscriptionId, sub.userId); + } + } + break; + case "payment_intent.payment_failed": + // Don't activate subscription on failed payment + break; + } + + res.json({ received: true }); +}); + +/* ------------------------------------------------------------------ */ +/* GET /api/subscriptions/:userId – get subscription status */ +/* ------------------------------------------------------------------ */ +router.get("/:userId", requireAuth, (req: AuthedRequest, res) => { + const userId = req.params.userId; + + const subscription = getUserSubscription(userId); + + if (!subscription) { + res.json({ + userId, + status: "inactive", + }); + return; + } + + res.json({ + userId: subscription.userId, + status: subscription.status, + expiryDate: subscription.expiryDate, + createdAt: subscription.createdAt, + activatedAt: subscription.activatedAt, + }); +}); + +/* ------------------------------------------------------------------ */ +/* DELETE /api/subscriptions/:id – cancel subscription */ +/* ------------------------------------------------------------------ */ +router.delete("/:id", requireAuth, (req: AuthedRequest, res) => { + const subscriptionId = req.params.id; + const requesterId = req.userId!; + + const result = cancelSubscription(subscriptionId, requesterId); + + if (result === "not_found") { + res.status(404).json({ error: "Subscription not found" }); + return; + } + + if (result === "forbidden") { + res.status(403).json({ error: "You can only cancel your own subscription" }); + return; + } + + res.json({ message: "Subscription cancelled successfully" }); +}); + +export default router; diff --git a/src/store.ts b/src/store.ts index c8a46c4..1331be5 100644 --- a/src/store.ts +++ b/src/store.ts @@ -17,6 +17,7 @@ import type { UserStats, Replay, Follow, + Subscription, } from "./types.js"; const users = new Map(); @@ -31,6 +32,8 @@ const replays = new Map(); const replaysByTournament = new Map(); const follows = new Map(); const followersByUser = new Map>(); // userId -> Set of follower IDs +const subscriptions = new Map(); +const subscriptionsByUser = new Map(); // userId -> subscriptionId const followingByUser = new Map>(); // userId -> Set of following IDs const matchReadyNotificationKeys = new Set(); @@ -960,4 +963,97 @@ export function getUserFollowers(userId: string, page = 1, page_size = 20): Pagi export function isFollowing(followerId: string, followingId: string): boolean { const following = followingByUser.get(followerId); return following ? following.has(followingId) : false; +} + +/* ------------------------------------------------------------------ */ +/* Subscription Functions */ +/* ------------------------------------------------------------------ */ + +export function createSubscription(userId: string, priceId: string): Subscription | null { + const user = users.get(userId); + if (!user) return null; + + // Check if user already has active or pending subscription + const existingSubscription = getUserSubscription(userId); + if (existingSubscription && (existingSubscription.status === "active" || existingSubscription.status === "pending")) { + return null; // Already has active or pending subscription + } + + const subscription: Subscription = { + id: randomUUID(), + userId, + priceId, + status: "pending", + createdAt: new Date().toISOString(), + clientSecret: `pi_${randomUUID()}`, // Mock Stripe payment intent format + }; + + subscriptions.set(subscription.id, subscription); + subscriptionsByUser.set(userId, subscription.id); + + return subscription; +} + +export function getSubscriptionById(subscriptionId: string): Subscription | undefined { + return subscriptions.get(subscriptionId); +} + +export function getUserSubscription(userId: string): Subscription | null { + const subscriptionId = subscriptionsByUser.get(userId); + if (!subscriptionId) return null; + + const subscription = subscriptions.get(subscriptionId); + if (!subscription) return null; + + // Check if subscription is expired + if (subscription.status === "active" && subscription.expiryDate) { + if (new Date(subscription.expiryDate) < new Date()) { + subscription.status = "expired"; + } + } + + return subscription; +} + +export function activateSubscription( + subscriptionId: string, + expiryDate?: string +): Subscription | null { + const subscription = subscriptions.get(subscriptionId); + if (!subscription) return null; + + subscription.status = "active"; + subscription.activatedAt = new Date().toISOString(); + + // Default to 30 days from now if no expiry date provided + if (expiryDate) { + subscription.expiryDate = expiryDate; + } else { + const expiry = new Date(); + expiry.setDate(expiry.getDate() + 30); + subscription.expiryDate = expiry.toISOString(); + } + + return subscription; +} + +export function cancelSubscription( + subscriptionId: string, + requesterId: string +): "ok" | "not_found" | "forbidden" { + const subscription = subscriptions.get(subscriptionId); + if (!subscription) return "not_found"; + + if (subscription.userId !== requesterId) return "forbidden"; + + subscription.status = "cancelled"; + subscription.cancelledAt = new Date().toISOString(); + + return "ok"; +} + +export function hasActivePremiumSubscription(userId: string): boolean { + const subscription = getUserSubscription(userId); + if (!subscription) return false; + return subscription.status === "active"; } \ No newline at end of file diff --git a/src/subscription.test.ts b/src/subscription.test.ts new file mode 100644 index 0000000..3e1620f --- /dev/null +++ b/src/subscription.test.ts @@ -0,0 +1,484 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { createApp } from "./app.js"; +import { createUser, __resetStoreForTests } from "./store.js"; +import { signToken } from "./auth/token.js"; +import bcrypt from "bcryptjs"; + +describe("US10: Premium Subscription", () => { + let app: ReturnType; + let organizerToken: string; + let organizerId: string; + let playerToken: string; + let playerId: string; + + beforeEach(() => { + __resetStoreForTests(); + app = createApp(); + + // Create an organizer + const organizer = createUser({ + username: "organizer1", + email: "organizer@test.com", + passwordHash: bcrypt.hashSync("password123", 10), + displayName: "Test Organizer", + games: ["Street Fighter 6"], + region: "Pittsburgh", + role: "organizer", + }); + organizerId = organizer.id; + organizerToken = signToken({ sub: organizerId, role: "organizer" }); + + // Create a 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" }); + }); + + describe("POST /api/subscriptions - Create Subscription", () => { + it("should create a Stripe payment intent and return client secret", async () => { + const response = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty("clientSecret"); + expect(response.body.clientSecret).toMatch(/^pi_/); // Stripe payment intent format + expect(response.body).toHaveProperty("subscriptionId"); + }); + + it("should require authentication", async () => { + const response = await request(app) + .post("/api/subscriptions") + .send({ + priceId: "price_monthly_premium", + }); + + expect(response.status).toBe(401); + }); + + it("should require organizer role", async () => { + const response = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${playerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + expect(response.status).toBe(403); + expect(response.body.error).toContain("organizer"); + }); + + it("should return 400 when priceId is missing", async () => { + const response = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({}); + + expect(response.status).toBe(400); + }); + + it("should return 409 when organizer already has active subscription", async () => { + // Create first subscription + await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + // Try to create second subscription + const response = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + expect(response.status).toBe(409); + expect(response.body.error).toContain("active subscription"); + }); + }); + + describe("POST /api/subscriptions/webhook - Stripe Webhook", () => { + it("should mark subscription active on successful payment", async () => { + // Create subscription + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + const subscriptionId = createResponse.body.subscriptionId; + + // Simulate successful payment webhook + const webhookResponse = await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "payment_intent.succeeded", + data: { + object: { + metadata: { + subscriptionId, + userId: organizerId, + }, + }, + }, + }); + + expect(webhookResponse.status).toBe(200); + + // Verify subscription is active + const statusResponse = await request(app) + .get(`/api/subscriptions/${organizerId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body.status).toBe("active"); + }); + + it("should handle payment failure webhook", async () => { + // Create subscription + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + const subscriptionId = createResponse.body.subscriptionId; + + // Simulate failed payment webhook + const webhookResponse = await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "payment_intent.payment_failed", + data: { + object: { + metadata: { + subscriptionId, + userId: organizerId, + }, + }, + }, + }); + + expect(webhookResponse.status).toBe(200); + + // Verify subscription is not active + const statusResponse = await request(app) + .get(`/api/subscriptions/${organizerId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body.status).not.toBe("active"); + }); + }); + + describe("GET /api/subscriptions/:userId - Get Subscription Status", () => { + it("should return subscription status for organizer", async () => { + const response = await request(app) + .get(`/api/subscriptions/${organizerId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("userId", organizerId); + expect(response.body).toHaveProperty("status"); + expect(["active", "inactive", "expired", "cancelled"]).toContain( + response.body.status + ); + }); + + it("should return inactive status when no subscription exists", async () => { + const response = await request(app) + .get(`/api/subscriptions/${organizerId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("inactive"); + }); + + it("should include expiry date for active subscriptions", async () => { + // Create and activate subscription + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "payment_intent.succeeded", + data: { + object: { + metadata: { + subscriptionId: createResponse.body.subscriptionId, + userId: organizerId, + }, + }, + }, + }); + + const response = await request(app) + .get(`/api/subscriptions/${organizerId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("active"); + expect(response.body).toHaveProperty("expiryDate"); + }); + + it("should require authentication", async () => { + const response = await request(app).get( + `/api/subscriptions/${organizerId}` + ); + + expect(response.status).toBe(401); + }); + + it("should allow organizer to view own subscription", async () => { + const response = await request(app) + .get(`/api/subscriptions/${organizerId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + }); + }); + + describe("Premium Feature Gating", () => { + it("should return 403 for non-subscribers accessing premium features", async () => { + const response = await request(app) + .get("/api/premium/features") + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(403); + expect(response.body.error).toContain("premium"); + }); + + it("should return 200 for active subscribers accessing premium features", async () => { + // Create and activate subscription + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "payment_intent.succeeded", + data: { + object: { + metadata: { + subscriptionId: createResponse.body.subscriptionId, + userId: organizerId, + }, + }, + }, + }); + + const response = await request(app) + .get("/api/premium/features") + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("features"); + }); + }); + + describe("Subscription Cancellation and Expiry", () => { + it("should mark subscription as cancelled on cancellation webhook", async () => { + // Create and activate subscription + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + const subscriptionId = createResponse.body.subscriptionId; + + await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "payment_intent.succeeded", + data: { + object: { + metadata: { + subscriptionId, + userId: organizerId, + }, + }, + }, + }); + + // Simulate cancellation webhook + const webhookResponse = await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "customer.subscription.deleted", + data: { + object: { + metadata: { + subscriptionId, + userId: organizerId, + }, + }, + }, + }); + + expect(webhookResponse.status).toBe(200); + + // Verify subscription is cancelled + const statusResponse = await request(app) + .get(`/api/subscriptions/${organizerId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body.status).toBe("cancelled"); + }); + + it("should correctly handle expired subscriptions", async () => { + // Create subscription with past expiry date + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + // Activate with past expiry + await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "payment_intent.succeeded", + data: { + object: { + metadata: { + subscriptionId: createResponse.body.subscriptionId, + userId: organizerId, + expiryDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Yesterday + }, + }, + }, + }); + + const response = await request(app) + .get(`/api/subscriptions/${organizerId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("expired"); + }); + + it("should block premium features for expired subscriptions", async () => { + // Create subscription with past expiry date + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + // Activate with past expiry + await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "payment_intent.succeeded", + data: { + object: { + metadata: { + subscriptionId: createResponse.body.subscriptionId, + userId: organizerId, + expiryDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + }, + }, + }, + }); + + const response = await request(app) + .get("/api/premium/features") + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(403); + }); + }); + + describe("DELETE /api/subscriptions/:id - Cancel Subscription", () => { + it("should allow organizer to cancel their own subscription", async () => { + // Create and activate subscription + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + const subscriptionId = createResponse.body.subscriptionId; + + await request(app) + .post("/api/subscriptions/webhook") + .send({ + type: "payment_intent.succeeded", + data: { + object: { + metadata: { + subscriptionId, + userId: organizerId, + }, + }, + }, + }); + + // Cancel subscription + const response = await request(app) + .delete(`/api/subscriptions/${subscriptionId}`) + .set("Authorization", `Bearer ${organizerToken}`); + + expect(response.status).toBe(200); + expect(response.body.message).toContain("cancelled"); + }); + + it("should require authentication", async () => { + const response = await request(app).delete( + "/api/subscriptions/test-sub-id" + ); + + expect(response.status).toBe(401); + }); + + it("should only allow owner to cancel subscription", async () => { + // Create subscription for organizer1 + const createResponse = await request(app) + .post("/api/subscriptions") + .set("Authorization", `Bearer ${organizerToken}`) + .send({ + priceId: "price_monthly_premium", + }); + + const subscriptionId = createResponse.body.subscriptionId; + + // Try to cancel with different user + const response = await request(app) + .delete(`/api/subscriptions/${subscriptionId}`) + .set("Authorization", `Bearer ${playerToken}`); + + expect(response.status).toBe(403); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 8de2b47..c46bb2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -152,4 +152,16 @@ export interface Follow { followerId: string; // User who is following followingId: string; // User being followed createdAt: string; +} + +export interface Subscription { + id: string; + userId: string; + priceId: string; + status: "pending" | "active" | "cancelled" | "expired" | "inactive"; + createdAt: string; + activatedAt?: string; + expiryDate?: string; + cancelledAt?: string; + clientSecret?: string; } \ No newline at end of file