diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..f991dea --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,18 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.env.local +.env.*.local +dist +build +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store +coverage +.nyc_output diff --git a/backend/Dockerfile b/backend/Dockerfile index 762ff88..005bc5c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,12 +1,38 @@ -FROM node:18-alpine +# Build stage +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +# Production stage +FROM node:18-alpine WORKDIR /app -COPY package*.json ./ -RUN npm install +# Install dumb-init for proper signal handling +RUN apk add --no-cache dumb-init -COPY . . +# Create non-root user +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 + +# Copy only production dependencies from builder +COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules +COPY --chown=nodejs:nodejs package*.json ./ + +# Copy application code +COPY --chown=nodejs:nodejs . . + +# Remove dev dependencies in production +RUN npm prune --production + +# Switch to non-root user +USER nodejs EXPOSE 3000 -CMD ["node", "src/server.js"] \ No newline at end of file +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" + +# Use dumb-init to handle signals properly +ENTRYPOINT ["dumb-init", "--"] +CMD ["npm", "start"] diff --git a/backend/package-lock.json b/backend/package-lock.json index 551dbde..9606c7e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,8 +19,9 @@ "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.7.0", + "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", - "supertest": "^7.1.0", + "supertest": "^7.2.2", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "typescript": "^6.0.3" @@ -1174,10 +1175,7 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, - "engines": { - "node": ">=14" - } + "peer": true }, "node_modules/@pkgr/core": { "version": "0.2.9", @@ -1436,6 +1434,18 @@ "undici-types": "~7.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -2664,9 +2674,10 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "peer": true, "engines": { - "node": ">=0.3.1" + "node": ">=8" } }, "node_modules/dotenv": { @@ -3303,6 +3314,7 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5677,12 +5689,13 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=8" + "node": ">=14.17" } }, "node_modules/synckit": { diff --git a/backend/package.json b/backend/package.json index ad71f8c..1b87c64 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,15 +28,24 @@ "^.+\\.ts$": ["ts-jest", { "tsconfig": "tsconfig.test.json" }] } }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "transform": { + "^.+\\.ts$": ["ts-jest", { "tsconfig": "tsconfig.test.json" }] + } + }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jest": "^30.0.0", "@types/node": "^25.7.0", "@types/supertest": "^7.2.0", "supertest": "^7.1.0", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "supertest": "^7.2.2" } } diff --git a/backend/src/app.ts b/backend/src/app.ts index 3520120..cb6ddcf 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,11 +1,14 @@ import express, { Request, Response } from 'express' import cors from 'cors' +import eloRoutes from './routes/elo.routes'; const app = express(); app.disable('x-powered-by'); //so express version isn't included in responses app.use(cors({origin: process.env.FRONTEND_URL || 'http://localhost:5173'})); app.use(express.json()); +app.use('/api/elo', eloRoutes); +app.use('/api/elo', eloRoutes); app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok'}); diff --git a/backend/src/config/db.ts b/backend/src/config/db.ts new file mode 100644 index 0000000..95eea55 --- /dev/null +++ b/backend/src/config/db.ts @@ -0,0 +1,19 @@ +import { Pool } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL +}); + +pool.on('connect', () => { + console.log('Connected to the database'); +}); + +pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + process.exit(-1); +}); + +export default pool; \ No newline at end of file diff --git a/backend/src/controllers/elo.controllers.ts b/backend/src/controllers/elo.controllers.ts new file mode 100644 index 0000000..583ac49 --- /dev/null +++ b/backend/src/controllers/elo.controllers.ts @@ -0,0 +1,221 @@ +import { Request, Response } from 'express'; +import pool from '../config/db'; + +// GET /api/elo/:user_id +// Get current elo rating for a user +export const getUserElo = async (req: Request, res: Response): Promise => { + const { user_id } = req.params; + + try { + const result = await pool.query( + `SELECT + e.elo_id, + e.user_id, + u.username, + e.rating, + e.updated_at + FROM elo_ratings e + JOIN users u ON u.user_id = e.user_id + WHERE e.user_id = $1`, + [user_id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ message: 'Elo rating not found for this user' }); + return; + } + + res.status(200).json(result.rows[0]); + + } catch (error) { + console.error('Error fetching elo rating:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// GET /api/elo/:user_id/history +// Get full elo history for a user +export const getEloHistory = async (req: Request, res: Response): Promise => { + const { user_id } = req.params; + + try { + const result = await pool.query( + `SELECT + eh.history_id, + eh.user_id, + eh.match_id, + eh.old_rating, + eh.new_rating, + eh.new_rating - eh.old_rating AS change, + eh.changed_at + FROM elo_history eh + WHERE eh.user_id = $1 + ORDER BY eh.changed_at DESC`, + [user_id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ message: 'No elo history found for this user' }); + return; + } + + res.status(200).json(result.rows); + + } catch (error) { + console.error('Error fetching elo history:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// POST /api/elo/update +// Update elo ratings for both players after a ranked match +// Uses the standard Elo formula +export const updateEloAfterMatch = async (req: Request, res: Response): Promise => { + const { match_id, winner_id, loser_id } = req.body; + + if (!match_id || !winner_id || !loser_id) { + res.status(400).json({ message: 'match_id, winner_id and loser_id are required' }); + return; + } + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Check match exists and is ranked + const matchResult = await client.query( + `SELECT * FROM matches WHERE match_id = $1`, + [match_id] + ); + + if (matchResult.rows.length === 0) { + res.status(404).json({ message: 'Match not found' }); + return; + } + + const match = matchResult.rows[0]; + + if (match.mode !== 'ranked') { + res.status(400).json({ message: 'Elo is only updated for ranked matches' }); + return; + } + + // Get current elo ratings for both players + const winnerEloResult = await client.query( + `SELECT * FROM elo_ratings WHERE user_id = $1`, + [winner_id] + ); + + const loserEloResult = await client.query( + `SELECT * FROM elo_ratings WHERE user_id = $1`, + [loser_id] + ); + + if (winnerEloResult.rows.length === 0 || loserEloResult.rows.length === 0) { + res.status(404).json({ message: 'Elo rating not found for one or both players' }); + return; + } + + const winnerRating = winnerEloResult.rows[0].rating; + const loserRating = loserEloResult.rows[0].rating; + + // Standard Elo formula + const K = 32; // K-factor, how much ratings change per match + const expectedWinner = 1 / (1 + Math.pow(10, (loserRating - winnerRating) / 400)); + const expectedLoser = 1 / (1 + Math.pow(10, (winnerRating - loserRating) / 400)); + + const newWinnerRating = Math.round(winnerRating + K * (1 - expectedWinner)); + const newLoserRating = Math.round(loserRating + K * (0 - expectedLoser)); + + const eloGained = newWinnerRating - winnerRating; + const eloLost = loserRating - newLoserRating; + + // Update winner elo + await client.query( + `UPDATE elo_ratings + SET rating = $1, updated_at = NOW() + WHERE user_id = $2`, + [newWinnerRating, winner_id] + ); + + // Update loser elo + await client.query( + `UPDATE elo_ratings + SET rating = $1, updated_at = NOW() + WHERE user_id = $2`, + [newLoserRating, loser_id] + ); + + // Insert elo history for winner + await client.query( + `INSERT INTO elo_history (user_id, match_id, old_rating, new_rating) + VALUES ($1, $2, $3, $4)`, + [winner_id, match_id, winnerRating, newWinnerRating] + ); + + // Insert elo history for loser + await client.query( + `INSERT INTO elo_history (user_id, match_id, old_rating, new_rating) + VALUES ($1, $2, $3, $4)`, + [loser_id, match_id, loserRating, newLoserRating] + ); + + // Update match log + await client.query( + `INSERT INTO match_log (match_id, winner_id, loser_id, elo_gained, elo_lost) + VALUES ($1, $2, $3, $4, $5)`, + [match_id, winner_id, loser_id, eloGained, eloLost] + ); + + await client.query('COMMIT'); + + res.status(200).json({ + message: 'Elo ratings updated successfully', + winner: { + user_id: winner_id, + old_rating: winnerRating, + new_rating: newWinnerRating, + elo_gained: eloGained + }, + loser: { + user_id: loser_id, + old_rating: loserRating, + new_rating: newLoserRating, + elo_lost: eloLost + } + }); + + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error updating elo ratings:', error); + res.status(500).json({ message: 'Internal server error' }); + } finally { + client.release(); + } +}; + +// GET /api/elo/leaderboard +// Get top 10 players by elo rating +export const getLeaderboard = async (req: Request, res: Response): Promise => { + try { + const result = await pool.query( + `SELECT + u.user_id, + u.username, + e.rating, + e.updated_at, + RANK() OVER (ORDER BY e.rating DESC) AS rank + FROM elo_ratings e + JOIN users u ON u.user_id = e.user_id + ORDER BY e.rating DESC + LIMIT 10` + ); + + res.status(200).json(result.rows); + + } catch (error) { + console.error('Error fetching leaderboard:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; \ No newline at end of file diff --git a/backend/src/controllers/matches.controllers.ts b/backend/src/controllers/matches.controllers.ts new file mode 100644 index 0000000..92b8ea8 --- /dev/null +++ b/backend/src/controllers/matches.controllers.ts @@ -0,0 +1,169 @@ +import { Request, Response } from 'express'; +import pool from '../config/db'; + +//GET api/matches/:optional params +//Returns a paginated list of matches for the authenticated user. Optionally filter by status or game mode. +export const getMatches = async (req: Request, res: Response): Promise => { + const { status, mode, limit = '10', offset = '0' } = req.query; + + + try{ + const conditions: string[] = []; + const values: any[] = []; + + if (status) { + conditions.push(`status = $${values.length + 1}`); + values.push(status); + } + if (mode) { + conditions.push(`mode = $${values.length + 1}`); + values.push(mode); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const result = await pool.query( + `SELECT + match_id, + mode, + status, + queue_start, + match_start + FROM matches + ${where} + LIMIT $${values.length +1} OFFSET $${values.length + 2}`, + [...values, parseInt(limit as string), parseInt(offset as string)] + ); + + res.status(200).json(result.rows[0]); + } catch(error){ + console.error('Error fetching matches:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +//GET api/matches/{match_id} +//Fetches a single match with its problems and players +export const getMatchById = async (req: Request, res: Response): Promise => { + const { match_id } = req.params; + + try { + const result = await pool.query( + `SELECT + m.match_id, m.mode, m.status, m.queue_start, m.match_start, + m.player1_id, m.player2_id, m.match_problems_id + FROM matches m + WHERE m.match_id = $1`, + [match_id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ message: 'Match not found' }); + return; + } + + res.status(200).json(result.rows[0]); + } catch (error) { + console.error('Error fetching match:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +//POST +//Creates a match when two people are connected in the queue +export const createMatch = async (req: Request, res: Response): Promise => { + const { player1_id, player2_id, mode, match_problems_id } = req.body; + + try { + const result = await pool.query( + `INSERT INTO matches (player1_id, player2_id, mode, match_problems_id) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [player1_id, player2_id, mode, match_problems_id] + ); + + res.status(201).json(result.rows[0]); + } catch (error) { + console.error('Error creating match:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +//POST +//Updates the status of a match, sets match_start at the beginning of a match +//N.B this endpoint MUST be called at the starting of a match +export const updateMatchStatus = async (req: Request, res: Response): Promise => { + const { match_id } = req.params; + const { status } = req.body; + + try { + const result = await pool.query( + `UPDATE matches + SET + status = $1, + match_start = CASE WHEN $1 = 'starting' THEN NOW() ELSE match_start END + WHERE match_id = $2 + RETURNING *`, + [status, match_id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ message: 'Match not found' }); + return; + } + + res.status(200).json(result.rows[0]); + } catch (error) { + console.error('Error updating match:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +//GET api/matches/:{match_id}/log +//Returns the history of a completed match +export const getMatchLog = async (req: Request, res: Response): Promise => { + const { match_id } = req.params; + + try { + const result = await pool.query( + `SELECT + ml.log_id, ml.match_id, + ml.winner_id, ml.loser_id, + ml.elo_gained, ml.elo_lost + FROM match_log ml + WHERE ml.match_id = $1`, + [match_id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ message: 'Match log not found' }); + return; + } + + res.status(200).json(result.rows[0]); + } catch (error) { + console.error('Error fetching match log:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +//POST +//Creates a match log for a casual match as there is no elo update +export const createMatchLog = async (req: Request, res: Response): Promise => { + const { match_id, winner_id, loser_id } = req.body; + + try { + // Insert match log + const logResult = await pool.query( + `INSERT INTO match_log (match_id, winner_id, loser_id) + VALUES ($1, $2, $3) + RETURNING *`, + [match_id, winner_id, loser_id] + ); + + + res.status(201).json(logResult.rows[0]); + } catch (error) { + console.error('Error creating match log:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; \ No newline at end of file diff --git a/backend/src/routes/api.routes.ts b/backend/src/routes/api.routes.ts new file mode 100644 index 0000000..c472913 --- /dev/null +++ b/backend/src/routes/api.routes.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { getMatches, getMatchById, createMatch, updateMatchStatus, getMatchLog } from '../controllers/matches.controllers'; +import { + getUserElo, + getEloHistory, + updateEloAfterMatch, + getLeaderboard +} from '../controllers/elo.controllers'; + +const router = Router(); + +// Match routes +router.get('/matches', getMatches); +router.get('/matches/:match_id', getMatchById); +router.post('/matches', createMatch); +router.patch('/matches/:match_id/status', updateMatchStatus); +router.get('/matches/:match_id/log', getMatchLog); + +//elo routes +router.get('/:user_id', getUserElo); +router.get('/:user_id/history', getEloHistory); +router.post('/update', updateEloAfterMatch); +router.get('/leaderboard', getLeaderboard); + +export default router; \ No newline at end of file diff --git a/backend/tests/unit/achievement.unit.test.ts b/backend/tests/unit/achievement.unit.test.ts index b121935..79044e9 100644 --- a/backend/tests/unit/achievement.unit.test.ts +++ b/backend/tests/unit/achievement.unit.test.ts @@ -1,39 +1,79 @@ -import { request, app, expectNotFound, expectValidationError, expectEmptyArray, expectArrayResponse, expectPaginated, expectShape, paginationValidationTests, userSubResourceTests } from '../helpers/test-utils' - -const achievementProps = ['achievement_id', 'name', 'description', 'condition'] +import request from 'supertest' +import app from '../../src/app' describe('Achievements API', () => { describe('GET /achievements', () => { test('returns 200 with full achievement catalog', async () => { const res = await request(app).get('/achievements') expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) expect(res.body.length).toBeGreaterThanOrEqual(1) - res.body.forEach((a: any) => expectShape(a, achievementProps)) + res.body.forEach((a: any) => { + expect(a).toHaveProperty('achievement_id') + expect(a).toHaveProperty('name') + expect(a).toHaveProperty('description') + expect(a).toHaveProperty('condition') + }) }) test('includes "First Blood" achievement in catalog', async () => { const res = await request(app).get('/achievements') expect(res.status).toBe(200) - expect(res.body.map((a: any) => a.name)).toContain('First Blood') + const names = res.body.map((a: any) => a.name) + expect(names).toContain('First Blood') }) - paginationValidationTests('/achievements') + test('returns 200 with limit applied', async () => { + const res = await request(app).get('/achievements?limit=2') + expect(res.status).toBe(200) + expect(res.body.length).toBeLessThanOrEqual(2) + }) + + test('returns 200 with offset applied', async () => { + const res = await request(app).get('/achievements?offset=1') + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + test('returns 200 with limit and offset combined', async () => { + const res = await request(app).get('/achievements?limit=1&offset=1') + expect(res.status).toBe(200) + expect(res.body.length).toBeLessThanOrEqual(1) + }) + + test('returns 400 for negative limit', async () => { + const res = await request(app).get('/achievements?limit=-1') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('returns 400 for non-integer offset', async () => { + const res = await request(app).get('/achievements?offset=abc') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) }) describe('GET /achievements/:achievement_id', () => { test('returns 200 with achievement details', async () => { const res = await request(app).get('/achievements/1') expect(res.status).toBe(200) - expectShape(res.body, achievementProps) - expect(res.body.achievement_id).toBe(1) + expect(res.body).toHaveProperty('achievement_id', 1) + expect(res.body).toHaveProperty('name') + expect(res.body).toHaveProperty('description') + expect(res.body).toHaveProperty('condition') }) test('returns 404 for non-existent achievement', async () => { - expectNotFound(await request(app).get('/achievements/999')) + const res = await request(app).get('/achievements/999') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) test('returns 400 for non-integer achievement_id', async () => { - expectValidationError(await request(app).get('/achievements/abc')) + const res = await request(app).get('/achievements/abc') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) @@ -43,10 +83,13 @@ describe('Achievements API', () => { expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) res.body.forEach((ua: any) => { - expectShape(ua, ['user_achievement_id', 'user_id', 'achievement_id', 'unlocked_at']) + expect(ua).toHaveProperty('user_achievement_id') + expect(ua).toHaveProperty('user_id', 42) + expect(ua).toHaveProperty('achievement_id') + expect(ua).toHaveProperty('unlocked_at') expect(ua).toHaveProperty('achievement') - expect(ua.user_id).toBe(42) - expectShape(ua.achievement, ['name', 'description']) + expect(ua.achievement).toHaveProperty('name') + expect(ua.achievement).toHaveProperty('description') }) }) @@ -60,15 +103,21 @@ describe('Achievements API', () => { }) test('returns 200 with empty array for user with no achievements', async () => { - expectEmptyArray(await request(app).get('/users/1/achievements')) + const res = await request(app).get('/users/1/achievements') + expect(res.status).toBe(200) + expect(res.body).toEqual([]) }) test('returns 404 for non-existent user', async () => { - expectNotFound(await request(app).get('/users/99999/achievements')) + const res = await request(app).get('/users/99999/achievements') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) test('returns 400 for invalid user_id', async () => { - expectValidationError(await request(app).get('/users/abc/achievements')) + const res = await request(app).get('/users/abc/achievements') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) }) diff --git a/backend/tests/unit/elo.unit.test.ts b/backend/tests/unit/elo.unit.test.ts index f5b3f5c..5127201 100644 --- a/backend/tests/unit/elo.unit.test.ts +++ b/backend/tests/unit/elo.unit.test.ts @@ -1,6 +1,5 @@ -import { request, app, expectNotFound, expectValidationError, expectEmptyArray, expectArrayShape } from '../helpers/test-utils' - -const eloProps = ['elo_id', 'user_id', 'game_mode', 'rating', 'updated_at'] +import request from 'supertest' +import app from '../../src/app' describe('ELO Ratings API', () => { describe('GET /users/:user_id/elo', () => { @@ -8,28 +7,38 @@ describe('ELO Ratings API', () => { const res = await request(app).get('/users/42/elo') expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) - expectArrayShape(res.body, eloProps) res.body.forEach((entry: any) => { - expect(entry.user_id).toBe(42) + expect(entry).toHaveProperty('elo_id') + expect(entry).toHaveProperty('user_id', 42) + expect(entry).toHaveProperty('game_mode') + expect(entry).toHaveProperty('rating') + expect(entry).toHaveProperty('updated_at') }) }) test('returns array with multiple game modes if user has played them', async () => { const res = await request(app).get('/users/42/elo') expect(res.status).toBe(200) - expect(res.body.length).toBeGreaterThanOrEqual(1) + const modes = res.body.map((e: any) => e.game_mode) + expect(modes.length).toBeGreaterThanOrEqual(1) }) test('returns 200 with empty array for user with no ELO history', async () => { - expectEmptyArray(await request(app).get('/users/1/elo')) + const res = await request(app).get('/users/1/elo') + expect(res.status).toBe(200) + expect(res.body).toEqual([]) }) test('returns 404 for non-existent user', async () => { - expectNotFound(await request(app).get('/users/99999/elo')) + const res = await request(app).get('/users/99999/elo') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) test('returns 400 for invalid user_id', async () => { - expectValidationError(await request(app).get('/users/abc/elo')) + const res = await request(app).get('/users/abc/elo') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) @@ -58,15 +67,21 @@ describe('ELO Ratings API', () => { }) test('returns 404 for non-existent game mode for user', async () => { - expectNotFound(await request(app).get('/users/42/elo/unknown_mode')) + const res = await request(app).get('/users/42/elo/unknown_mode') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) test('returns 404 for non-existent user', async () => { - expectNotFound(await request(app).get('/users/99999/elo/blitz')) + const res = await request(app).get('/users/99999/elo/blitz') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) test('returns 400 for invalid user_id', async () => { - expectValidationError(await request(app).get('/users/abc/elo/blitz')) + const res = await request(app).get('/users/abc/elo/blitz') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) }) diff --git a/backend/tests/unit/matches.unit.test.ts b/backend/tests/unit/matches.unit.test.ts index 236775d..f28819a 100644 --- a/backend/tests/unit/matches.unit.test.ts +++ b/backend/tests/unit/matches.unit.test.ts @@ -1,107 +1,168 @@ -import { request, app, userAuth, expectNotFound, expectValidationError, expectUnauthorized, expectArrayShape, expectShape, matchActionTests } from '../helpers/test-utils' - -const matchProps = ['match_id', 'player1', 'player2', 'status', 'started_at'] +import request from 'supertest' +import app from '../../src/app' describe('Matches API', () => { describe('GET /matches', () => { - test('returns 200 with list of matches', async () => { - const res = await request(app).get('/matches') + test('returns 200 with paginated match list for authenticated user', async () => { + const res = await request(app) + .get('/matches') + .set('Authorization', 'Bearer valid-jwt') expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) - expectArrayShape(res.body, matchProps) + res.body.forEach((m: any) => { + expect(m).toHaveProperty('match_id') + expect(m).toHaveProperty('game_mode') + expect(m).toHaveProperty('status') + expect(m).toHaveProperty('winner_id') + }) }) - test('returns 200 with match status filter', async () => { - const res = await request(app).get('/matches?status=completed') + test('returns 200 filtered by status', async () => { + const res = await request(app) + .get('/matches?status=completed') + .set('Authorization', 'Bearer valid-jwt') expect(res.status).toBe(200) res.body.forEach((m: any) => { expect(m.status).toBe('completed') }) }) - test('returns 200 with empty array when no matches match filter', async () => { - const res = await request(app).get('/matches?status=unknown_type') + test('returns 200 filtered by game_mode', async () => { + const res = await request(app) + .get('/matches?game_mode=ranked') + .set('Authorization', 'Bearer valid-jwt') expect(res.status).toBe(200) - expect(res.body).toEqual([]) + res.body.forEach((m: any) => { + expect(m.game_mode).toBe('ranked') + }) + }) + + test('returns 200 with limit applied', async () => { + const res = await request(app) + .get('/matches?limit=5') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + expect(res.body.length).toBeLessThanOrEqual(5) + }) + + test('returns 200 with offset applied', async () => { + const res = await request(app) + .get('/matches?offset=10') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + test('returns 401 without auth token', async () => { + const res = await request(app).get('/matches') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + }) + + test('returns 400 for invalid status value', async () => { + const res = await request(app) + .get('/matches?status=invalid_status') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) describe('GET /matches/:match_id', () => { - test('returns 200 with match details', async () => { - const res = await request(app).get('/matches/1') + test('returns 200 with full match details', async () => { + const res = await request(app).get('/matches/301') expect(res.status).toBe(200) - expectShape(res.body, [...matchProps, 'winner', 'duration']) - expect(res.body.match_id).toBe(1) + expect(res.body).toHaveProperty('match_id', 301) + expect(res.body).toHaveProperty('game_mode', 'ranked') + expect(res.body).toHaveProperty('status', 'completed') + expect(res.body).toHaveProperty('winner_id', 42) }) test('returns 404 for non-existent match', async () => { - expectNotFound(await request(app).get('/matches/99999')) + const res = await request(app).get('/matches/999') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) test('returns 400 for non-integer match_id', async () => { - expectValidationError(await request(app).get('/matches/abc')) + const res = await request(app).get('/matches/abc') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('winner_id is null for in-progress matches', async () => { + const res = await request(app).get('/matches/302') + expect(res.status).toBe(200) + expect(res.body.winner_id).toBeNull() }) }) - describe('GET /users/:user_id/matches', () => { - test('returns 200 with match history for user', async () => { - const res = await request(app).get('/users/42/matches') + describe('GET /matches/:match_id/rounds', () => { + test('returns 200 with rounds array for match', async () => { + const res = await request(app).get('/matches/301/rounds') expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) - res.body.forEach((m: any) => { - expect(m.player1 === 42 || m.player2 === 42).toBe(true) + res.body.forEach((r: any) => { + expect(r).toHaveProperty('round_id') + expect(r).toHaveProperty('match_id', 301) + expect(r).toHaveProperty('problem_id') + expect(r).toHaveProperty('start_time') + expect(r).toHaveProperty('end_time') }) }) - test('returns 200 with empty array for user with no matches', async () => { - const res = await request(app).get('/users/1/matches') + test('rounds are ordered by start_time ascending', async () => { + const res = await request(app).get('/matches/301/rounds') expect(res.status).toBe(200) - expect(res.body).toEqual([]) + for (let i = 1; i < res.body.length; i++) { + const prev = new Date(res.body[i - 1].start_time).getTime() + const curr = new Date(res.body[i].start_time).getTime() + expect(curr).toBeGreaterThanOrEqual(prev) + } }) - test('returns 404 for non-existent user', async () => { - expectNotFound(await request(app).get('/users/99999/matches')) + test('returns 200 with empty array for match with no rounds', async () => { + const res = await request(app).get('/matches/303/rounds') + expect(res.status).toBe(200) + expect(res.body).toEqual([]) }) - test('returns 400 for invalid user_id', async () => { - expectValidationError(await request(app).get('/users/abc/matches')) + test('returns 404 for non-existent match', async () => { + const res = await request(app).get('/matches/999/rounds') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) - }) - describe('POST /matches', () => { - test('returns 201 with created match when invited player exists', async () => { - const res = await request(app) - .post('/matches') - .set('Authorization', userAuth) - .send({ invited_player_id: 3, game_mode: 'blitz' }) - expect(res.status).toBe(201) - expect(res.body).toHaveProperty('match_id') - expect(res.body.status).toBe('pending') - expect(res.body.player2).toBe(3) + test('returns 400 for non-integer match_id', async () => { + const res = await request(app).get('/matches/abc/rounds') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) + }) - test('returns 401 without auth', async () => { - expectUnauthorized( - await request(app).post('/matches').send({ invited_player_id: 3, game_mode: 'blitz' }) - ) + describe('GET /rounds/:round_id', () => { + test('returns 200 with full round details', async () => { + const res = await request(app).get('/rounds/7') + expect(res.status).toBe(200) + expect(res.body).toEqual({ + round_id: 7, + match_id: 301, + problem_id: 15, + start_time: '2025-04-15T10:05:00Z', + end_time: '2025-04-15T10:20:00Z', + }) }) - test('returns 400 for missing required fields', async () => { - expectValidationError( - await request(app).post('/matches').set('Authorization', userAuth).send({}) - ) + test('returns 404 for non-existent round', async () => { + const res = await request(app).get('/rounds/999') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) - test('returns 400 for non-existent game_mode', async () => { - expectValidationError( - await request(app) - .post('/matches') - .set('Authorization', userAuth) - .send({ invited_player_id: 3, game_mode: '' }) - ) + test('returns 400 for non-integer round_id', async () => { + const res = await request(app).get('/rounds/abc') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) - - matchActionTests('accept', 'accepted') - matchActionTests('decline', 'declined') }) diff --git a/backend/tests/unit/matchmaking.unit.test.ts b/backend/tests/unit/matchmaking.unit.test.ts index af14f90..bf07e5a 100644 --- a/backend/tests/unit/matchmaking.unit.test.ts +++ b/backend/tests/unit/matchmaking.unit.test.ts @@ -1,101 +1,136 @@ -import { request, app, userAuth, expectNotFound, expectValidationError, expectUnauthorized, expectArrayResponse } from '../helpers/test-utils' - -const queueProps = ['queue_id', 'user_id', 'game_mode', 'joined_at', 'status'] +import request from 'supertest' +import app from '../../src/app' describe('Matchmaking API', () => { - describe('GET /matchmaking/queue', () => { - test('returns 200 with current queue', async () => { - const res = await request(app).get('/matchmaking/queue') - expect(res.status).toBe(200) - expect(Array.isArray(res.body)).toBe(true) - res.body.forEach((entry: any) => expect(entry).toHaveProperty('user_id')) + describe('POST /matchmaking/join', () => { + test('returns 201 when user joins queue with valid game_mode', async () => { + const res = await request(app) + .post('/matchmaking/join') + .set('Authorization', 'Bearer valid-jwt') + .send({ game_mode: 'ranked' }) + expect(res.status).toBe(201) + expect(res.body).toHaveProperty('queue_id') + expect(res.body).toHaveProperty('user_id', 42) + expect(res.body).toHaveProperty('game_mode', 'ranked') + expect(res.body).toHaveProperty('elo_rating') + expect(res.body).toHaveProperty('joined_at') }) - test('returns 200 with status filter', async () => { - const res = await request(app).get('/matchmaking/queue?status=waiting') - expect(res.status).toBe(200) - res.body.forEach((entry: any) => { - expect(entry.status).toBe('waiting') - }) + test('returns 201 for "blitz" game mode', async () => { + const res = await request(app) + .post('/matchmaking/join') + .set('Authorization', 'Bearer valid-jwt') + .send({ game_mode: 'blitz' }) + expect(res.status).toBe(201) + expect(res.body.game_mode).toBe('blitz') }) - test('returns 200 with game_mode filter', async () => { - const res = await request(app).get('/matchmaking/queue?game_mode=blitz') - expect(res.status).toBe(200) - res.body.forEach((entry: any) => { - expect(entry.game_mode).toBe('blitz') - }) + test('returns 201 for "math" game mode', async () => { + const res = await request(app) + .post('/matchmaking/join') + .set('Authorization', 'Bearer valid-jwt') + .send({ game_mode: 'math' }) + expect(res.status).toBe(201) + expect(res.body.game_mode).toBe('math') }) - test('returns 200 with empty array for unknown game_mode', async () => { - const res = await request(app).get('/matchmaking/queue?game_mode=unknown') - expect(res.status).toBe(200) - expect(res.body).toEqual([]) + test('returns 401 without auth token', async () => { + const res = await request(app) + .post('/matchmaking/join') + .send({ game_mode: 'ranked' }) + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) - test('returns 200 with combined filters', async () => { - const res = await request(app).get('/matchmaking/queue?status=waiting&game_mode=blitz') - expect(res.status).toBe(200) - res.body.forEach((entry: any) => { - expect(entry.status).toBe('waiting') - expect(entry.game_mode).toBe('blitz') - }) + test('returns 400 when game_mode is missing', async () => { + const res = await request(app) + .post('/matchmaking/join') + .set('Authorization', 'Bearer valid-jwt') + .send({}) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) - }) - describe('POST /matchmaking/join', () => { - test('returns 201 when user joins queue', async () => { + test('returns 400 for invalid game_mode', async () => { const res = await request(app) .post('/matchmaking/join') - .set('Authorization', userAuth) - .send({ game_mode: 'blitz' }) - expect(res.status).toBe(201) - expect(res.body).toHaveProperty('queue_id') - expect(res.body.status).toBe('waiting') - expect(res.body.user_id).toBe(42) + .set('Authorization', 'Bearer valid-jwt') + .send({ game_mode: '' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) - test('returns 201 with optional elo_range filter', async () => { - const res = await request(app) + test('returns 409 if user is already in queue', async () => { + await request(app) .post('/matchmaking/join') - .set('Authorization', userAuth) - .send({ game_mode: 'ranked', elo_range: { min: 1300, max: 1600 } }) - expect(res.status).toBe(201) - expect(res.body.game_mode).toBe('ranked') + .set('Authorization', 'Bearer valid-jwt') + .send({ game_mode: 'ranked' }) + + const res2 = await request(app) + .post('/matchmaking/join') + .set('Authorization', 'Bearer valid-jwt') + .send({ game_mode: 'ranked' }) + expect(res2.status).toBe(409) + expect(res2.body.error.code).toBe('CONFLICT') }) + }) - test('returns 401 without auth', async () => { - expectUnauthorized( - await request(app).post('/matchmaking/join').send({ game_mode: 'blitz' }) - ) + describe('DELETE /matchmaking/leave', () => { + test('returns 204 when leaving queue while queued', async () => { + await request(app) + .post('/matchmaking/join') + .set('Authorization', 'Bearer valid-jwt') + .send({ game_mode: 'ranked' }) + + const res = await request(app) + .delete('/matchmaking/leave') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(204) }) - test('returns 400 for missing game_mode', async () => { - expectValidationError( - await request(app).post('/matchmaking/join').set('Authorization', userAuth).send({}) - ) + test('returns 204 when not in queue (safe to call)', async () => { + const res = await request(app) + .delete('/matchmaking/leave') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(204) }) - test('returns 400 for empty game_mode', async () => { - expectValidationError( - await request(app) - .post('/matchmaking/join') - .set('Authorization', userAuth) - .send({ game_mode: '' }) - ) + test('returns 401 without auth token', async () => { + const res = await request(app).delete('/matchmaking/leave') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) }) - describe('DELETE /matchmaking/cancel', () => { - test('returns 204 when user cancels queue', async () => { + describe('GET /matchmaking/status', () => { + test('returns 200 with queue entry when user is in queue', async () => { + await request(app) + .post('/matchmaking/join') + .set('Authorization', 'Bearer valid-jwt') + .send({ game_mode: 'ranked' }) + const res = await request(app) - .delete('/matchmaking/cancel') - .set('Authorization', userAuth) - expect(res.status).toBe(204) + .get('/matchmaking/status') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('queue_id') + expect(res.body).toHaveProperty('game_mode', 'ranked') + expect(res.body).toHaveProperty('elo_rating') + expect(res.body).toHaveProperty('joined_at') + }) + + test('returns 404 when user is not in queue', async () => { + const res = await request(app) + .get('/matchmaking/status') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) - test('returns 401 without auth', async () => { - expectUnauthorized(await request(app).delete('/matchmaking/cancel')) + test('returns 401 without auth token', async () => { + const res = await request(app).get('/matchmaking/status') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) }) }) diff --git a/backend/tests/unit/problems.unit.test.ts b/backend/tests/unit/problems.unit.test.ts index a2112a6..6201338 100644 --- a/backend/tests/unit/problems.unit.test.ts +++ b/backend/tests/unit/problems.unit.test.ts @@ -1,211 +1,528 @@ -import { request, app, userAuth, adminAuth, expectNotFound, expectValidationError, expectUnauthorized, expectForbidden, expectConflict, expectArrayResponse, expectShape, paginationValidationTests, adminGuardTests } from '../helpers/test-utils' - -const problemProps = ['problem_id', 'title', 'difficulty', 'problem_type'] +import request from 'supertest' +import app from '../../src/app' describe('Problems API', () => { describe('GET /problems', () => { - test('returns 200 with problem list', async () => { + test('returns 200 with paginated problem list', async () => { const res = await request(app).get('/problems') expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) + res.body.forEach((p: any) => { + expect(p).toHaveProperty('problem_id') + expect(p).toHaveProperty('problem_category') + expect(p).toHaveProperty('difficulty_level') + expect(p).toHaveProperty('title') + expect(p).toHaveProperty('time_limit') + }) }) - test('returns 200 with difficulty filter', async () => { - const res = await request(app).get('/problems?difficulty=medium') + test('returns 200 filtered by problem_category=programming', async () => { + const res = await request(app).get('/problems?problem_category=programming') expect(res.status).toBe(200) res.body.forEach((p: any) => { - expect(p.difficulty).toBe('medium') + expect(p.problem_category).toBe('programming') }) }) - test('returns 200 with problem_type filter', async () => { - const res = await request(app).get('/problems?problem_type=math') + test('returns 200 filtered by problem_category=math', async () => { + const res = await request(app).get('/problems?problem_category=math') expect(res.status).toBe(200) res.body.forEach((p: any) => { - expect(p.problem_type).toBe('math') + expect(p.problem_category).toBe('math') }) }) - test('returns 200 with combined filters', async () => { - const res = await request(app).get('/problems?difficulty=hard&problem_type=algorithm') + test('returns 200 filtered by difficulty_level', async () => { + const res = await request(app).get('/problems?difficulty_level=medium') expect(res.status).toBe(200) res.body.forEach((p: any) => { - expect(p.difficulty).toBe('hard') - expect(p.problem_type).toBe('algorithm') + expect(p.difficulty_level).toBe('medium') }) }) - test('returns 200 with empty array for non-existent difficulty', async () => { - const res = await request(app).get('/problems?difficulty=impossible') + test('returns 200 filtered by category', async () => { + const res = await request(app).get('/problems?category=graphs') expect(res.status).toBe(200) - expect(res.body).toEqual([]) }) - paginationValidationTests('/problems') - }) + test('returns 200 with limit applied', async () => { + const res = await request(app).get('/problems?limit=3') + expect(res.status).toBe(200) + expect(res.body.length).toBeLessThanOrEqual(3) + }) - describe('GET /problems/:problem_id', () => { - test('returns 200 with problem details', async () => { - const res = await request(app).get('/problems/1') + test('returns 200 with offset applied', async () => { + const res = await request(app).get('/problems?offset=5') expect(res.status).toBe(200) - expectShape(res.body, problemProps) - expect(res.body.problem_id).toBe(1) + expect(Array.isArray(res.body)).toBe(true) }) - test('returns 404 for non-existent problem', async () => { - expectNotFound(await request(app).get('/problems/99999')) + test('returns 400 for invalid problem_category', async () => { + const res = await request(app).get('/problems?problem_category=invalid') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) - test('returns 400 for non-integer problem_id', async () => { - expectValidationError(await request(app).get('/problems/abc')) + test('returns 400 for negative limit', async () => { + const res = await request(app).get('/problems?limit=-1') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) - describe('GET /problems/:problem_id/test-cases', () => { - test('returns 200 with test cases for a problem', async () => { - const res = await request(app).get('/problems/1/test-cases') + describe('GET /problems/:problem_id', () => { + test('returns 200 with programming problem details', async () => { + const res = await request(app).get('/problems/15') expect(res.status).toBe(200) - expect(Array.isArray(res.body)).toBe(true) - res.body.forEach((tc: any) => { - expect(tc).toHaveProperty('test_case_id') - expect(tc).toHaveProperty('input') - expect(tc).toHaveProperty('expected_output') - }) + expect(res.body).toHaveProperty('problem_id', 15) + expect(res.body).toHaveProperty('problem_category', 'programming') + expect(res.body).toHaveProperty('difficulty_level', 'medium') + expect(res.body).toHaveProperty('title', 'Shortest Path') + expect(res.body).toHaveProperty('description') + expect(res.body).toHaveProperty('time_limit', 2000) + expect(res.body).toHaveProperty('supported_languages') + expect(Array.isArray(res.body.supported_languages)).toBe(true) }) - test('returns 200 with empty array when problem has no test cases', async () => { - const res = await request(app).get('/problems/99/test-cases') + test('returns 200 with math problem details including solution_formula', async () => { + const res = await request(app).get('/problems/22') expect(res.status).toBe(200) - expect(res.body).toEqual([]) + expect(res.body).toHaveProperty('problem_id', 22) + expect(res.body).toHaveProperty('problem_category', 'math') + expect(res.body).toHaveProperty('difficulty_level', 'hard') + expect(res.body).toHaveProperty('title', 'Integral Bounds') + expect(res.body).toHaveProperty('description') + expect(res.body).toHaveProperty('time_limit', 3000) + expect(res.body).toHaveProperty('solution_formula') + }) + + test('returns 404 for non-existent problem', async () => { + const res = await request(app).get('/problems/999') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + test('returns 400 for non-integer problem_id', async () => { + const res = await request(app).get('/problems/abc') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) describe('POST /problems', () => { - const newProblem = { - title: 'New Challenge', - description: 'Solve this new problem', - difficulty: 'medium', - problem_type: 'algorithm', + const programmingProblem = { + problem_category: 'programming', + title: 'New Problem', + description: 'A new problem description', + difficulty_level: 'easy', + time_limit: 1000, + supported_languages: ['java', 'c++'], } - test('returns 201 when admin creates problem', async () => { + const mathProblem = { + problem_category: 'math', + title: 'New Math Problem', + description: 'A math problem', + difficulty_level: 'medium', + time_limit: 2000, + solution_formula: '42', + } + + test('returns 201 for valid programming problem (admin)', async () => { const res = await request(app) .post('/problems') - .set('Authorization', adminAuth) - .send(newProblem) + .set('Authorization', 'Bearer admin-jwt') + .send(programmingProblem) expect(res.status).toBe(201) expect(res.body).toHaveProperty('problem_id') - expect(res.body.title).toBe('New Challenge') + expect(res.body).toHaveProperty('problem_category', 'programming') + expect(res.body).toHaveProperty('title', 'New Problem') + expect(res.body).toHaveProperty('supported_languages', ['java', 'c++']) + }) + + test('returns 201 for valid math problem (admin)', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer admin-jwt') + .send(mathProblem) + expect(res.status).toBe(201) + expect(res.body).toHaveProperty('problem_category', 'math') + expect(res.body).toHaveProperty('solution_formula', '42') + }) + + test('returns 401 without auth token', async () => { + const res = await request(app) + .post('/problems') + .send(programmingProblem) + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + }) + + test('returns 403 with non-admin token', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer valid-jwt') + .send(programmingProblem) + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + test('returns 400 when title is missing', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer admin-jwt') + .send({ problem_category: 'programming' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('returns 400 when description is missing', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer admin-jwt') + .send({ problem_category: 'programming', title: 'Test' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) - adminGuardTests('post', '/problems', newProblem) + test('returns 400 when difficulty_level is missing', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer admin-jwt') + .send({ + problem_category: 'programming', + title: 'Test', + description: 'Desc', + }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) - test('returns 400 for missing required fields', async () => { - expectValidationError( - await request(app) - .post('/problems') - .set('Authorization', adminAuth) - .send({}) - ) + test('returns 400 when time_limit is missing', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer admin-jwt') + .send({ + problem_category: 'programming', + title: 'Test', + description: 'Desc', + difficulty_level: 'easy', + }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('returns 400 for programming problem missing supported_languages', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer admin-jwt') + .send({ + problem_category: 'programming', + title: 'Test', + description: 'Desc', + difficulty_level: 'easy', + time_limit: 1000, + }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) - test('returns 400 for empty title', async () => { - expectValidationError( - await request(app) - .post('/problems') - .set('Authorization', adminAuth) - .send({ ...newProblem, title: '' }) - ) + test('returns 400 for math problem missing solution_formula', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer admin-jwt') + .send({ + problem_category: 'math', + title: 'Test', + description: 'Desc', + difficulty_level: 'easy', + time_limit: 1000, + }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) - test('returns 409 for duplicate title', async () => { - expectConflict( - await request(app) - .post('/problems') - .set('Authorization', adminAuth) - .send({ ...newProblem, title: 'Duplicate Title' }) - ) + test('returns 400 for invalid problem_category', async () => { + const res = await request(app) + .post('/problems') + .set('Authorization', 'Bearer admin-jwt') + .send({ + problem_category: 'invalid', + title: 'Test', + description: 'Desc', + difficulty_level: 'easy', + time_limit: 1000, + }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) describe('PUT /problems/:problem_id', () => { - const updateData = { title: 'Updated Title', difficulty: 'hard' } + const validUpdate = { title: 'Updated Title', difficulty_level: 'hard' } - test('returns 200 when admin updates problem', async () => { + test('returns 200 for valid partial update (admin)', async () => { const res = await request(app) - .put('/problems/1') - .set('Authorization', adminAuth) - .send(updateData) + .put('/problems/15') + .set('Authorization', 'Bearer admin-jwt') + .send(validUpdate) expect(res.status).toBe(200) - expect(res.body.title).toBe('Updated Title') + expect(res.body).toHaveProperty('problem_id', 15) + expect(res.body).toHaveProperty('title', 'Updated Title') }) - adminGuardTests('put', '/problems/1', updateData) + test('returns 401 without auth token', async () => { + const res = await request(app) + .put('/problems/15') + .send(validUpdate) + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + }) + + test('returns 403 with non-admin token', async () => { + const res = await request(app) + .put('/problems/15') + .set('Authorization', 'Bearer valid-jwt') + .send(validUpdate) + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) test('returns 404 for non-existent problem', async () => { - expectNotFound( - await request(app) - .put('/problems/99999') - .set('Authorization', adminAuth) - .send({ title: 'Ghost' }) - ) - }) - - test('returns 400 for invalid difficulty', async () => { - expectValidationError( - await request(app) - .put('/problems/1') - .set('Authorization', adminAuth) - .send({ difficulty: 'godly' }) - ) - }) - - test('returns 409 for duplicate title after update', async () => { - expectConflict( - await request(app) - .put('/problems/1') - .set('Authorization', adminAuth) - .send({ title: 'Existing Title' }) - ) + const res = await request(app) + .put('/problems/999') + .set('Authorization', 'Bearer admin-jwt') + .send(validUpdate) + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + test('returns 400 when trying to change problem_category', async () => { + const res = await request(app) + .put('/problems/15') + .set('Authorization', 'Bearer admin-jwt') + .send({ problem_category: 'math' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) describe('DELETE /problems/:problem_id', () => { - test('returns 204 when admin deletes problem', async () => { + test('returns 204 when problem has no active rounds (admin)', async () => { const res = await request(app) - .delete('/problems/1') - .set('Authorization', adminAuth) + .delete('/problems/15') + .set('Authorization', 'Bearer admin-jwt') expect(res.status).toBe(204) }) - adminGuardTests('delete', '/problems/1') + test('returns 401 without auth token', async () => { + const res = await request(app).delete('/problems/15') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + }) + + test('returns 403 with non-admin token', async () => { + const res = await request(app) + .delete('/problems/15') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) test('returns 404 for non-existent problem', async () => { - expectNotFound( - await request(app).delete('/problems/99999').set('Authorization', adminAuth) - ) + const res = await request(app) + .delete('/problems/999') + .set('Authorization', 'Bearer admin-jwt') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + test('returns 409 if problem is referenced by active rounds', async () => { + const res = await request(app) + .delete('/problems/1') + .set('Authorization', 'Bearer admin-jwt') + expect(res.status).toBe(409) + expect(res.body.error.code).toBe('CONFLICT') + }) + + test('returns 400 for non-integer problem_id', async () => { + const res = await request(app) + .delete('/problems/abc') + .set('Authorization', 'Bearer admin-jwt') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) - describe('GET /users/:user_id/statistics', () => { - test('returns 200 with user statistics including problems solved', async () => { - const res = await request(app).get('/users/42/statistics') + describe('GET /problems/:problem_id/testcases', () => { + test('returns 200 with test cases array', async () => { + const res = await request(app).get('/problems/15/testcases') expect(res.status).toBe(200) - expect(res.body).toHaveProperty('problems_solved') - expect(res.body).toHaveProperty('matches_played') - expect(res.body).toHaveProperty('win_rate') - expect(typeof res.body.win_rate).toBe('number') + expect(Array.isArray(res.body)).toBe(true) + res.body.forEach((tc: any) => { + expect(tc).toHaveProperty('testcase_id') + expect(tc).toHaveProperty('problem_id', 15) + expect(tc).toHaveProperty('input') + expect(tc).toHaveProperty('expected_output') + }) + }) + + test('returns 404 for non-existent problem', async () => { + const res = await request(app).get('/problems/999/testcases') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + test('returns 400 for non-integer problem_id', async () => { + const res = await request(app).get('/problems/abc/testcases') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + }) + + describe('POST /problems/:problem_id/testcases', () => { + const validBody = { + input: '5\n1 3\n3 5', + expected_output: '2', + } + + test('returns 201 for valid test case (admin)', async () => { + const res = await request(app) + .post('/problems/15/testcases') + .set('Authorization', 'Bearer admin-jwt') + .send(validBody) + expect(res.status).toBe(201) + expect(res.body).toHaveProperty('testcase_id') + expect(res.body).toHaveProperty('problem_id', 15) + expect(res.body).toHaveProperty('input', '5\n1 3\n3 5') + expect(res.body).toHaveProperty('expected_output', '2') + }) + + test('returns 401 without auth token', async () => { + const res = await request(app) + .post('/problems/15/testcases') + .send(validBody) + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + }) + + test('returns 403 with non-admin token', async () => { + const res = await request(app) + .post('/problems/15/testcases') + .set('Authorization', 'Bearer valid-jwt') + .send(validBody) + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + test('returns 400 when input is missing', async () => { + const res = await request(app) + .post('/problems/15/testcases') + .set('Authorization', 'Bearer admin-jwt') + .send({ expected_output: '2' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) - test('returns 200 with zero stats for user with no activity', async () => { - const res = await request(app).get('/users/1/statistics') + test('returns 400 when expected_output is missing', async () => { + const res = await request(app) + .post('/problems/15/testcases') + .set('Authorization', 'Bearer admin-jwt') + .send({ input: '5\n1 3\n3 5' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('returns 404 for non-existent problem', async () => { + const res = await request(app) + .post('/problems/999/testcases') + .set('Authorization', 'Bearer admin-jwt') + .send(validBody) + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + }) + + describe('PUT /testcases/:testcase_id', () => { + const validUpdate = { + input: '3\n1 2\n2 3', + expected_output: '2', + } + + test('returns 200 for valid update (admin)', async () => { + const res = await request(app) + .put('/testcases/92') + .set('Authorization', 'Bearer admin-jwt') + .send(validUpdate) expect(res.status).toBe(200) - expect(res.body.problems_solved).toBe(0) - expect(res.body.matches_played).toBe(0) + expect(res.body).toHaveProperty('testcase_id', 92) + expect(res.body).toHaveProperty('input', '3\n1 2\n2 3') + }) + + test('returns 200 for partial update of just input (admin)', async () => { + const res = await request(app) + .put('/testcases/92') + .set('Authorization', 'Bearer admin-jwt') + .send({ input: '1\n1 1' }) + expect(res.status).toBe(200) + expect(res.body.input).toBe('1\n1 1') + }) + + test('returns 401 without auth token', async () => { + const res = await request(app) + .put('/testcases/92') + .send(validUpdate) + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) - test('returns 404 for non-existent user', async () => { - expectNotFound(await request(app).get('/users/99999/statistics')) + test('returns 403 with non-admin token', async () => { + const res = await request(app) + .put('/testcases/92') + .set('Authorization', 'Bearer valid-jwt') + .send(validUpdate) + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + test('returns 404 for non-existent test case', async () => { + const res = await request(app) + .put('/testcases/999') + .set('Authorization', 'Bearer admin-jwt') + .send(validUpdate) + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + }) + + describe('DELETE /testcases/:testcase_id', () => { + test('returns 204 (admin)', async () => { + const res = await request(app) + .delete('/testcases/92') + .set('Authorization', 'Bearer admin-jwt') + expect(res.status).toBe(204) + }) + + test('returns 401 without auth token', async () => { + const res = await request(app).delete('/testcases/92') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + }) + + test('returns 403 with non-admin token', async () => { + const res = await request(app) + .delete('/testcases/92') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') + }) + + test('returns 404 for non-existent test case', async () => { + const res = await request(app) + .delete('/testcases/999') + .set('Authorization', 'Bearer admin-jwt') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) }) }) diff --git a/backend/tests/unit/submissions.unit.test.ts b/backend/tests/unit/submissions.unit.test.ts index 678242d..ca7b10e 100644 --- a/backend/tests/unit/submissions.unit.test.ts +++ b/backend/tests/unit/submissions.unit.test.ts @@ -1,201 +1,285 @@ -import { request, app, userAuth, expectNotFound, expectValidationError, expectUnauthorized, expectForbidden, expectArrayResponse, expectShape } from '../helpers/test-utils' +import request from 'supertest' +import app from '../../src/app' -const submissionProps = ['submission_id', 'user_id', 'problem_id', 'status', 'submitted_at'] +describe('Submissions API', () => { + describe('POST /submissions', () => { + const validBody = { + round_id: 7, + code: 'def solve(n, edges): pass', + language: 'java', + } -describe('Submissions / Execution Results API', () => { - describe('GET /submissions', () => { - test('returns 200 with all submissions for authenticated user', async () => { + test('returns 201 with pending submission', async () => { const res = await request(app) - .get('/submissions') - .set('Authorization', userAuth) - expect(res.status).toBe(200) - expect(Array.isArray(res.body)).toBe(true) - res.body.forEach((s: any) => { - expect(s.user_id).toBe(42) - }) + .post('/submissions') + .set('Authorization', 'Bearer valid-jwt') + .send(validBody) + expect(res.status).toBe(201) + expect(res.body).toHaveProperty('submission_id', 550) + expect(res.body).toHaveProperty('user_id', 42) + expect(res.body).toHaveProperty('round_id', 7) + expect(res.body).toHaveProperty('language', 'java') + expect(res.body).toHaveProperty('status', 'pending') + expect(res.body).toHaveProperty('submitted_at') }) - test('returns 200 with problem_id filter', async () => { + test('returns 201 with language "c++"', async () => { const res = await request(app) - .get('/submissions?problem_id=1') - .set('Authorization', userAuth) - expect(res.status).toBe(200) - res.body.forEach((s: any) => { - expect(s.problem_id).toBe(1) - }) + .post('/submissions') + .set('Authorization', 'Bearer valid-jwt') + .send({ ...validBody, language: 'c++' }) + expect(res.status).toBe(201) + expect(res.body.language).toBe('c++') }) - test('returns 200 with status filter', async () => { + test('returns 401 without auth token', async () => { const res = await request(app) - .get('/submissions?status=accepted') - .set('Authorization', userAuth) - expect(res.status).toBe(200) - res.body.forEach((s: any) => { - expect(s.status).toBe('accepted') - }) + .post('/submissions') + .send(validBody) + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) - test('returns 200 with combined filters', async () => { + test('returns 400 when round_id is missing', async () => { const res = await request(app) - .get('/submissions?problem_id=1&status=accepted') - .set('Authorization', userAuth) - expect(res.status).toBe(200) - res.body.forEach((s: any) => { - expect(s.problem_id).toBe(1) - expect(s.status).toBe('accepted') - }) + .post('/submissions') + .set('Authorization', 'Bearer valid-jwt') + .send({ code: 'def solve(): pass', language: 'java' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('returns 400 when code is missing', async () => { + const res = await request(app) + .post('/submissions') + .set('Authorization', 'Bearer valid-jwt') + .send({ round_id: 7, language: 'java' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('returns 400 when language is missing', async () => { + const res = await request(app) + .post('/submissions') + .set('Authorization', 'Bearer valid-jwt') + .send({ round_id: 7, code: 'def solve(): pass' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('returns 400 for unsupported language', async () => { + const res = await request(app) + .post('/submissions') + .set('Authorization', 'Bearer valid-jwt') + .send({ round_id: 7, code: 'print(1)', language: 'python' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) - test('returns 401 without auth', async () => { - expectUnauthorized(await request(app).get('/submissions')) + test('returns 400 for empty code', async () => { + const res = await request(app) + .post('/submissions') + .set('Authorization', 'Bearer valid-jwt') + .send({ round_id: 7, code: '', language: 'java' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') + }) + + test('returns 422 for non-existent round', async () => { + const res = await request(app) + .post('/submissions') + .set('Authorization', 'Bearer valid-jwt') + .send({ round_id: 999, code: 'def solve(): pass', language: 'java' }) + expect(res.status).toBe(422) + expect(res.body.error.code).toBe('UNPROCESSABLE') }) }) describe('GET /submissions/:submission_id', () => { - test('returns 200 with submission details', async () => { + test('returns 200 with full submission details', async () => { + const res = await request(app) + .get('/submissions/550') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('submission_id', 550) + expect(res.body).toHaveProperty('user_id', 42) + expect(res.body).toHaveProperty('round_id', 7) + expect(res.body).toHaveProperty('code') + expect(res.body).toHaveProperty('language') + expect(res.body).toHaveProperty('status') + expect(res.body).toHaveProperty('submitted_at') + }) + + test('status can be "pending"', async () => { const res = await request(app) - .get('/submissions/1') - .set('Authorization', userAuth) + .get('/submissions/550') + .set('Authorization', 'Bearer valid-jwt') expect(res.status).toBe(200) - expectShape(res.body, [...submissionProps, 'language', 'code_length']) - expect(res.body.submission_id).toBe(1) + expect(['pending', 'running', 'accepted', 'wrong_answer', 'time_limit_exceeded', 'runtime_error']).toContain(res.body.status) }) - test('returns 403 when submission belongs to different user', async () => { - expectForbidden( - await request(app).get('/submissions/1').set('Authorization', 'Bearer other-user-jwt') - ) + test('status can be "accepted"', async () => { + const res = await request(app) + .get('/submissions/551') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + expect(res.body.status).toBe('accepted') }) - test('returns 401 without auth', async () => { - expectUnauthorized(await request(app).get('/submissions/1')) + test('returns 401 without auth token', async () => { + const res = await request(app).get('/submissions/550') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) - test('returns 404 for non-existent submission', async () => { - expectNotFound( - await request(app).get('/submissions/99999').set('Authorization', userAuth) - ) + test('returns 403 if submission belongs to another user', async () => { + const res = await request(app) + .get('/submissions/550') + .set('Authorization', 'Bearer other-user-jwt') + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') }) - test('returns 400 for non-integer submission_id', async () => { - expectValidationError( - await request(app).get('/submissions/abc').set('Authorization', userAuth) - ) + test('returns 404 for non-existent submission', async () => { + const res = await request(app) + .get('/submissions/999') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) }) - describe('POST /submissions', () => { - const validSubmission = { - problem_id: 1, - language: 'python', - code: 'print("hello")', - } - - test('returns 201 when user submits code', async () => { + describe('GET /users/:user_id/submissions', () => { + test('returns 200 with paginated submission list, newest first', async () => { const res = await request(app) - .post('/submissions') - .set('Authorization', userAuth) - .send(validSubmission) - expect(res.status).toBe(201) - expect(res.body).toHaveProperty('submission_id') - expect(res.body.status).toBe('pending') + .get('/users/42/submissions') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + if (res.body.length > 1) { + const prev = new Date(res.body[0].submitted_at).getTime() + for (let i = 1; i < res.body.length; i++) { + const curr = new Date(res.body[i].submitted_at).getTime() + expect(prev).toBeGreaterThanOrEqual(curr) + } + } }) - test('returns 401 without auth', async () => { - expectUnauthorized( - await request(app).post('/submissions').send(validSubmission) - ) + test('returns 200 filtered by status', async () => { + const res = await request(app) + .get('/users/42/submissions?status=accepted') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + res.body.forEach((s: any) => { + expect(s.status).toBe('accepted') + }) }) - test('returns 400 for missing required fields', async () => { - expectValidationError( - await request(app) - .post('/submissions') - .set('Authorization', userAuth) - .send({}) - ) + test('returns 200 filtered by language', async () => { + const res = await request(app) + .get('/users/42/submissions?language=java') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + res.body.forEach((s: any) => { + expect(s.language).toBe('java') + }) }) - test('returns 400 for empty language field', async () => { - expectValidationError( - await request(app) - .post('/submissions') - .set('Authorization', userAuth) - .send({ ...validSubmission, language: '' }) - ) + test('returns 200 with limit applied', async () => { + const res = await request(app) + .get('/users/42/submissions?limit=5') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(200) + expect(res.body.length).toBeLessThanOrEqual(5) }) - test('returns 400 for non-existent problem_id', async () => { - expectValidationError( - await request(app) - .post('/submissions') - .set('Authorization', userAuth) - .send({ ...validSubmission, problem_id: 99999 }) - ) + test('returns 401 without auth token', async () => { + const res = await request(app).get('/users/42/submissions') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) - test('returns 201 for long code submission', async () => { + test('returns 403 when listing another user submissions', async () => { const res = await request(app) - .post('/submissions') - .set('Authorization', userAuth) - .send({ - problem_id: 1, - language: 'javascript', - code: 'x'.repeat(10000), - }) - expect(res.status).toBe(201) + .get('/users/1/submissions') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') }) - test('returns 400 for unsupported language', async () => { - expectValidationError( - await request(app) - .post('/submissions') - .set('Authorization', userAuth) - .send({ ...validSubmission, language: 'brainfuck' }) - ) + test('returns 404 for non-existent user', async () => { + const res = await request(app) + .get('/users/99999/submissions') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) }) describe('GET /submissions/:submission_id/result', () => { - test('returns 200 with execution result', async () => { + test('returns 200 with execution result when processing is complete', async () => { const res = await request(app) - .get('/submissions/1/result') - .set('Authorization', userAuth) + .get('/submissions/550/result') + .set('Authorization', 'Bearer valid-jwt') expect(res.status).toBe(200) - expectShape(res.body, ['submission_id', 'status', 'passed', 'total', 'execution_time']) + expect(res.body).toHaveProperty('result_id', 210) + expect(res.body).toHaveProperty('submission_id', 550) + expect(res.body).toHaveProperty('passed_cases') + expect(res.body).toHaveProperty('total_cases') + expect(res.body).toHaveProperty('execution_time') + expect(res.body).toHaveProperty('memory_used') }) - test('returns 200 with detailed test case results', async () => { + test('passed_cases and total_cases are integers', async () => { const res = await request(app) - .get('/submissions/1/result') - .set('Authorization', userAuth) - expect(res.status).toBe(200) - if (res.body.test_cases) { - res.body.test_cases.forEach((tc: any) => { - expect(tc).toHaveProperty('test_case_id') - expect(tc).toHaveProperty('passed') - }) - } + .get('/submissions/550/result') + .set('Authorization', 'Bearer valid-jwt') + expect(Number.isInteger(res.body.passed_cases)).toBe(true) + expect(Number.isInteger(res.body.total_cases)).toBe(true) }) - test('returns 403 when result belongs to different user', async () => { - expectForbidden( - await request(app) - .get('/submissions/1/result') - .set('Authorization', 'Bearer other-user-jwt') - ) + test('execution_time is in milliseconds', async () => { + const res = await request(app) + .get('/submissions/550/result') + .set('Authorization', 'Bearer valid-jwt') + expect(res.body.execution_time).toBeGreaterThanOrEqual(0) + }) + + test('memory_used is in bytes', async () => { + const res = await request(app) + .get('/submissions/550/result') + .set('Authorization', 'Bearer valid-jwt') + expect(res.body.memory_used).toBeGreaterThanOrEqual(0) }) - test('returns 401 without auth', async () => { - expectUnauthorized(await request(app).get('/submissions/1/result')) + test('returns 404 if execution not yet completed', async () => { + const res = await request(app) + .get('/submissions/551/result') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') + }) + + test('returns 401 without auth token', async () => { + const res = await request(app).get('/submissions/550/result') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') + }) + + test('returns 403 if submission belongs to another user', async () => { + const res = await request(app) + .get('/submissions/550/result') + .set('Authorization', 'Bearer other-user-jwt') + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') }) test('returns 404 for non-existent submission', async () => { - expectNotFound( - await request(app) - .get('/submissions/99999/result') - .set('Authorization', userAuth) - ) + const res = await request(app) + .get('/submissions/999/result') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) }) }) diff --git a/backend/tests/unit/user.unit.test.ts b/backend/tests/unit/user.unit.test.ts index 056dc83..f576c26 100644 --- a/backend/tests/unit/user.unit.test.ts +++ b/backend/tests/unit/user.unit.test.ts @@ -1,4 +1,5 @@ -import { request, app, userAuth, expectNotFound, expectValidationError, expectUnauthorized, expectForbidden } from '../helpers/test-utils' +import request from 'supertest' +import app from '../../src/app' describe('Users API', () => { describe('GET /users/:user_id', () => { @@ -14,11 +15,15 @@ describe('Users API', () => { }) test('returns 404 for non-existent user', async () => { - expectNotFound(await request(app).get('/users/99999')) + const res = await request(app).get('/users/99999') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) test('returns 400 for non-integer user_id', async () => { - expectValidationError(await request(app).get('/users/abc')) + const res = await request(app).get('/users/abc') + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) }) @@ -28,7 +33,7 @@ describe('Users API', () => { test('returns 200 and updated profile when self-updating with auth', async () => { const res = await request(app) .put('/users/42') - .set('Authorization', userAuth) + .set('Authorization', 'Bearer valid-jwt') .send(validBody) expect(res.status).toBe(200) expect(res.body).toEqual({ @@ -42,7 +47,7 @@ describe('Users API', () => { test('returns 200 with partial update (single field)', async () => { const res = await request(app) .put('/users/42') - .set('Authorization', userAuth) + .set('Authorization', 'Bearer valid-jwt') .send({ username: 'new_name_only' }) expect(res.status).toBe(200) expect(res.body.username).toBe('new_name_only') @@ -50,43 +55,45 @@ describe('Users API', () => { }) test('returns 401 when no auth token provided', async () => { - expectUnauthorized(await request(app).put('/users/42').send(validBody)) + const res = await request(app).put('/users/42').send(validBody) + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) test('returns 403 when updating a different user', async () => { - expectForbidden( - await request(app) - .put('/users/1') - .set('Authorization', userAuth) - .send(validBody) - ) + const res = await request(app) + .put('/users/1') + .set('Authorization', 'Bearer valid-jwt') + .send(validBody) + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') }) test('returns 400 for invalid email format', async () => { - expectValidationError( - await request(app) - .put('/users/42') - .set('Authorization', userAuth) - .send({ email: 'not-an-email' }) - ) + const res = await request(app) + .put('/users/42') + .set('Authorization', 'Bearer valid-jwt') + .send({ email: 'not-an-email' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) test('returns 400 for empty username', async () => { - expectValidationError( - await request(app) - .put('/users/42') - .set('Authorization', userAuth) - .send({ username: '' }) - ) + const res = await request(app) + .put('/users/42') + .set('Authorization', 'Bearer valid-jwt') + .send({ username: '' }) + expect(res.status).toBe(400) + expect(res.body.error.code).toBe('VALIDATION_ERROR') }) test('returns 404 when user does not exist', async () => { - expectNotFound( - await request(app) - .put('/users/99999') - .set('Authorization', userAuth) - .send(validBody) - ) + const res = await request(app) + .put('/users/99999') + .set('Authorization', 'Bearer valid-jwt') + .send(validBody) + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) }) @@ -94,24 +101,30 @@ describe('Users API', () => { test('returns 204 when authorized user deletes own account', async () => { const res = await request(app) .delete('/users/42') - .set('Authorization', userAuth) + .set('Authorization', 'Bearer valid-jwt') expect(res.status).toBe(204) }) test('returns 401 without auth token', async () => { - expectUnauthorized(await request(app).delete('/users/42')) + const res = await request(app).delete('/users/42') + expect(res.status).toBe(401) + expect(res.body.error.code).toBe('UNAUTHORIZED') }) test('returns 403 when deleting another user', async () => { - expectForbidden( - await request(app).delete('/users/1').set('Authorization', userAuth) - ) + const res = await request(app) + .delete('/users/1') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(403) + expect(res.body.error.code).toBe('FORBIDDEN') }) test('returns 404 for non-existent user', async () => { - expectNotFound( - await request(app).delete('/users/99999').set('Authorization', userAuth) - ) + const res = await request(app) + .delete('/users/99999') + .set('Authorization', 'Bearer valid-jwt') + expect(res.status).toBe(404) + expect(res.body.error.code).toBe('NOT_FOUND') }) }) }) diff --git a/database/init.sql b/database/init.sql index bf62d7e..50289f8 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1,4 +1,3 @@ ---very generic tables that can be changed later, just trying not to keep the file empty CREATE TABLE IF NOT EXISTS users ( user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(50) UNIQUE NOT NULL, @@ -8,7 +7,7 @@ CREATE TABLE IF NOT EXISTS users ( ); CREATE TABLE IF NOT EXISTS match_problems( - match_problems_id PRIMARY KEY DEFAULT gen_random_uuid(), + match_problems_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), question1 UUID REFERENCES problems(id) NOT NULL, question2 UUID REFERENCES problems(id) NOT NULL, question3 UUID REFERENCES problems(id) NOT NULL, --every match has a minimum of 3 questions, i.e. difficult mode @@ -24,7 +23,7 @@ CREATE TABLE IF NOT EXISTS matches( mode VARCHAR(10) CHECK (mode IN ('ranked', 'casual')) NOT NULL, queue_start TIMESTAMP DEFAULT NOW() NOT NULL, match_start TIMESTAMP, - status VARCHAR(20) CHECK (status IN ('waiting', 'in_progress', 'completed', 'abandoned')) DEFAULT 'waiting' + status VARCHAR(20) CHECK (status IN ('waiting', 'starting','in_progress', 'completed', 'abandoned')) DEFAULT 'waiting' --TODO check is there a function to set a found match status to starting? ); CREATE TABLE IF NOT EXISTS match_log( @@ -37,11 +36,13 @@ CREATE TABLE IF NOT EXISTS match_log( ); CREATE TYPE problem_category AS ENUM ('math', 'programming'); +CREATE TYPE difficulty_level AS ENUM ('Easy', 'Medium', 'Difficult'); +CREATE TYPE supported_language AS ENUM ('java', 'c++'); CREATE TABLE IF NOT EXISTS problems ( id SERIAL PRIMARY KEY, type problem_category NOT NULL, - difficulty_level ENUM('Easy', 'Medium', 'Difficult') NOT NULL, + type difficulty_level NOT NULL, title VARCHAR(20) NOT NULL, description VARCHAR(40) NOT NULL, time_limit TIME(2) NOT NULL @@ -51,7 +52,7 @@ CREATE TABLE IF NOT EXISTS elo_ratings ( elo_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(user_id), rating INTEGER DEFAULT 600, - updated_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() ); @@ -70,7 +71,7 @@ CREATE TABLE IF NOT EXISTS programming_problems ( id SERIAL PRIMARY KEY, problem_id INT NOT NULL REFERENCES problems(id) ON DELETE CASCADE, --function_signature VARCHAR(25) NOT NULL, - supported_languages ENUM('java', 'c++') NOT NULL, + type supported_languages NOT NULL, CONSTRAINT programming_category_check CHECK ( (SELECT type FROM problems WHERE id = problem_id) = 'programming' ) diff --git a/docs/api-docs(3).html b/docs/api-docs(3).html new file mode 100644 index 0000000..5c27943 --- /dev/null +++ b/docs/api-docs(3).html @@ -0,0 +1,1370 @@ + + + + + +CodeDuels API Reference + + + + + + +
+ + +
+
+

