Added unit and api tests

This commit is contained in:
Brian McGonagill 2026-03-26 10:21:19 -05:00
parent 9f4204cc73
commit fedf1eb4c5
34 changed files with 9205 additions and 20 deletions

2
backend/dist/routes/admin.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const router: import("express-serve-static-core").Router;
export default router;

213
backend/dist/routes/admin.js vendored Normal file
View file

@ -0,0 +1,213 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const index_1 = require("../index");
const auth_1 = require("../middleware/auth");
const uuid_1 = require("uuid");
const router = (0, express_1.Router)();
router.use(auth_1.authenticate);
router.get('/settings', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const settings = await index_1.prisma.systemSettings.findUnique({
where: { id: 'default' }
});
if (!settings) {
const newSettings = await index_1.prisma.systemSettings.create({
data: { id: 'default', registrationEnabled: true }
});
return res.json(newSettings);
}
res.json(settings);
}
catch (error) {
console.error('Get settings error:', error);
res.status(500).json({ error: 'Failed to get settings' });
}
});
router.put('/settings', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { registrationEnabled } = req.body;
const settings = await index_1.prisma.systemSettings.upsert({
where: { id: 'default' },
update: { registrationEnabled },
create: { id: 'default', registrationEnabled: registrationEnabled ?? true }
});
res.json(settings);
}
catch (error) {
console.error('Update settings error:', error);
res.status(500).json({ error: 'Failed to update settings' });
}
});
router.post('/settings/invite-code', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const inviteCode = (0, uuid_1.v4)().slice(0, 12);
const settings = await index_1.prisma.systemSettings.upsert({
where: { id: 'default' },
update: { inviteCode },
create: { id: 'default', registrationEnabled: true, inviteCode }
});
res.json({ inviteCode: settings.inviteCode });
}
catch (error) {
console.error('Generate invite code error:', error);
res.status(500).json({ error: 'Failed to generate invite code' });
}
});
router.delete('/settings/invite-code', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
await index_1.prisma.systemSettings.update({
where: { id: 'default' },
data: { inviteCode: null }
});
res.json({ message: 'Invite code removed' });
}
catch (error) {
console.error('Remove invite code error:', error);
res.status(500).json({ error: 'Failed to remove invite code' });
}
});
router.get('/users', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const users = await index_1.prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
screenName: true,
isAdmin: true,
isApiEnabled: true,
createdAt: true,
_count: {
select: { games: true, teams: true }
}
},
orderBy: { createdAt: 'desc' }
});
res.json(users);
}
catch (error) {
console.error('List users error:', error);
res.status(500).json({ error: 'Failed to list users' });
}
});
router.put('/users/:userId/admin', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { userId } = req.params;
const { isAdmin } = req.body;
const user = await index_1.prisma.user.update({
where: { id: userId },
data: { isAdmin },
select: {
id: true,
email: true,
name: true,
isAdmin: true
}
});
res.json(user);
}
catch (error) {
console.error('Update admin status error:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
router.put('/users/:userId/api-access', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { userId } = req.params;
const { isApiEnabled } = req.body;
const user = await index_1.prisma.user.update({
where: { id: userId },
data: { isApiEnabled },
select: {
id: true,
email: true,
name: true,
isApiEnabled: true
}
});
res.json(user);
}
catch (error) {
console.error('Update API access error:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
router.get('/banned-emails', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const bannedEmails = await index_1.prisma.bannedEmail.findMany({
orderBy: { createdAt: 'desc' }
});
res.json(bannedEmails);
}
catch (error) {
console.error('List banned emails error:', error);
res.status(500).json({ error: 'Failed to list banned emails' });
}
});
router.post('/banned-emails', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { email, reason } = req.body;
if (!email) {
return res.status(400).json({ error: 'Email is required' });
}
const bannedEmail = await index_1.prisma.bannedEmail.create({
data: {
email: email.toLowerCase(),
reason
}
});
res.json(bannedEmail);
}
catch (error) {
if (error.code === 'P2002') {
return res.status(400).json({ error: 'Email already banned' });
}
console.error('Ban email error:', error);
res.status(500).json({ error: 'Failed to ban email' });
}
});
router.delete('/banned-emails/:id', async (req, res) => {
try {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
await index_1.prisma.bannedEmail.delete({
where: { id }
});
res.json({ message: 'Email unbanned' });
}
catch (error) {
console.error('Unban email error:', error);
res.status(500).json({ error: 'Failed to unban email' });
}
});
exports.default = router;

2
backend/dist/routes/apikeys.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const router: import("express-serve-static-core").Router;
export default router;

85
backend/dist/routes/apikeys.js vendored Normal file
View file

@ -0,0 +1,85 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const index_1 = require("../index");
const auth_1 = require("../middleware/auth");
const crypto_1 = __importDefault(require("crypto"));
const router = (0, express_1.Router)();
router.use(auth_1.authenticate);
router.get('/me/api-keys', async (req, res) => {
try {
if (!req.user?.isApiEnabled) {
return res.status(403).json({ error: 'API access is not enabled for your account' });
}
const apiKeys = await index_1.prisma.apiKey.findMany({
where: { userId: req.user.id },
select: {
id: true,
name: true,
expiresAt: true,
lastUsed: true,
createdAt: true
}
});
res.json(apiKeys);
}
catch (error) {
console.error('Get API keys error:', error);
res.status(500).json({ error: 'Failed to get API keys' });
}
});
router.post('/me/api-keys', async (req, res) => {
try {
if (!req.user?.isApiEnabled) {
return res.status(403).json({ error: 'API access is not enabled for your account' });
}
const { name, expiresInDays } = req.body;
if (!name) {
return res.status(400).json({ error: 'Key name is required' });
}
const key = crypto_1.default.randomBytes(32).toString('hex');
const keyHash = crypto_1.default.createHash('sha256').update(key).digest('hex');
const expiresAt = expiresInDays
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
: null;
const apiKey = await index_1.prisma.apiKey.create({
data: {
key: keyHash,
name,
userId: req.user.id,
expiresAt
}
});
res.json({
id: apiKey.id,
name: apiKey.name,
key,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt
});
}
catch (error) {
console.error('Create API key error:', error);
res.status(500).json({ error: 'Failed to create API key' });
}
});
router.delete('/me/api-keys/:id', async (req, res) => {
try {
if (!req.user?.isApiEnabled) {
return res.status(403).json({ error: 'API access is not enabled for your account' });
}
const { id } = req.params;
await index_1.prisma.apiKey.delete({
where: { id, userId: req.user.id }
});
res.json({ message: 'API key revoked' });
}
catch (error) {
console.error('Delete API key error:', error);
res.status(500).json({ error: 'Failed to delete API key' });
}
});
exports.default = router;

