"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const vitest_1 = require("vitest"); const express_1 = __importDefault(require("express")); const bcryptjs_1 = __importDefault(require("bcryptjs")); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const supertest_1 = __importDefault(require("supertest")); const client_1 = require("@prisma/client"); const prisma = new client_1.PrismaClient(); const JWT_SECRET = 'test-secret-key'; function createApp() { const app = (0, express_1.default)(); app.use(express_1.default.json()); const authenticate = async (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'No token provided' }); } const token = authHeader.split(' ')[1]; try { const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET); const user = await prisma.user.findUnique({ where: { id: decoded.userId }, select: { id: true, email: true, name: true, isAdmin: true } }); if (!user) { return res.status(401).json({ error: 'User not found' }); } req.user = user; next(); } catch { return res.status(401).json({ error: 'Invalid token' }); } }; app.get('/me', authenticate, async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { id: true, email: true, name: true, screenName: true, avatarUrl: true, unitPreference: true, createdAt: true } }); if (!user) { return res.status(404).json({ error: 'User not found' }); } res.json(user); } catch { res.status(500).json({ error: 'Failed to get user' }); } }); app.put('/me', authenticate, async (req, res) => { try { const { name, screenName, avatarUrl, unitPreference } = req.body; const updated = await prisma.user.update({ where: { id: req.user.id }, data: { name: name || undefined, screenName: screenName !== undefined ? screenName || null : undefined, avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined, unitPreference: unitPreference || undefined }, select: { id: true, email: true, name: true, screenName: true, avatarUrl: true, unitPreference: true, createdAt: true } }); res.json(updated); } catch { res.status(500).json({ error: 'Failed to update user' }); } }); app.get('/me/location-history', authenticate, async (req, res) => { try { const locations = await prisma.locationHistory.findMany({ where: { userId: req.user.id }, include: { game: { select: { id: true, name: true } } }, orderBy: { recordedAt: 'desc' } }); const games = await prisma.game.findMany({ where: { teams: { some: { members: { some: { userId: req.user.id } } } } }, select: { id: true, name: true } }); const locationByGame = games.map(game => { const gameLocations = locations.filter(l => l.gameId === game.id); return { game: game, locations: gameLocations, locationCount: gameLocations.length }; }).filter(g => g.locationCount > 0); res.json({ totalLocations: locations.length, byGame: locationByGame }); } catch { res.status(500).json({ error: 'Failed to get location history' }); } }); app.get('/me/games', authenticate, async (req, res) => { try { const memberships = await prisma.teamMember.findMany({ where: { userId: req.user.id }, include: { team: { include: { game: { select: { id: true, name: true, status: true, startDate: true, locationLat: true, locationLng: true, gameMasterId: true, gameMaster: { select: { name: true } } } }, teamRoutes: { include: { route: { include: { routeLegs: { orderBy: { sequenceNumber: 'asc' } } } } } }, photoSubmissions: true } } } }); const gamesWithDetails = memberships.map(m => { const team = m.team; const game = team.game; const teamRoute = team.teamRoutes[0]; const route = teamRoute?.route; const photoSubmissions = team.photoSubmissions; const routeLegs = route?.routeLegs || []; const proofLocations = routeLegs.filter(leg => photoSubmissions.some(p => p.routeLegId === leg.id)); let totalDistance = 0; if (game.locationLat && game.locationLng) { let prevLat = game.locationLat; let prevLng = game.locationLng; for (const leg of routeLegs) { if (leg.locationLat && leg.locationLng) { const R = 6371; const dLat = (leg.locationLat - prevLat) * Math.PI / 180; const dLng = (leg.locationLng - prevLng) * Math.PI / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(prevLat * Math.PI / 180) * Math.cos(leg.locationLat * Math.PI / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); totalDistance += R * c; prevLat = leg.locationLat; prevLng = leg.locationLng; } } } return { gameId: game.id, gameName: game.name, gameStatus: game.status, gameMaster: game.gameMaster.name, startDate: game.startDate, teamId: team.id, teamName: team.name, teamStatus: team.status, routeId: route?.id || null, routeName: route?.name || null, routeColor: route?.color || null, totalLegs: routeLegs.length, totalDistance: Math.round(totalDistance * 100) / 100, proofLocations: proofLocations.map(leg => ({ legNumber: leg.sequenceNumber, description: leg.description, locationLat: leg.locationLat, locationLng: leg.locationLng, hasPhotoProof: photoSubmissions.some(p => p.routeLegId === leg.id) })) }; }); res.json(gamesWithDetails); } catch { res.status(500).json({ error: 'Failed to get user games' }); } }); app.delete('/me/location-data', authenticate, async (req, res) => { try { await prisma.locationHistory.deleteMany({ where: { userId: req.user.id } }); res.json({ message: 'Location data deleted' }); } catch { res.status(500).json({ error: 'Failed to delete location data' }); } }); app.delete('/me/account', authenticate, async (req, res) => { try { await prisma.user.delete({ where: { id: req.user.id } }); res.json({ message: 'Account deleted' }); } catch { res.status(500).json({ error: 'Failed to delete account' }); } }); return app; } (0, vitest_1.describe)('Users API', () => { let app; let userToken; let userId; async function cleanup() { await prisma.photoSubmission.deleteMany(); await prisma.routeLeg.deleteMany(); await prisma.teamRoute.deleteMany(); await prisma.teamMember.deleteMany(); await prisma.team.deleteMany(); await prisma.route.deleteMany(); await prisma.chatMessage.deleteMany(); await prisma.locationHistory.deleteMany(); await prisma.game.deleteMany(); await prisma.user.deleteMany(); await prisma.systemSettings.deleteMany(); await prisma.bannedEmail.deleteMany(); await prisma.apiKey.deleteMany(); } (0, vitest_1.beforeAll)(async () => { app = createApp(); await cleanup(); }); (0, vitest_1.afterAll)(async () => { await cleanup(); await prisma.$disconnect(); }); (0, vitest_1.beforeEach)(async () => { await cleanup(); const passwordHash = await bcryptjs_1.default.hash('password123', 10); const user = await prisma.user.create({ data: { email: 'testuser@test.com', passwordHash, name: 'Test User', unitPreference: 'METRIC' } }); userId = user.id; userToken = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); }); (0, vitest_1.describe)('GET /me', () => { (0, vitest_1.it)('should get current user profile', async () => { const res = await (0, supertest_1.default)(app) .get('/me') .set('Authorization', `Bearer ${userToken}`); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.id).toBe(userId); (0, vitest_1.expect)(res.body.email).toBe('testuser@test.com'); (0, vitest_1.expect)(res.body.name).toBe('Test User'); (0, vitest_1.expect)(res.body.unitPreference).toBe('METRIC'); }); (0, vitest_1.it)('should return 401 without token', async () => { const res = await (0, supertest_1.default)(app).get('/me'); (0, vitest_1.expect)(res.status).toBe(401); }); (0, vitest_1.it)('should return 401 with invalid token', async () => { const res = await (0, supertest_1.default)(app) .get('/me') .set('Authorization', 'Bearer invalid-token'); (0, vitest_1.expect)(res.status).toBe(401); }); }); (0, vitest_1.describe)('PUT /me', () => { (0, vitest_1.it)('should update user name', async () => { const res = await (0, supertest_1.default)(app) .put('/me') .set('Authorization', `Bearer ${userToken}`) .send({ name: 'Updated Name' }); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.name).toBe('Updated Name'); (0, vitest_1.expect)(res.body.email).toBe('testuser@test.com'); }); (0, vitest_1.it)('should update screen name', async () => { const res = await (0, supertest_1.default)(app) .put('/me') .set('Authorization', `Bearer ${userToken}`) .send({ screenName: 'CoolPlayer' }); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.screenName).toBe('CoolPlayer'); }); (0, vitest_1.it)('should update avatar URL', async () => { const res = await (0, supertest_1.default)(app) .put('/me') .set('Authorization', `Bearer ${userToken}`) .send({ avatarUrl: 'https://example.com/avatar.png' }); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.avatarUrl).toBe('https://example.com/avatar.png'); }); (0, vitest_1.it)('should update unit preference to imperial', async () => { const res = await (0, supertest_1.default)(app) .put('/me') .set('Authorization', `Bearer ${userToken}`) .send({ unitPreference: 'IMPERIAL' }); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.unitPreference).toBe('IMPERIAL'); }); (0, vitest_1.it)('should allow clearing optional fields with empty string', async () => { await prisma.user.update({ where: { id: userId }, data: { screenName: 'HasScreenName' } }); const res = await (0, supertest_1.default)(app) .put('/me') .set('Authorization', `Bearer ${userToken}`) .send({ screenName: '' }); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.screenName).toBe(null); }); (0, vitest_1.it)('should update multiple fields at once', async () => { const res = await (0, supertest_1.default)(app) .put('/me') .set('Authorization', `Bearer ${userToken}`) .send({ name: 'Multi Update', screenName: 'Multi', unitPreference: 'IMPERIAL' }); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.name).toBe('Multi Update'); (0, vitest_1.expect)(res.body.screenName).toBe('Multi'); (0, vitest_1.expect)(res.body.unitPreference).toBe('IMPERIAL'); }); (0, vitest_1.it)('should return 401 without token', async () => { const res = await (0, supertest_1.default)(app) .put('/me') .send({ name: 'Hacker' }); (0, vitest_1.expect)(res.status).toBe(401); }); }); (0, vitest_1.describe)('GET /me/location-history', () => { (0, vitest_1.it)('should return location history summary', async () => { const res = await (0, supertest_1.default)(app) .get('/me/location-history') .set('Authorization', `Bearer ${userToken}`); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body).toHaveProperty('totalLocations'); (0, vitest_1.expect)(res.body).toHaveProperty('byGame'); (0, vitest_1.expect)(res.body.totalLocations).toBe(0); (0, vitest_1.expect)(res.body.byGame).toEqual([]); }); (0, vitest_1.it)('should include location history with game info', async () => { const gm = await prisma.user.create({ data: { email: 'gm@test.com', passwordHash: await bcryptjs_1.default.hash('pass', 10), name: 'GM' } }); const game = await prisma.game.create({ data: { name: 'Location Game', gameMasterId: gm.id } }); const team = await prisma.team.create({ data: { name: 'Loc Team', gameId: game.id, captainId: userId } }); await prisma.teamMember.create({ data: { teamId: team.id, userId } }); await prisma.locationHistory.create({ data: { userId, gameId: game.id, teamId: team.id, lat: 40.7128, lng: -74.0060, recordedAt: new Date() } }); const res = await (0, supertest_1.default)(app) .get('/me/location-history') .set('Authorization', `Bearer ${userToken}`); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.totalLocations).toBe(1); (0, vitest_1.expect)(res.body.byGame.length).toBe(1); (0, vitest_1.expect)(res.body.byGame[0].game.name).toBe('Location Game'); (0, vitest_1.expect)(res.body.byGame[0].locationCount).toBe(1); }); (0, vitest_1.it)('should return 401 without token', async () => { const res = await (0, supertest_1.default)(app).get('/me/location-history'); (0, vitest_1.expect)(res.status).toBe(401); }); }); (0, vitest_1.describe)('GET /me/games', () => { (0, vitest_1.it)('should return empty array when user has no games', async () => { const res = await (0, supertest_1.default)(app) .get('/me/games') .set('Authorization', `Bearer ${userToken}`); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body).toEqual([]); }); (0, vitest_1.it)('should return user games with details', async () => { const gm = await prisma.user.create({ data: { email: 'gm@test.com', passwordHash: await bcryptjs_1.default.hash('pass', 10), name: 'Game Master' } }); const game = await prisma.game.create({ data: { name: 'My Game', gameMasterId: gm.id, status: 'LIVE' } }); const route = await prisma.route.create({ data: { name: 'My Route', gameId: game.id, color: '#FF0000' } }); await prisma.routeLeg.create({ data: { routeId: route.id, sequenceNumber: 1, description: 'First stop', locationLat: 40.7128, locationLng: -74.0060 } }); const team = await prisma.team.create({ data: { name: 'My Team', gameId: game.id, captainId: userId } }); await prisma.teamMember.create({ data: { teamId: team.id, userId } }); await prisma.teamRoute.create({ data: { teamId: team.id, routeId: route.id } }); const res = await (0, supertest_1.default)(app) .get('/me/games') .set('Authorization', `Bearer ${userToken}`); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.length).toBe(1); (0, vitest_1.expect)(res.body[0].gameName).toBe('My Game'); (0, vitest_1.expect)(res.body[0].teamName).toBe('My Team'); (0, vitest_1.expect)(res.body[0].routeName).toBe('My Route'); (0, vitest_1.expect)(res.body[0].totalLegs).toBe(1); (0, vitest_1.expect)(res.body[0].teamStatus).toBe('ACTIVE'); }); (0, vitest_1.it)('should return 401 without token', async () => { const res = await (0, supertest_1.default)(app).get('/me/games'); (0, vitest_1.expect)(res.status).toBe(401); }); }); (0, vitest_1.describe)('DELETE /me/location-data', () => { (0, vitest_1.it)('should delete user location history', async () => { const gm = await prisma.user.create({ data: { email: 'gm@test.com', passwordHash: await bcryptjs_1.default.hash('pass', 10), name: 'GM' } }); const game = await prisma.game.create({ data: { name: 'Del Game', gameMasterId: gm.id } }); const team = await prisma.team.create({ data: { name: 'Del Team', gameId: game.id, captainId: userId } }); await prisma.teamMember.create({ data: { teamId: team.id, userId } }); await prisma.locationHistory.create({ data: { userId, gameId: game.id, teamId: team.id, lat: 40.7128, lng: -74.0060, recordedAt: new Date() } }); const res = await (0, supertest_1.default)(app) .delete('/me/location-data') .set('Authorization', `Bearer ${userToken}`); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.message).toBe('Location data deleted'); const locations = await prisma.locationHistory.count({ where: { userId } }); (0, vitest_1.expect)(locations).toBe(0); }); (0, vitest_1.it)('should return 401 without token', async () => { const res = await (0, supertest_1.default)(app).delete('/me/location-data'); (0, vitest_1.expect)(res.status).toBe(401); }); }); (0, vitest_1.describe)('DELETE /me/account', () => { (0, vitest_1.it)('should delete user account', async () => { const res = await (0, supertest_1.default)(app) .delete('/me/account') .set('Authorization', `Bearer ${userToken}`); (0, vitest_1.expect)(res.status).toBe(200); (0, vitest_1.expect)(res.body.message).toBe('Account deleted'); const user = await prisma.user.findUnique({ where: { id: userId } }); (0, vitest_1.expect)(user).toBeNull(); }); (0, vitest_1.it)('should return 401 without token', async () => { const res = await (0, supertest_1.default)(app).delete('/me/account'); (0, vitest_1.expect)(res.status).toBe(401); }); (0, vitest_1.it)('should not allow login after account deletion', async () => { await (0, supertest_1.default)(app) .delete('/me/account') .set('Authorization', `Bearer ${userToken}`); const res = await (0, supertest_1.default)(app) .get('/me') .set('Authorization', `Bearer ${userToken}`); (0, vitest_1.expect)(res.status).toBe(401); }); }); });