Overview

+

The CodeDuels REST API powers a real-time competitive programming platform where users battle head-to-head on coding and math challenges.

+
+
+
+

Base URL

+

https://api.codeduels.io/v1

+
+
+

Content Type

+

application/json

+
+
+

Authentication

+

Cognito JWT via Amplify SDK

+
+
+

Rate Limit

+

300 req / min per user

+
+
+
Standard response codes
+
+ 200 OK + 201 Created + 204 No Content + 400 Bad Request + 401 Unauthorized + 403 Forbidden + 404 Not Found + 409 Conflict + 422 Unprocessable + 500 Server Error +
+
+ +
+ + +
+
+

Auth Flow

+

Authentication is handled entirely by AWS Amplify + Cognito on the client. This backend has no auth endpoints — it only validates the JWT Cognito issues.

+
+ +
+
+

What Amplify handles (client-side only)

+

+ Sign up · Confirm email · Sign in · Sign out · Token refresh · Forgot password · MFA +

+

All via @aws-amplify/auth SDK. Your backend never sees these calls.

+
+
+

What every protected request looks like

+
+
// Amplify fetches & auto-refreshes the token
+const { tokens } = await fetchAuthSession();
+const jwt = tokens.idToken.toString();
+
+fetch('/v1/matches', {
+  headers: {
+    'Authorization': `Bearer ${jwt}`
+  }
+});
+
+
+
+ +
+
How your backend validates the token
+
+
+