View file

@ -11,22 +11,43 @@ const router = (0, express_1.Router)();
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
const { email, password, name, inviteCode } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'Email, password, and name are required' });
}
const settings = await index_1.prisma.systemSettings.findUnique({
where: { id: 'default' }
});
const isBanned = await index_1.prisma.bannedEmail.findUnique({
where: { email: email.toLowerCase() }
});
if (isBanned) {
return res.status(403).json({ error: 'This email is not allowed to register' });
}
if (settings && !settings.registrationEnabled) {
if (!inviteCode || settings.inviteCode !== inviteCode) {
return res.status(403).json({ error: 'Registration is currently closed. An invite code may be required.' });
}
}
const existingUser = await index_1.prisma.user.findUnique({ where: { email } });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const passwordHash = await bcryptjs_1.default.hash(password, 10);
const userCount = await index_1.prisma.user.count();
const isFirstUser = userCount === 0;
const user = await index_1.prisma.user.create({
data: { email, passwordHash, name }
data: {
email,
passwordHash,
name,
isAdmin: isFirstUser
}
});
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin }
});
}
catch (error) {
@ -51,7 +72,7 @@ router.post('/login', async (req, res) => {
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name }
user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin }
});
}
catch (error) {
@ -59,6 +80,19 @@ router.post('/login', async (req, res) => {
res.status(500).json({ error: 'Failed to login' });
}
});
router.get('/registration-status', async (req, res) => {
try {
const settings = await index_1.prisma.systemSettings.findUnique({
where: { id: 'default' }
});
res.json({
enabled: !settings || settings.registrationEnabled
});
}
catch (error) {
res.json({ enabled: true });
}
});
router.get('/me', async (req, res) => {
try {
const authHeader = req.headers.authorization;
@ -69,7 +103,7 @@ router.get('/me', async (req, res) => {
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
const user = await index_1.prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true, createdAt: true }
select: { id: true, email: true, name: true, isAdmin: true, isApiEnabled: true, createdAt: true }
});
if (!user) {
return res.status(401).json({ error: 'User not found' });

1
backend/dist/routes/auth.test.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export {};

323
backend/dist/routes/auth.test.js vendored Normal file
View file

@ -0,0 +1,323 @@
"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());
app.post('/register', async (req, res) => {
try {
const { email, password, name, inviteCode } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'Email, password, and name are required' });
}
const settings = await prisma.systemSettings.findUnique({
where: { id: 'default' }
});
const isBanned = await prisma.bannedEmail.findUnique({
where: { email: email.toLowerCase() }
});
if (isBanned) {
return res.status(403).json({ error: 'This email is not allowed to register' });
}
if (settings && !settings.registrationEnabled) {
if (!inviteCode || settings.inviteCode !== inviteCode) {
return res.status(403).json({ error: 'Registration is currently closed. An invite code may be required.' });
}
}
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const passwordHash = await bcryptjs_1.default.hash(password, 10);
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const user = await prisma.user.create({
data: {
email,
passwordHash,
name,
isAdmin: isFirstUser
}
});
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin }
});
}
catch (error) {
console.error('Register error:', error);
res.status(500).json({ error: 'Failed to register' });
}
});
app.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const validPassword = await bcryptjs_1.default.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin }
});
}
catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Failed to login' });
}
});
app.get('/me', async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
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, isApiEnabled: true, createdAt: true }
});
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
res.json(user);
}
catch {
res.status(401).json({ error: 'Invalid token' });
}
});
return app;
}
(0, vitest_1.describe)('Auth Routes', () => {
let app;
async function cleanup() {
await prisma.routeLeg.deleteMany();
await prisma.route.deleteMany();
await prisma.teamRoute.deleteMany();
await prisma.teamMember.deleteMany();
await prisma.team.deleteMany();
await prisma.photoSubmission.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();
});
(0, vitest_1.afterAll)(async () => {
await prisma.user.deleteMany();
await prisma.systemSettings.deleteMany();
await prisma.bannedEmail.deleteMany();
await prisma.$disconnect();
});
(0, vitest_1.beforeEach)(async () => {
await prisma.user.deleteMany();
await prisma.systemSettings.deleteMany();
await prisma.bannedEmail.deleteMany();
});
(0, vitest_1.describe)('POST /register', () => {
(0, vitest_1.it)('should register a new user successfully', async () => {
const res = await (0, supertest_1.default)(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User'
});
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body).toHaveProperty('token');
(0, vitest_1.expect)(res.body.user).toHaveProperty('id');
(0, vitest_1.expect)(res.body.user.email).toBe('test@example.com');
(0, vitest_1.expect)(res.body.user.name).toBe('Test User');
});
(0, vitest_1.it)('should return error when email is missing', async () => {
const res = await (0, supertest_1.default)(app)
.post('/register')
.send({
password: 'password123',
name: 'Test User'
});
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('required');
});
(0, vitest_1.it)('should return error when email already exists', async () => {
await (0, supertest_1.default)(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User'
});
const res = await (0, supertest_1.default)(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password456',
name: 'Another User'
});
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('already registered');
});
(0, vitest_1.it)('should not register a banned email', async () => {
await prisma.bannedEmail.create({
data: { email: 'banned@example.com', reason: 'Test ban' }
});
const res = await (0, supertest_1.default)(app)
.post('/register')
.send({
email: 'banned@example.com',
password: 'password123',
name: 'Banned User'
});
(0, vitest_1.expect)(res.status).toBe(403);
(0, vitest_1.expect)(res.body.error).toContain('not allowed');
});
(0, vitest_1.it)('should require invite code when registration is disabled', async () => {
await prisma.systemSettings.create({
data: { id: 'default', registrationEnabled: false }
});
const res = await (0, supertest_1.default)(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User'
});
(0, vitest_1.expect)(res.status).toBe(403);
(0, vitest_1.expect)(res.body.error).toContain('invite code');
});
(0, vitest_1.it)('should allow registration with valid invite code when registration is disabled', async () => {
await prisma.systemSettings.create({
data: { id: 'default', registrationEnabled: false, inviteCode: 'VALID123' }
});
const res = await (0, supertest_1.default)(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User',
inviteCode: 'VALID123'
});
(0, vitest_1.expect)(res.status).toBe(200);
});
});
(0, vitest_1.describe)('POST /login', () => {
(0, vitest_1.beforeEach)(async () => {
const passwordHash = await bcryptjs_1.default.hash('password123', 10);
await prisma.user.create({
data: {
email: 'test@example.com',
passwordHash,
name: 'Test User'
}
});
});
(0, vitest_1.it)('should login successfully with valid credentials', async () => {
const res = await (0, supertest_1.default)(app)
.post('/login')
.send({
email: 'test@example.com',
password: 'password123'
});
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body).toHaveProperty('token');
(0, vitest_1.expect)(res.body.user.email).toBe('test@example.com');
});
(0, vitest_1.it)('should return error with invalid password', async () => {
const res = await (0, supertest_1.default)(app)
.post('/login')
.send({
email: 'test@example.com',
password: 'wrongpassword'
});
(0, vitest_1.expect)(res.status).toBe(401);
(0, vitest_1.expect)(res.body.error).toBe('Invalid credentials');
});
(0, vitest_1.it)('should return error for non-existent user', async () => {
const res = await (0, supertest_1.default)(app)
.post('/login')
.send({
email: 'nonexistent@example.com',
password: 'password123'
});
(0, vitest_1.expect)(res.status).toBe(401);
(0, vitest_1.expect)(res.body.error).toBe('Invalid credentials');
});
(0, vitest_1.it)('should return error when email is missing', async () => {
const res = await (0, supertest_1.default)(app)
.post('/login')
.send({
password: 'password123'
});
(0, vitest_1.expect)(res.status).toBe(400);
});
});
(0, vitest_1.describe)('GET /me', () => {
let token;
let userId;
(0, vitest_1.beforeEach)(async () => {
const passwordHash = await bcryptjs_1.default.hash('password123', 10);
const user = await prisma.user.create({
data: {
email: 'test@example.com',
passwordHash,
name: 'Test User',
isAdmin: true
}
});
userId = user.id;
token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
});
(0, vitest_1.it)('should return user data with valid token', async () => {
const res = await (0, supertest_1.default)(app)
.get('/me')
.set('Authorization', `Bearer ${token}`);
(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('test@example.com');
(0, vitest_1.expect)(res.body.isAdmin).toBe(true);
});
(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);
});
});
});