1 — Receive request

+

Extract the Authorization: Bearer <token> header on every protected route.

+
+
+

2 — Verify JWT

+

Validate signature against your Cognito User Pool's JWKS endpoint. Reject if expired or tampered.

+
+
+

3 — Resolve user

+

Read the sub claim from the token. Look up the matching row in your User table by that value.

+
+
+

+ JWKS URL pattern: https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json +

+
+ +
+
User record creation on signup
+

+ Because registration happens in Cognito, your User table is populated via a Cognito post-confirmation Lambda trigger — not an API call. When Cognito confirms a new user, it fires the trigger, which inserts a row into your database using the sub as the primary key. The username and email are passed as trigger event attributes. +

+
+
+ +
+ + +
+
+

Users

+

Manage user accounts and retrieve profile data. Auth attributes (email, password) live in Cognito — your User table holds game-specific data only. The user_id field corresponds to the Cognito sub claim.

+
+ +
+
+ GET + /users/{user_id} + Get user profile +
+
+

Returns public profile details for the specified user, including username, join date and stats.

+
+
+
Path parameters
+ + + +
ParamType
user_idintegerrequired
+
+
+
Response 200
+
+
json
+
{
+  "user_id": 42,
+  "username": "striker99",
+  "email": "user@example.com",
+  "created_at": "2025-01-15T08:30:00Z"
+}
+
+
+
+
+
+ +
+
+ PUT + /users/{user_id} + Update user profile + Auth +
+
+

Updates the authenticated user's profile. Users may only update their own account.

+
+
+
Request body (partial)
+ + + + + +
FieldType
usernamestringoptional
emailstringoptional
passwordstringoptional
+
+
+
Response 200
+
+
json
+
{
+  "user_id": 42,
+  "username": "striker_v2",
+  "email": "new@example.com",
+  "created_at": "2025-01-15T08:30:00Z"
+}
+
+
+
+
+
+ +
+
+ DELETE + /users/{user_id} + Delete user account + Auth +
+
+

Permanently deletes the authenticated user's account and all associated data. Returns 204 No Content. This action is irreversible.

+
+
+
+ +
+ + +
+
+

ELO Ratings

+

Read per-user ELO ratings across different game modes. Ratings are updated automatically after each match.

+
+ +
+
+ GET + /users/{user_id}/elo + Get all ELO ratings +
+
+

Returns an array of ELO ratings for the user across all game modes they have played.

+
+
+
Path parameters
+ + + +
ParamType
user_idintegerrequired
+
+
+
Response 200
+
+
json
+
[
+  {
+    "elo_id": 5,
+    "user_id": 42,
+    "game_mode": "blitz",
+    "rating": 1420,
+    "updated_at": "2025-04-10T18:00:00Z"
+  }
+]
+
+
+
+
+
+ +
+
+ GET + /users/{user_id}/elo/{game_mode} + Get ELO for game mode +
+
+