View file

@ -171,6 +171,9 @@ router.delete('/:id', auth_1.authenticate, async (req, res) => {
if (game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
if (game.status !== 'DRAFT') {
return res.status(400).json({ error: 'Only draft games can be deleted' });
}
await index_1.prisma.game.delete({ where: { id } });
res.json({ message: 'Game deleted' });
}

1
backend/dist/routes/games.test.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export {};

474
backend/dist/routes/games.test.js vendored Normal file
View file

@ -0,0 +1,474 @@
"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 }
});
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('/games', async (req, res) => {
try {
const { search, status } = req.query;
const where = { visibility: 'PUBLIC' };
if (status)
where.status = status;
if (search)
where.name = { contains: search, mode: 'insensitive' };
const games = await prisma.game.findMany({
where,
include: {
gameMaster: { select: { id: true, name: true } },
_count: { select: { teams: true, routes: true } }
},
orderBy: { createdAt: 'desc' }
});
res.json(games);
}
catch (error) {
res.status(500).json({ error: 'Failed to list games' });
}
});
app.get('/games/my-games', authenticate, async (req, res) => {
try {
const games = await prisma.game.findMany({
where: { gameMasterId: req.user.id },
include: { _count: { select: { teams: true, routes: true } } },
orderBy: { createdAt: 'desc' }
});
res.json(games);
}
catch (error) {
res.status(500).json({ error: 'Failed to get games' });
}
});
app.get('/games/:id', async (req, res) => {
try {
const game = await prisma.game.findUnique({
where: { id: req.params.id },
include: {
gameMaster: { select: { id: true, name: true } },
routes: { include: { routeLegs: { orderBy: { sequenceNumber: 'asc' } } } },
teams: { include: { members: { include: { user: { select: { id: true, name: true, email: true } } } } } }
}
});
if (!game)
return res.status(404).json({ error: 'Game not found' });
const isOwner = req.user?.id === game.gameMasterId;
if (game.visibility === 'PRIVATE' && !isOwner) {
return res.status(403).json({ error: 'Access denied' });
}
res.json({ ...game, rules: game.rules ? JSON.parse(game.rules) : [] });
}
catch (error) {
res.status(500).json({ error: 'Failed to get game' });
}
});
app.post('/games', authenticate, async (req, res) => {
try {
const { name, description, visibility, startDate, locationLat, locationLng, searchRadius } = req.body;
if (!name)
return res.status(400).json({ error: 'Name is required' });
const game = await prisma.game.create({
data: {
name,
description,
visibility: visibility || 'PUBLIC',
startDate: startDate ? new Date(startDate) : null,
locationLat,
locationLng,
searchRadius,
gameMasterId: req.user.id,
inviteCode: Math.random().toString(36).slice(2, 10)
}
});
res.json(game);
}
catch (error) {
res.status(500).json({ error: 'Failed to create game' });
}
});
app.put('/games/:id', authenticate, async (req, res) => {
try {
const game = await prisma.game.findUnique({ where: { id: req.params.id } });
if (!game)
return res.status(404).json({ error: 'Game not found' });
if (game.gameMasterId !== req.user.id)
return res.status(403).json({ error: 'Not authorized' });
const updated = await prisma.game.update({
where: { id: req.params.id },
data: req.body
});
res.json(updated);
}
catch (error) {
res.status(500).json({ error: 'Failed to update game' });
}
});
app.delete('/games/:id', authenticate, async (req, res) => {
try {
const game = await prisma.game.findUnique({ where: { id: req.params.id } });
if (!game)
return res.status(404).json({ error: 'Game not found' });
if (game.gameMasterId !== req.user.id)
return res.status(403).json({ error: 'Not authorized' });
if (game.status !== 'DRAFT')
return res.status(400).json({ error: 'Only draft games can be deleted' });
await prisma.game.delete({ where: { id: req.params.id } });
res.json({ message: 'Game deleted' });
}
catch (error) {
res.status(500).json({ error: 'Failed to delete game' });
}
});
app.post('/games/:id/publish', authenticate, async (req, res) => {
try {
const game = await prisma.game.findUnique({
where: { id: req.params.id },
include: { routes: { include: { routeLegs: true } } }
});
if (!game)
return res.status(404).json({ error: 'Game not found' });
if (game.gameMasterId !== req.user.id)
return res.status(403).json({ error: 'Not authorized' });
if (!game.routes?.length || !game.routes.some((r) => r.routeLegs?.length)) {
return res.status(400).json({ error: 'Game must have at least one route with legs' });
}
const updated = await prisma.game.update({
where: { id: req.params.id },
data: { status: 'LIVE' }
});
res.json(updated);
}
catch (error) {
res.status(500).json({ error: 'Failed to publish game' });
}
});
app.post('/games/:id/end', authenticate, async (req, res) => {
try {
const game = await prisma.game.findUnique({ where: { id: req.params.id } });
if (!game)
return res.status(404).json({ error: 'Game not found' });
if (game.gameMasterId !== req.user.id)
return res.status(403).json({ error: 'Not authorized' });
const updated = await prisma.game.update({
where: { id: req.params.id },
data: { status: 'ENDED' }
});
res.json(updated);
}
catch (error) {
res.status(500).json({ error: 'Failed to end game' });
}
});
app.post('/games/:id/archive', authenticate, async (req, res) => {
try {
const game = await prisma.game.findUnique({ where: { id: req.params.id } });
if (!game)
return res.status(404).json({ error: 'Game not found' });
if (game.gameMasterId !== req.user.id)
return res.status(403).json({ error: 'Not authorized' });
const updated = await prisma.game.update({
where: { id: req.params.id },
data: { status: 'ARCHIVED' }
});
res.json(updated);
}
catch (error) {
res.status(500).json({ error: 'Failed to archive game' });
}
});
app.get('/games/invite/:code', async (req, res) => {
try {
const game = await prisma.game.findUnique({
where: { inviteCode: req.params.code },
include: { gameMaster: { select: { id: true, name: true } } }
});
if (!game)
return res.status(404).json({ error: 'Game not found' });
res.json(game);
}
catch (error) {
res.status(500).json({ error: 'Failed to get game' });
}
});
return app;
}
(0, vitest_1.describe)('Games API', () => {
let app;
let userToken;
let userId;
let otherToken;
let otherUserId;
async function cleanup() {
await prisma.routeLeg.deleteMany();
await prisma.route.deleteMany();
await prisma.teamRoute.deleteMany();
await prisma.teamMember.deleteMany();
await prisma.team.deleteMany();
await prisma.photoSubmission.deleteMany();
await prisma.chatMessage.deleteMany();
await prisma.locationHistory.deleteMany();
await prisma.game.deleteMany();
await prisma.user.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: 'owner@test.com', passwordHash, name: 'Owner User' }
});
userId = user.id;
userToken = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
const otherUser = await prisma.user.create({
data: { email: 'other@test.com', passwordHash, name: 'Other User' }
});
otherUserId = otherUser.id;
otherToken = jsonwebtoken_1.default.sign({ userId: otherUser.id }, JWT_SECRET, { expiresIn: '7d' });
});
(0, vitest_1.describe)('GET /games', () => {
(0, vitest_1.it)('should list public games', async () => {
await prisma.game.create({
data: { name: 'Public Game', gameMasterId: userId, visibility: 'PUBLIC' }
});
await prisma.game.create({
data: { name: 'Private Game', gameMasterId: userId, visibility: 'PRIVATE' }
});
const res = await (0, supertest_1.default)(app).get('/games');
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.length).toBe(1);
(0, vitest_1.expect)(res.body[0].name).toBe('Public Game');
});
(0, vitest_1.it)('should filter by status', async () => {
await prisma.game.create({
data: { name: 'Draft Game', gameMasterId: userId, status: 'DRAFT' }
});
await prisma.game.create({
data: { name: 'Live Game', gameMasterId: userId, status: 'LIVE' }
});
const res = await (0, supertest_1.default)(app).get('/games?status=LIVE');
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.length).toBe(1);
(0, vitest_1.expect)(res.body[0].name).toBe('Live Game');
});
(0, vitest_1.it)('should search by name', async () => {
await prisma.game.create({
data: { name: 'Treasure Hunt', gameMasterId: userId }
});
await prisma.game.create({
data: { name: 'Scavenger Race', gameMasterId: userId }
});
const res = await (0, supertest_1.default)(app).get('/games?search=treasure');
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.length).toBe(1);
(0, vitest_1.expect)(res.body[0].name).toBe('Treasure Hunt');
});
});
(0, vitest_1.describe)('GET /games/my-games', () => {
(0, vitest_1.it)('should list games created by user', async () => {
await prisma.game.create({
data: { name: 'My Game', gameMasterId: userId }
});
await prisma.game.create({
data: { name: 'Other Game', gameMasterId: otherUserId }
});
const res = await (0, supertest_1.default)(app)
.get('/games/my-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].name).toBe('My Game');
});
(0, vitest_1.it)('should return 401 without token', async () => {
const res = await (0, supertest_1.default)(app).get('/games/my-games');
(0, vitest_1.expect)(res.status).toBe(401);
});
});
(0, vitest_1.describe)('POST /games', () => {
(0, vitest_1.it)('should create a game', async () => {
const res = await (0, supertest_1.default)(app)
.post('/games')
.set('Authorization', `Bearer ${userToken}`)
.send({ name: 'New Game', description: 'A test game' });
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.name).toBe('New Game');
(0, vitest_1.expect)(res.body.gameMasterId).toBe(userId);
});
(0, vitest_1.it)('should require name', async () => {
const res = await (0, supertest_1.default)(app)
.post('/games')
.set('Authorization', `Bearer ${userToken}`)
.send({ description: 'No name' });
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toBe('Name is required');
});
(0, vitest_1.it)('should return 401 without token', async () => {
const res = await (0, supertest_1.default)(app)
.post('/games')
.send({ name: 'Test' });
(0, vitest_1.expect)(res.status).toBe(401);
});
});
(0, vitest_1.describe)('GET /games/:id', () => {
(0, vitest_1.it)('should get a game by id', async () => {
const game = await prisma.game.create({
data: { name: 'Test Game', gameMasterId: userId }
});
const res = await (0, supertest_1.default)(app).get(`/games/${game.id}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.name).toBe('Test Game');
});
(0, vitest_1.it)('should return 404 for non-existent game', async () => {
const res = await (0, supertest_1.default)(app).get('/games/non-existent-id');
(0, vitest_1.expect)(res.status).toBe(404);
});
});
(0, vitest_1.describe)('PUT /games/:id', () => {
(0, vitest_1.it)('should update a game', async () => {
const game = await prisma.game.create({
data: { name: 'Original Name', gameMasterId: userId }
});
const res = await (0, supertest_1.default)(app)
.put(`/games/${game.id}`)
.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.it)('should not allow update by non-owner', async () => {
const game = await prisma.game.create({
data: { name: 'Test Game', gameMasterId: userId }
});
const res = await (0, supertest_1.default)(app)
.put(`/games/${game.id}`)
.set('Authorization', `Bearer ${otherToken}`)
.send({ name: 'Hacked' });
(0, vitest_1.expect)(res.status).toBe(403);
});
});
(0, vitest_1.describe)('DELETE /games/:id', () => {
(0, vitest_1.it)('should delete a draft game', async () => {
const game = await prisma.game.create({
data: { name: 'Draft Game', gameMasterId: userId, status: 'DRAFT' }
});
const res = await (0, supertest_1.default)(app)
.delete(`/games/${game.id}`)
.set('Authorization', `Bearer ${userToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.message).toBe('Game deleted');
});
(0, vitest_1.it)('should not delete non-draft game', async () => {
const game = await prisma.game.create({
data: { name: 'Live Game', gameMasterId: userId, status: 'LIVE' }
});
const res = await (0, supertest_1.default)(app)
.delete(`/games/${game.id}`)
.set('Authorization', `Bearer ${userToken}`);
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('Only draft');
});
});
(0, vitest_1.describe)('POST /games/:id/publish', () => {
(0, vitest_1.it)('should publish a game with routes and legs', async () => {
const game = await prisma.game.create({
data: { name: 'Test Game', gameMasterId: userId }
});
const route = await prisma.route.create({
data: { name: 'Test Route', gameId: game.id }
});
await prisma.routeLeg.create({
data: { routeId: route.id, sequenceNumber: 1, description: 'First leg' }
});
const res = await (0, supertest_1.default)(app)
.post(`/games/${game.id}/publish`)
.set('Authorization', `Bearer ${userToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.status).toBe('LIVE');
});
(0, vitest_1.it)('should not publish game without routes', async () => {
const game = await prisma.game.create({
data: { name: 'Empty Game', gameMasterId: userId }
});
const res = await (0, supertest_1.default)(app)
.post(`/games/${game.id}/publish`)
.set('Authorization', `Bearer ${userToken}`);
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('at least one route');
});
});
(0, vitest_1.describe)('POST /games/:id/end', () => {
(0, vitest_1.it)('should end a live game', async () => {
const game = await prisma.game.create({
data: { name: 'Live Game', gameMasterId: userId, status: 'LIVE' }
});
const res = await (0, supertest_1.default)(app)
.post(`/games/${game.id}/end`)
.set('Authorization', `Bearer ${userToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.status).toBe('ENDED');
});
});
(0, vitest_1.describe)('POST /games/:id/archive', () => {
(0, vitest_1.it)('should archive an ended game', async () => {
const game = await prisma.game.create({
data: { name: 'Ended Game', gameMasterId: userId, status: 'ENDED' }
});
const res = await (0, supertest_1.default)(app)
.post(`/games/${game.id}/archive`)
.set('Authorization', `Bearer ${userToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.status).toBe('ARCHIVED');
});
});
(0, vitest_1.describe)('GET /games/invite/:code', () => {
(0, vitest_1.it)('should find game by invite code', async () => {
await prisma.game.create({
data: { name: 'Invite Game', gameMasterId: userId, inviteCode: 'TESTCODE123' }
});
const res = await (0, supertest_1.default)(app).get('/games/invite/TESTCODE123');
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.name).toBe('Invite Game');
});
(0, vitest_1.it)('should return 404 for invalid code', async () => {
const res = await (0, supertest_1.default)(app).get('/games/invite/INVALID');
(0, vitest_1.expect)(res.status).toBe(404);
});
});
});

1
backend/dist/routes/teams.test.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export {};

835
backend/dist/routes/teams.test.js vendored Normal file
View file

@ -0,0 +1,835 @@
"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('/game/:gameId', async (req, res) => {
try {
const { gameId } = req.params;
const teams = await prisma.team.findMany({
where: { gameId },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } },
teamRoutes: {
include: {
route: {
include: {
routeLegs: { orderBy: { sequenceNumber: 'asc' } }
}
}
}
}
},
orderBy: { createdAt: 'asc' }
});
res.json(teams);
}
catch {
res.status(500).json({ error: 'Failed to get teams' });
}
});
app.post('/game/:gameId', authenticate, async (req, res) => {
try {
const { gameId } = req.params;
const { name } = req.body;
const game = await prisma.game.findUnique({
where: { id: gameId },
include: { teams: true }
});
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
if (game.status !== 'DRAFT' && game.status !== 'LIVE') {
return res.status(400).json({ error: 'Cannot join game at this time' });
}
const existingMember = await prisma.teamMember.findFirst({
where: {
userId: req.user.id,
team: { gameId }
}
});
if (existingMember) {
return res.status(400).json({ error: 'Already in a team for this game' });
}
const team = await prisma.team.create({
data: {
gameId,
name,
captainId: req.user.id
}
});
await prisma.teamMember.create({
data: {
teamId: team.id,
userId: req.user.id
}
});
const created = await prisma.team.findUnique({
where: { id: team.id },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } },
teamRoutes: {
include: {
route: {
include: {
routeLegs: { orderBy: { sequenceNumber: 'asc' } }
}
}
}
}
}
});
res.json(created);
}
catch {
res.status(500).json({ error: 'Failed to create team' });
}
});
app.post('/:teamId/join', authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: { game: true, members: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.members.length >= 5) {
return res.status(400).json({ error: 'Team is full (max 5 members)' });
}
const existingMember = await prisma.teamMember.findFirst({
where: {
userId: req.user.id,
teamId
}
});
if (existingMember) {
return res.status(400).json({ error: 'Already in this team' });
}
const gameMember = await prisma.teamMember.findFirst({
where: {
userId: req.user.id,
team: { gameId: team.gameId }
}
});
if (gameMember) {
return res.status(400).json({ error: 'Already in another team for this game' });
}
await prisma.teamMember.create({
data: {
teamId,
userId: req.user.id
}
});
res.json({ message: 'Joined team successfully' });
}
catch {
res.status(500).json({ error: 'Failed to join team' });
}
});
app.post('/:teamId/leave', authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.captainId === req.user.id) {
return res.status(400).json({ error: 'Captain cannot leave the team' });
}
await prisma.teamMember.deleteMany({
where: {
teamId,
userId: req.user.id
}
});
res.json({ message: 'Left team successfully' });
}
catch {
res.status(500).json({ error: 'Failed to leave team' });
}
});
app.post('/:teamId/assign-route', authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const { routeId } = req.body;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: { game: true, teamRoutes: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const route = await prisma.route.findUnique({
where: { id: routeId }
});
if (!route || route.gameId !== team.gameId) {
return res.status(400).json({ error: 'Invalid route for this game' });
}
await prisma.teamRoute.deleteMany({
where: { teamId }
});
const teamRoute = await prisma.teamRoute.create({
data: {
teamId,
routeId
},
include: {
route: {
include: {
routeLegs: { orderBy: { sequenceNumber: 'asc' } }
}
}
}
});
res.json(teamRoute);
}
catch {
res.status(500).json({ error: 'Failed to assign route' });
}
});
app.post('/:teamId/advance', authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: {
game: true,
teamRoutes: {
include: {
route: {
include: {
routeLegs: { orderBy: { sequenceNumber: 'asc' } }
}
}
}
}
}
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const teamRoute = team.teamRoutes[0];
if (!teamRoute) {
return res.status(400).json({ error: 'Team has no assigned route' });
}
const legs = teamRoute.route.routeLegs;
const currentLegIndex = team.currentLegIndex;
let nextLegIndex = currentLegIndex;
if (currentLegIndex < legs.length - 1) {
nextLegIndex = currentLegIndex + 1;
}
const updated = await prisma.team.update({
where: { id: teamId },
data: {
currentLegIndex: nextLegIndex,
status: nextLegIndex >= legs.length - 1 ? 'FINISHED' : 'ACTIVE'
},
include: {
members: { include: { user: { select: { id: true, name: true } } } },
teamRoutes: {
include: {
route: {
include: {
routeLegs: { orderBy: { sequenceNumber: 'asc' } }
}
}
}
}
}
});
res.json(updated);
}
catch {
res.status(500).json({ error: 'Failed to advance team' });
}
});
app.post('/:teamId/deduct', authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const { seconds } = req.body;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: { game: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const deduction = seconds || 60;
const updated = await prisma.team.update({
where: { id: teamId },
data: { totalTimeDeduction: { increment: deduction } }
});
res.json(updated);
}
catch {
res.status(500).json({ error: 'Failed to deduct time' });
}
});
app.post('/:teamId/disqualify', authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: { game: true }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
if (team.game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await prisma.team.update({
where: { id: teamId },
data: { status: 'DISQUALIFIED' }
});
res.json(updated);
}
catch {
res.status(500).json({ error: 'Failed to disqualify team' });
}
});
app.post('/:teamId/location', authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const { lat, lng } = req.body;
const team = await prisma.team.findUnique({
where: { id: teamId }
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
const member = await prisma.teamMember.findFirst({
where: { teamId, userId: req.user.id }
});
if (!member && team.captainId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = await prisma.team.update({
where: { id: teamId },
data: { lat, lng }
});
res.json(updated);
}
catch {
res.status(500).json({ error: 'Failed to update location' });
}
});
app.get('/:teamId', authenticate, async (req, res) => {
try {
const { teamId } = req.params;
const team = await prisma.team.findUnique({
where: { id: teamId },
include: {
members: { include: { user: { select: { id: true, name: true, email: true } } } },
captain: { select: { id: true, name: true } },
game: { include: { routes: { include: { routeLegs: { orderBy: { sequenceNumber: 'asc' } } } } } },
teamRoutes: {
include: {
route: {
include: {
routeLegs: { orderBy: { sequenceNumber: 'asc' } }
}
}
}
}
}
});
if (!team) {
return res.status(404).json({ error: 'Team not found' });
}
res.json(team);
}
catch {
res.status(500).json({ error: 'Failed to get team' });
}
});
return app;
}
(0, vitest_1.describe)('Teams API', () => {
let app;
let gameMasterToken;
let gameMasterId;
let playerToken;
let playerId;
let gameId;
let routeId;
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 gm = await prisma.user.create({
data: { email: 'gm@test.com', passwordHash, name: 'Game Master', isAdmin: true }
});
gameMasterId = gm.id;
gameMasterToken = jsonwebtoken_1.default.sign({ userId: gm.id }, JWT_SECRET, { expiresIn: '7d' });
const player = await prisma.user.create({
data: { email: 'player@test.com', passwordHash, name: 'Player' }
});
playerId = player.id;
playerToken = jsonwebtoken_1.default.sign({ userId: player.id }, JWT_SECRET, { expiresIn: '7d' });
const game = await prisma.game.create({
data: { name: 'Test Game', gameMasterId, status: 'LIVE' }
});
gameId = game.id;
const route = await prisma.route.create({
data: { name: 'Test Route', gameId }
});
routeId = route.id;
await prisma.routeLeg.createMany({
data: [
{ routeId, sequenceNumber: 1, description: 'First leg', conditionType: 'photo' },
{ routeId, sequenceNumber: 2, description: 'Second leg', conditionType: 'photo' },
{ routeId, sequenceNumber: 3, description: 'Final leg', conditionType: 'photo' }
]
});
});
(0, vitest_1.describe)('GET /game/:gameId', () => {
(0, vitest_1.it)('should list teams for a game', async () => {
await prisma.team.create({
data: { name: 'Team Alpha', gameId, captainId: gameMasterId }
});
const res = await (0, supertest_1.default)(app).get(`/game/${gameId}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.length).toBe(1);
(0, vitest_1.expect)(res.body[0].name).toBe('Team Alpha');
});
(0, vitest_1.it)('should return empty array for game with no teams', async () => {
const res = await (0, supertest_1.default)(app).get(`/game/${gameId}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body).toEqual([]);
});
});
(0, vitest_1.describe)('POST /game/:gameId', () => {
(0, vitest_1.it)('should create a team', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/game/${gameId}`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ name: 'New Team' });
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.name).toBe('New Team');
(0, vitest_1.expect)(res.body.captain.id).toBe(playerId);
(0, vitest_1.expect)(res.body.members.length).toBe(1);
});
(0, vitest_1.it)('should return 401 without token', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/game/${gameId}`)
.send({ name: 'New Team' });
(0, vitest_1.expect)(res.status).toBe(401);
});
(0, vitest_1.it)('should return 404 for non-existent game', async () => {
const res = await (0, supertest_1.default)(app)
.post('/game/non-existent-id')
.set('Authorization', `Bearer ${playerToken}`)
.send({ name: 'New Team' });
(0, vitest_1.expect)(res.status).toBe(404);
});
(0, vitest_1.it)('should not allow joining ended game', async () => {
await prisma.game.update({
where: { id: gameId },
data: { status: 'ENDED' }
});
const res = await (0, supertest_1.default)(app)
.post(`/game/${gameId}`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ name: 'Late Team' });
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('Cannot join game');
});
(0, vitest_1.it)('should not allow user already in a team', async () => {
await prisma.team.create({
data: { name: 'First Team', gameId, captainId: playerId }
});
await prisma.teamMember.create({
data: { teamId: (await prisma.team.findFirst({ where: { gameId } })).id, userId: playerId }
});
const res = await (0, supertest_1.default)(app)
.post(`/game/${gameId}`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ name: 'Second Team' });
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('Already in a team');
});
});
(0, vitest_1.describe)('POST /:teamId/join', () => {
let teamId;
(0, vitest_1.beforeEach)(async () => {
const team = await prisma.team.create({
data: { name: 'Joinable Team', gameId, captainId: gameMasterId }
});
teamId = team.id;
await prisma.teamMember.create({
data: { teamId, userId: gameMasterId }
});
});
(0, vitest_1.it)('should allow player to join team', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/join`)
.set('Authorization', `Bearer ${playerToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.message).toBe('Joined team successfully');
const members = await prisma.teamMember.findMany({ where: { teamId } });
(0, vitest_1.expect)(members.length).toBe(2);
});
(0, vitest_1.it)('should return 401 without token', async () => {
const res = await (0, supertest_1.default)(app).post(`/${teamId}/join`);
(0, vitest_1.expect)(res.status).toBe(401);
});
(0, vitest_1.it)('should return 404 for non-existent team', async () => {
const res = await (0, supertest_1.default)(app)
.post('/non-existent-team/join')
.set('Authorization', `Bearer ${playerToken}`);
(0, vitest_1.expect)(res.status).toBe(404);
});
(0, vitest_1.it)('should not allow joining full team', async () => {
for (let i = 0; i < 5; i++) {
const user = await prisma.user.create({
data: { email: `member${i}@test.com`, passwordHash: await bcryptjs_1.default.hash('pass', 10), name: `Member ${i}` }
});
await prisma.teamMember.create({
data: { teamId, userId: user.id }
});
}
const newPlayer = await prisma.user.create({
data: { email: 'overflow@test.com', passwordHash: await bcryptjs_1.default.hash('pass', 10), name: 'Overflow' }
});
const newToken = jsonwebtoken_1.default.sign({ userId: newPlayer.id }, JWT_SECRET);
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/join`)
.set('Authorization', `Bearer ${newToken}`);
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('Team is full');
});
(0, vitest_1.it)('should not allow joining same team twice', async () => {
await (0, supertest_1.default)(app)
.post(`/${teamId}/join`)
.set('Authorization', `Bearer ${playerToken}`);
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/join`)
.set('Authorization', `Bearer ${playerToken}`);
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('Already in this team');
});
});
(0, vitest_1.describe)('POST /:teamId/leave', () => {
let teamId;
(0, vitest_1.beforeEach)(async () => {
const team = await prisma.team.create({
data: { name: 'Leavable Team', gameId, captainId: gameMasterId }
});
teamId = team.id;
await prisma.teamMember.create({
data: { teamId, userId: gameMasterId }
});
await prisma.teamMember.create({
data: { teamId, userId: playerId }
});
});
(0, vitest_1.it)('should allow player to leave team', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/leave`)
.set('Authorization', `Bearer ${playerToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.message).toBe('Left team successfully');
const members = await prisma.teamMember.findMany({ where: { teamId } });
(0, vitest_1.expect)(members.length).toBe(1);
});
(0, vitest_1.it)('should return 401 without token', async () => {
const res = await (0, supertest_1.default)(app).post(`/${teamId}/leave`);
(0, vitest_1.expect)(res.status).toBe(401);
});
(0, vitest_1.it)('should not allow captain to leave', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/leave`)
.set('Authorization', `Bearer ${gameMasterToken}`);
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('Captain cannot leave');
});
});
(0, vitest_1.describe)('POST /:teamId/assign-route', () => {
let teamId;
(0, vitest_1.beforeEach)(async () => {
const team = await prisma.team.create({
data: { name: 'Route Team', gameId, captainId: gameMasterId }
});
teamId = team.id;
});
(0, vitest_1.it)('should assign route to team', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/assign-route`)
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ routeId });
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.route.id).toBe(routeId);
});
(0, vitest_1.it)('should return 403 for non-game-master', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/assign-route`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ routeId });
(0, vitest_1.expect)(res.status).toBe(403);
});
(0, vitest_1.it)('should return 404 for non-existent team', async () => {
const res = await (0, supertest_1.default)(app)
.post('/non-existent/assign-route')
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ routeId });
(0, vitest_1.expect)(res.status).toBe(404);
});
(0, vitest_1.it)('should return 400 for route from different game', async () => {
const otherGame = await prisma.game.create({
data: { name: 'Other Game', gameMasterId }
});
const otherRoute = await prisma.route.create({
data: { name: 'Other Route', gameId: otherGame.id }
});
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/assign-route`)
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ routeId: otherRoute.id });
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('Invalid route');
});
});
(0, vitest_1.describe)('POST /:teamId/advance', () => {
let teamId;
(0, vitest_1.beforeEach)(async () => {
const team = await prisma.team.create({
data: { name: 'Advancing Team', gameId, captainId: gameMasterId, status: 'ACTIVE', currentLegIndex: 0 }
});
teamId = team.id;
await prisma.teamRoute.create({
data: { teamId, routeId }
});
});
(0, vitest_1.it)('should advance team to next leg', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/advance`)
.set('Authorization', `Bearer ${gameMasterToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.currentLegIndex).toBe(1);
const updated = await prisma.team.findUnique({ where: { id: teamId } });
(0, vitest_1.expect)(updated.currentLegIndex).toBe(1);
});
(0, vitest_1.it)('should mark team as finished on last leg', async () => {
await prisma.team.update({
where: { id: teamId },
data: { currentLegIndex: 2 }
});
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/advance`)
.set('Authorization', `Bearer ${gameMasterToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.status).toBe('FINISHED');
});
(0, vitest_1.it)('should return 400 for team without route', async () => {
await prisma.teamRoute.deleteMany({ where: { teamId } });
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/advance`)
.set('Authorization', `Bearer ${gameMasterToken}`);
(0, vitest_1.expect)(res.status).toBe(400);
(0, vitest_1.expect)(res.body.error).toContain('no assigned route');
});
(0, vitest_1.it)('should return 403 for non-game-master', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/advance`)
.set('Authorization', `Bearer ${playerToken}`);
(0, vitest_1.expect)(res.status).toBe(403);
});
});
(0, vitest_1.describe)('POST /:teamId/deduct', () => {
let teamId;
(0, vitest_1.beforeEach)(async () => {
const team = await prisma.team.create({
data: { name: 'Time Team', gameId, captainId: gameMasterId, totalTimeDeduction: 0 }
});
teamId = team.id;
});
(0, vitest_1.it)('should deduct time from team', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/deduct`)
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ seconds: 120 });
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.totalTimeDeduction).toBe(120);
});
(0, vitest_1.it)('should use default deduction of 60 seconds', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/deduct`)
.set('Authorization', `Bearer ${gameMasterToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.totalTimeDeduction).toBe(60);
});
(0, vitest_1.it)('should return 403 for non-game-master', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/deduct`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ seconds: 30 });
(0, vitest_1.expect)(res.status).toBe(403);
});
});
(0, vitest_1.describe)('POST /:teamId/disqualify', () => {
let teamId;
(0, vitest_1.beforeEach)(async () => {
const team = await prisma.team.create({
data: { name: 'DQ Team', gameId, captainId: gameMasterId, status: 'ACTIVE' }
});
teamId = team.id;
});
(0, vitest_1.it)('should disqualify team', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/disqualify`)
.set('Authorization', `Bearer ${gameMasterToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.status).toBe('DISQUALIFIED');
});
(0, vitest_1.it)('should return 403 for non-game-master', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/disqualify`)
.set('Authorization', `Bearer ${playerToken}`);
(0, vitest_1.expect)(res.status).toBe(403);
});
(0, vitest_1.it)('should return 404 for non-existent team', async () => {
const res = await (0, supertest_1.default)(app)
.post('/non-existent/disqualify')
.set('Authorization', `Bearer ${gameMasterToken}`);
(0, vitest_1.expect)(res.status).toBe(404);
});
});
(0, vitest_1.describe)('POST /:teamId/location', () => {
let teamId;
(0, vitest_1.beforeEach)(async () => {
const team = await prisma.team.create({
data: { name: 'Location Team', gameId, captainId: gameMasterId }
});
teamId = team.id;
await prisma.teamMember.create({
data: { teamId, userId: playerId }
});
});
(0, vitest_1.it)('should update team location for member', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/location`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ lat: 40.7128, lng: -74.0060 });
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.lat).toBe(40.7128);
(0, vitest_1.expect)(res.body.lng).toBe(-74.0060);
});
(0, vitest_1.it)('should update team location for captain', async () => {
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/location`)
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ lat: 51.5074, lng: -0.1278 });
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.lat).toBe(51.5074);
});
(0, vitest_1.it)('should return 403 for non-member non-captain', async () => {
const outsider = await prisma.user.create({
data: { email: 'outsider@test.com', passwordHash: await bcryptjs_1.default.hash('pass', 10), name: 'Outsider' }
});
const outsiderToken = jsonwebtoken_1.default.sign({ userId: outsider.id }, JWT_SECRET);
const res = await (0, supertest_1.default)(app)
.post(`/${teamId}/location`)
.set('Authorization', `Bearer ${outsiderToken}`)
.send({ lat: 0, lng: 0 });
(0, vitest_1.expect)(res.status).toBe(403);
});
});
(0, vitest_1.describe)('GET /:teamId', () => {
let teamId;
(0, vitest_1.beforeEach)(async () => {
const team = await prisma.team.create({
data: { name: 'Get Team', gameId, captainId: gameMasterId }
});
teamId = team.id;
});
(0, vitest_1.it)('should get team details', async () => {
const res = await (0, supertest_1.default)(app)
.get(`/${teamId}`)
.set('Authorization', `Bearer ${playerToken}`);
(0, vitest_1.expect)(res.status).toBe(200);
(0, vitest_1.expect)(res.body.name).toBe('Get Team');
(0, vitest_1.expect)(res.body.captain.id).toBe(gameMasterId);
});
(0, vitest_1.it)('should return 401 without token', async () => {
const res = await (0, supertest_1.default)(app).get(`/${teamId}`);
(0, vitest_1.expect)(res.status).toBe(401);
});
(0, vitest_1.it)('should return 404 for non-existent team', async () => {
const res = await (0, supertest_1.default)(app)
.get('/non-existent')
.set('Authorization', `Bearer ${playerToken}`);
(0, vitest_1.expect)(res.status).toBe(404);
});
});
});

View file

@ -14,6 +14,7 @@ router.get('/me', auth_1.authenticate, async (req, res) => {
name: true,
screenName: true,
avatarUrl: true,
unitPreference: true,
createdAt: true
}
});
@ -29,13 +30,14 @@ router.get('/me', auth_1.authenticate, async (req, res) => {
});
router.put('/me', auth_1.authenticate, async (req, res) => {
try {
const { name, screenName, avatarUrl } = req.body;
const { name, screenName, avatarUrl, unitPreference } = req.body;
const updated = await index_1.prisma.user.update({
where: { id: req.user.id },
data: {
name: name || undefined,
screenName: screenName !== undefined ? screenName || null : undefined,
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined,
unitPreference: unitPreference || undefined
},
select: {
id: true,
@ -43,6 +45,7 @@ router.put('/me', auth_1.authenticate, async (req, res) => {
name: true,
screenName: true,
avatarUrl: true,
unitPreference: true,
createdAt: true
}
});

1
backend/dist/routes/users.test.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export {};

549
backend/dist/routes/users.test.js vendored Normal file
View file

@ -0,0 +1,549 @@
"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);
});
});
});