Returns the ELO rating for a specific game mode (e.g. blitz, ranked, math).

+
+
Response 200
+
+
json
+
{
+  "elo_id": 5,
+  "user_id": 42,
+  "game_mode": "blitz",
+  "rating": 1420,
+  "updated_at": "2025-04-10T18:00:00Z"
+}
+
+
+
+
+
+ +
+ + +
+
+

Achievements

+

Browse the global achievement catalog and track user progress.

+
+ +
+
+ GET + /achievements + List all achievements +
+
+

Returns the complete catalog of achievements available on the platform.

+
+
+
Query parameters
+ + + + +
ParamType
limitintegeroptional
offsetintegeroptional
+
+
+
Response 200
+
+
json
+
[
+  {
+    "achievement_id": 1,
+    "name": "First Blood",
+    "description": "Win your first match",
+    "condition": "matches_won >= 1"
+  }
+]
+
+
+
+
+
+ +
+
+ GET + /achievements/{achievement_id} + Get achievement details +
+
+

Returns details for a single achievement including its unlock condition.

+
+
+ +
+
+ GET + /users/{user_id}/achievements + Get user's achievements +
+
+

Returns a list of achievements unlocked by the given user, including unlock timestamps.

+
+
Response 200
+
+
json
+
[
+  {
+    "user_achievement_id": 12,
+    "user_id": 42,
+    "achievement_id": 1,
+    "unlocked_at": "2025-02-20T09:15:00Z",
+    "achievement": {
+      "name": "First Blood",
+      "description": "Win your first match"
+    }
+  }
+]
+
+
+
+
+
+ +
+ + +
+
+

Matchmaking

+

Join the ELO-based matchmaking queue. When two compatible opponents are found, a match is automatically created.

+
+ +
+
+ POST + /matchmaking/join + Join matchmaking queue + Auth +
+
+

Adds the authenticated user to the matchmaking queue for the specified game mode. The user's current ELO for that mode is read and stored in the queue entry.

+
+
+
Request body
+ + + +
FieldType
game_modestringrequired
+
+
+
Response 201
+
+
json
+
{
+  "queue_id": 88,
+  "user_id": 42,
+  "game_mode": "ranked",
+  "elo_rating": 1420,
+  "joined_at": "2025-04-15T10:00:00Z"
+}
+
+
+
+
+
+ +
+
+ DELETE + /matchmaking/leave + Leave queue + Auth +
+
+

Removes the authenticated user from the active matchmaking queue. Returns 204 No Content. Safe to call even if not currently in queue.

+
+
+ +
+
+ GET + /matchmaking/status + Get queue status + Auth +
+
+

Returns the current queue entry for the authenticated user, or 404 if not in queue.

+
+
Response 200
+
+
json
+
{
+  "queue_id": 88,
+  "game_mode": "ranked",
+  "elo_rating": 1420,
+  "joined_at": "2025-04-15T10:00:00Z"
+}
+
+
+
+
+
+ +
+ + +
+
+

Matches

+

Matches are created by the matchmaking system. Each match has a game mode, status, winner, and one or more rounds.

+
+ +
+
+ GET + /matches + List matches + Auth +
+
+

Returns a paginated list of matches for the authenticated user. Optionally filter by status or game mode.

+
+
+
Query parameters
+ + + + + + +
ParamType
statusstringoptional
game_modestringoptional
limitintegeroptional
offsetintegeroptional
+
+
+
Response 200
+
+
json
+
[
+  {
+    "match_id": 301,
+    "game_mode": "ranked",
+    "status": "completed",
+    "winner_id": 42
+  }
+]
+
+
+
+
+
+ +
+
+ GET + /matches/{match_id} + Get match details +
+
+

Returns full details for a single match including its current status and winner.

+
+
Response 200
+
+
json
+
{
+  "match_id": 301,
+  "game_mode": "ranked",
+  "status": "completed",
+  "winner_id": 42
+}
+
+
+
+
+ +
+
+ GET + /matches/{match_id}/rounds + Get rounds for a match +
+
+

Returns all rounds belonging to a match, ordered by round start time.

+
+
Response 200
+
+
json
+
[
+  {
+    "round_id": 7,
+    "match_id": 301,
+    "problem_id": 15,
+    "start_time": "2025-04-15T10:05:00Z",
+    "end_time": "2025-04-15T10:20:00Z"
+  }
+]
+
+
+
+
+
+ +
+ + +
+
+

Rounds

+

A round ties together a match, a problem, and a time window. Each round records when it started and ended.

+
+ +
+
+ GET + /rounds/{round_id} + Get round details +
+
+

Returns details for a single round including the associated problem and timing data.

+
+
Response 200
+
+
json
+
{
+  "round_id": 7,
+  "match_id": 301,
+  "problem_id": 15,
+  "start_time": "2025-04-15T10:05:00Z",
+  "end_time": "2025-04-15T10:20:00Z"
+}
+
+
+
+
+
+ +
+ + +
+
+

Problems

+

problems is a virtual base type — it is never stored directly. A problem_category enum (math | programming) determines which concrete table the record lives in: math_problems or programming_problems. The API resolves the correct table transparently and returns a unified shape.

+
+ +
+
+ GET + /problems + List problems +
+
+

Returns a paginated list of problems across both concrete tables. Filter by problem_category, difficulty, or topic category.

+
+
+
Query parameters
+ + + + + + + +
ParamType
problem_categoryenummath | programming
difficulty_levelstringoptional
categorystringoptional
limitintegeroptional
offsetintegeroptional
+
+
+
Response 200
+
+
json
+
[
+  {
+    "problem_id": 15,
+    "problem_category": "programming",
+    "difficulty_level": "medium",
+    "title": "Shortest Path",
+    "time_limit": 2000
+  }
+]
+
+
+
+
+
+ +
+
+ GET + /problems/{problem_id} + Get problem details +
+
+

Returns the full problem. The shape differs based on problem_categoryprogramming_problems include supported_languages; math_problems include solution_formula.

+
+
+
Response 200 — programming_problems
+
+
json
+
{
+  "problem_id": 15,
+  "problem_category": "programming",
+  "difficulty_level": "medium",
+  "title": "Shortest Path",
+  "description": "Given a directed graph...",
+  "time_limit": 2000,
+  "supported_languages": ["java", "c++"]
+}
+
+
+
+
Response 200 — math_problems
+
+
json
+
{
+  "problem_id": 22,
+  "problem_category": "math",
+  "difficulty_level": "hard",
+  "title": "Integral Bounds",
+  "description": "Evaluate the integral...",
+  "time_limit": 3000,
+  "solution_formula": "1/3"
+}
+
+
+
+
+
+ +
+
+ POST + /problems + Create a problem + Admin +
+
+

Creates a new problem. The value of problem_category determines which concrete table the record is inserted into. Provide the corresponding type-specific fields accordingly.

+
+
+
Request body — shared fields
+ + + + + + + +
FieldType
problem_categoryenummath | programming
titlestringrequired
descriptionstringrequired
difficulty_levelstringrequired
time_limitintegerrequired
+
programming_problems only
+ + + +
FieldType
supported_languagesenum[]java | c++
+
math_problems only
+ + + +
FieldType
solution_formulastringrequired
+
+
+
Response 201 — programming_problems
+
+
json
+
{
+  "problem_id": 15,
+  "problem_category": "programming",
+  "title": "Shortest Path",
+  "difficulty_level": "medium",
+  "description": "Given a directed graph...",
+  "time_limit": 2000,
+  "supported_languages": ["java", "c++"]
+}
+
+
Response 201 — math_problems
+
+
json
+
{
+  "problem_id": 22,
+  "problem_category": "math",
+  "title": "Integral Bounds",
+  "difficulty_level": "hard",
+  "description": "Evaluate the integral...",
+  "time_limit": 3000,
+  "solution_formula": "1/3"
+}
+
+
+
+
+
+ +
+
+ PUT + /problems/{problem_id} + Update a problem + Admin +
+
+

Updates fields on an existing problem. Partial updates are supported. problem_category cannot be changed after creation.

+
+
+ +
+
+ DELETE + /problems/{problem_id} + Delete a problem + Admin +
+
+

Permanently deletes the problem and all associated test cases. Returns 204 No Content. Will fail with 409 if the problem is referenced by active rounds.

+
+
+
+ +
+ + +
+
+

Test Cases

+

Each problem has one or more test cases. Test case inputs and expected outputs are used by the execution engine to grade submissions.

+
+ +
+
+ GET + /problems/{problem_id}/testcases + List test cases +
+
+

Returns all test cases for the given problem. expected_output may be redacted for non-admin callers.

+
+
Response 200
+
+
json
+
[
+  {
+    "testcase_id": 91,
+    "problem_id": 15,
+    "input": "4\n1 2\n2 3\n3 4",
+    "expected_output": "3"
+  }
+]
+
+
+
+
+ +
+
+ POST + /problems/{problem_id}/testcases + Add a test case + Admin +
+
+

Creates a new test case for the problem. Both input and expected_output are required.

+
+
+
Request body
+ + + + +
FieldType
inputstringrequired
expected_outputstringrequired
+
+
+
Response 201
+
+
json
+
{
+  "testcase_id": 92,
+  "problem_id": 15,
+  "input": "5\n1 3\n3 5",
+  "expected_output": "2"
+}
+
+
+
+
+
+ +
+
+ PUT + /testcases/{testcase_id} + Update a test case + Admin +
+
+

Updates input or expected_output on an existing test case.

+
+
+ +
+
+ DELETE + /testcases/{testcase_id} + Delete a test case + Admin +
+
+

Permanently removes the test case. Returns 204 No Content.

+
+
+
+ +
+ + +
+
+

Submissions

+

Users submit code or answers during a round. Each submission is immediately queued for execution and an ExecutionResult is linked once complete.

+
+ +
+
+ POST + /submissions + Submit a solution + Auth +
+
+

Submits a solution to a problem within an active round. The submission is queued for asynchronous execution. Poll /submissions/{id}/result for the execution outcome.

+
+
+
Request body
+ + + + + +
FieldType
round_idintegerrequired
codestringrequired
languageenumjava | c++
+
+
+
Response 201
+
+
json
+
{
+  "submission_id": 550,
+  "user_id": 42,
+  "round_id": 7,
+  "language": "java",
+  "status": "pending",
+  "submitted_at": "2025-04-15T10:10:00Z"
+}
+
+
+
+
+
+ +
+
+ GET + /submissions/{submission_id} + Get submission details + Auth +
+
+

Returns the submission object. status will be one of pending, running, accepted, wrong_answer, time_limit_exceeded, or runtime_error.

+
+
Response 200
+
+
json
+
{
+  "submission_id": 550,
+  "user_id": 42,
+  "round_id": 7,
+  "code": "def solve(n, edges):...",
+  "language": "python",
+  "status": "accepted",
+  "submitted_at": "2025-04-15T10:10:00Z"
+}
+
+
+
+
+ +
+
+ GET + /users/{user_id}/submissions + List user submissions + Auth +
+
+

Returns a paginated list of all submissions by the specified user, newest first.

+
+
Query parameters
+ + + + + + +
ParamType
statusstringoptional
languagestringoptional
limitintegeroptional
offsetintegeroptional
+
+
+
+
+ +
+ + +
+
+

Execution Results

+

After a submission is processed, an ExecutionResult is created with pass/fail counts, execution time, and memory usage.

+
+ +
+
+ GET + /submissions/{submission_id}/result + Get execution result + Auth +
+
+

Returns the execution result for a submission. Returns 404 if execution has not yet completed — poll until available.

+
+
Response 200
+
+
json
+
{
+  "result_id": 210,
+  "submission_id": 550,
+  "passed_cases": 8,
+  "total_cases": 10,
+  "execution_time": 134,
+  "memory_used": 20480
+}
+
+
+
+
+
+ +
+ + +
+
+

Error Codes

+

All errors return a consistent JSON body with a machine-readable code field.

+
+
+
+
Error envelope
+
{
+  "error": {
+    "code": "VALIDATION_ERROR",
+    "message": "email is not a valid email address",
+    "field": "email"
+  }
+}
+
+
+
+ + + + + + + + + + +
HTTPCodeDescription
400VALIDATION_ERRORRequest body failed validation
401UNAUTHORIZEDMissing or invalid bearer token
403FORBIDDENToken valid but lacks permission
404NOT_FOUNDResource does not exist
409CONFLICTResource state conflict (e.g. duplicate username)
422UNPROCESSABLESemantically invalid request
429RATE_LIMITEDToo many requests — back off and retry
500INTERNAL_ERRORUnexpected server error
+
+ +
+ + +
+
+

Data Models

+

Quick reference for all entity shapes returned by the API.

+
+ +
+ +
+

User

+
+
user_id: integer
+username: string
+email: string
+created_at: datetime
+
+
+ +
+

EloRating

+
+
elo_id: integer
+user_id: integer
+game_mode: string
+rating: integer
+updated_at: datetime
+
+
+ +
+

Match

+
+
match_id: integer
+game_mode: string
+status: string
+winner_id: integer | null
+
+
+ +
+

Round

+
+
round_id: integer
+match_id: integer
+problem_id: integer
+start_time: datetime
+end_time: datetime
+
+
+ +
+

Problem (base)

+
+
problem_id: integer
+type: string
+difficulty_level: string
+title: string
+description: string
+time_limit: integer (ms)
+
+
+ +
+

Submission

+
+
submission_id: integer
+user_id: integer
+round_id: integer
+code: string
+language: string
+status: string
+submitted_at: datetime
+
+
+ +
+

ExecutionResult

+
+
result_id: integer
+submission_id: integer
+passed_cases: integer
+total_cases: integer
+execution_time: integer (ms)
+memory_used: integer (bytes)
+
+
+ +
+

Achievement

+
+
achievement_id: integer
+name: string
+description: string
+condition: string
+
+
+ +
+
+ +
+ + + + diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f991dea --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,18 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.env.local +.env.*.local +dist +build +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store +coverage +.nyc_output diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a9e7b76 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,38 @@ +# Build stage +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +# Build the app +COPY . . +RUN npm run build + +# Production stage +FROM node:22-alpine +WORKDIR /app + +# Install dumb-init for proper signal handling +RUN apk add --no-cache dumb-init + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 + +# Copy built assets from builder +COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist + +# Copy package files for preview server +COPY --chown=nodejs:nodejs package*.json ./ + +# Switch to non-root user +USER nodejs + +EXPOSE 5173 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:5173 || exit 1 + +# Use dumb-init to handle signals properly +ENTRYPOINT ["dumb-init", "--"] +CMD ["npm", "run", "preview"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 51d3d71..f9e9cde 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "mathlive": "^0.109.2", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" @@ -28,6 +29,12 @@ "vitest": "^4.1.6" } }, + "node_modules/@arnog/colors": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@arnog/colors/-/colors-0.3.0.tgz", + "integrity": "sha512-CDZFMVWeE3HqcrzvacY2Y8257RS9c0f8+D+MWjbjmb5IWpOZPPeJSqWyxkVcFleCjA+x5aq6foc57cVaP+AMQg==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -268,6 +275,21 @@ "node": ">=6.9.0" } }, + "node_modules/@cortex-js/compute-engine": { + "version": "0.55.6", + "resolved": "https://registry.npmjs.org/@cortex-js/compute-engine/-/compute-engine-0.55.6.tgz", + "integrity": "sha512-lWnZ34gtFpUDpFmEMdsL+5HJuh7hyj0DoaZhVTFnVtGX2Rf7qFyD+zgTs1vY9h2qhcpKymiakE6evvWzI6kwtA==", + "license": "MIT", + "dependencies": { + "@arnog/colors": "^0.3.0", + "complex-esm": "^2.1.1-esm1", + "decimal.js": "^10.6.0" + }, + "engines": { + "node": ">=21.7.3", + "npm": ">=10.5.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1459,6 +1481,16 @@ "node": ">=18" } }, + "node_modules/complex-esm": { + "version": "2.1.1-esm1", + "resolved": "https://registry.npmjs.org/complex-esm/-/complex-esm-2.1.1-esm1.tgz", + "integrity": "sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==", + "license": "MIT", + "engines": { + "node": ">=16.14.2", + "npm": ">=8.5.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1519,6 +1551,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2358,6 +2396,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mathlive": { + "version": "0.109.2", + "resolved": "https://registry.npmjs.org/mathlive/-/mathlive-0.109.2.tgz", + "integrity": "sha512-/2uNk8xFP8msIINwWFKv9bBLnCnaNL2wzUWaDu89Vj7sSuIUX8FFg0PY6XER0pNpHJCa/T+Ct5MK6m+zFTdPKw==", + "license": "MIT", + "dependencies": { + "@cortex-js/compute-engine": "0.55.6" + }, + "funding": { + "type": "individual", + "url": "https://paypal.me/arnogourdol" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index f27921c..a48907b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "coverage": "vitest --coverage" }, "dependencies": { + "mathlive": "^0.109.2", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 72e30d2..5852394 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,13 +2,16 @@ import React, { useState } from 'react'; import Welcome from './pages/Welcome'; import SignIn from './pages/SignIn'; import SignUp from './pages/SignUp'; +import MathFieldTest from './pages/MathFieldTest'; -type Page = 'welcome' | 'signin' | 'signup'; +type Page = 'welcome' | 'signin' | 'signup' | 'mathfieldtest'; const App: React.FC = () => { + //const [page, setPage] = useState('mathfieldtest'); const [page, setPage] = useState('welcome'); return ( <> + {page === 'mathfieldtest' && } {page === 'welcome' && ( setPage('signin')} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 40dbbd1..1074b60 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,7 +9,7 @@ import Found from './pages/queuePages/found' ReactDOM.createRoot(document.getElementById('root')!).render( - + ) \ No newline at end of file diff --git a/frontend/src/pages/MathFieldTest.tsx b/frontend/src/pages/MathFieldTest.tsx new file mode 100644 index 0000000..b6d2d8f --- /dev/null +++ b/frontend/src/pages/MathFieldTest.tsx @@ -0,0 +1,26 @@ +import MathField from './components/MathField.tsx'; +import { useState } from 'react'; + +const MathFieldTest = () => { + const [currentValue, setCurrentValue] = useState(''); + + return ( +
+

MathField Test Page

+ +

Math Input:

+ setCurrentValue(val)} /> + +

Current Value (LaTeX):

+
+        {currentValue || 'Nothing typed yet'}
+      
+
+ ); +}; + +export default MathFieldTest; \ No newline at end of file diff --git a/frontend/src/pages/components/MathField.tsx b/frontend/src/pages/components/MathField.tsx new file mode 100644 index 0000000..db8f2b6 --- /dev/null +++ b/frontend/src/pages/components/MathField.tsx @@ -0,0 +1,54 @@ +//This file defines a mathfield object that can be imported into the match screens +//Tutorial taken from https://mathlive.io/mathfield/guides/getting-started/ + +import "mathlive"; +import { MathfieldElement} from "mathlive"; +import { useState, useRef } from "react"; +import VirtualKeyboard from './VirtualKeyboard'; + +//Extending JSX to react mathfield as a valid element +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'math-field': { + ref?: React.RefObject; + value?: string; + onInput?: (evt: React.SyntheticEvent) => void; //double check SyntheticEvent is the correct function + children?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + }; + } + } +} + + +interface MathFieldProps { + onValueChange?: (value: string) => void; +} + +const MathField = ({ onValueChange }: MathFieldProps) => { + const [value, setValue] = useState(''); + const mathfieldRef = useRef(null) + + const handleInput = (evt: React.SyntheticEvent) => { + const target = evt.target as MathfieldElement; + const newValue = target.value; + setValue(newValue); + onValueChange?.(newValue); + }; + + return ( +
+ + {value} + + +
+ ); +}; + +export default MathField; \ No newline at end of file diff --git a/frontend/src/pages/components/VirtualKeyboard.tsx b/frontend/src/pages/components/VirtualKeyboard.tsx new file mode 100644 index 0000000..b81b8f4 --- /dev/null +++ b/frontend/src/pages/components/VirtualKeyboard.tsx @@ -0,0 +1,46 @@ +//Page containing the virtual math keyboard + +import 'mathlive'; +import { MathfieldElement } from 'mathlive'; +import { useEffect, useRef } from 'react'; + +interface VirtualKeyboardProps { + mathfieldRef: React.RefObject; +} + +const VirtualKeyboard = ({ mathfieldRef }: VirtualKeyboardProps) => { + useEffect(() => { + const mf = mathfieldRef.current; + + if (!mf) return; + + // Set keyboard policy to manual so we control when it shows + mf.mathVirtualKeyboardPolicy = 'manual'; + + // const handleFocusIn = () => { + // window.mathVirtualKeyboard.show(); + // }; + + // const handleFocusOut = () => { + // window.mathVirtualKeyboard.hide(); + // }; + // Tell MathLive to attach the keyboard to a specific container + window.mathVirtualKeyboard.container = document.body; + + const handleFocusIn = () => window.mathVirtualKeyboard.show(); + const handleFocusOut = () => window.mathVirtualKeyboard.hide(); + + mf.addEventListener('focusin', handleFocusIn); + mf.addEventListener('focusout', handleFocusOut); + + // Cleanup event listeners when component unmounts + return () => { + mf.removeEventListener('focusin', handleFocusIn); + mf.removeEventListener('focusout', handleFocusOut); + }; + }, [mathfieldRef]); + + return null; +}; + +export default VirtualKeyboard; \ No newline at end of file