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

View file

@ -15,6 +15,8 @@ const teams_1 = __importDefault(require("./routes/teams"));
const routes_1 = __importDefault(require("./routes/routes")); const routes_1 = __importDefault(require("./routes/routes"));
const users_1 = __importDefault(require("./routes/users")); const users_1 = __importDefault(require("./routes/users"));
const upload_1 = __importDefault(require("./routes/upload")); const upload_1 = __importDefault(require("./routes/upload"));
const admin_1 = __importDefault(require("./routes/admin"));
const apikeys_1 = __importDefault(require("./routes/apikeys"));
const index_1 = __importDefault(require("./socket/index")); const index_1 = __importDefault(require("./socket/index"));
const app = (0, express_1.default)(); const app = (0, express_1.default)();
const httpServer = (0, http_1.createServer)(app); const httpServer = (0, http_1.createServer)(app);
@ -34,6 +36,8 @@ app.use('/api/teams', teams_1.default);
app.use('/api/routes', routes_1.default); app.use('/api/routes', routes_1.default);
app.use('/api/users', users_1.default); app.use('/api/users', users_1.default);
app.use('/api/upload', upload_1.default); app.use('/api/upload', upload_1.default);
app.use('/api/admin', admin_1.default);
app.use('/api', apikeys_1.default);
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ status: 'ok' }); res.json({ status: 'ok' });
}); });

View file

@ -4,6 +4,8 @@ export interface AuthRequest extends Request {
id: string; id: string;
email: string; email: string;
name: string; name: string;
isAdmin?: boolean;
isApiEnabled?: boolean;
}; };
} }
export declare const authenticate: (req: AuthRequest, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>>>; export declare const authenticate: (req: AuthRequest, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>>>;

View file

@ -17,7 +17,7 @@ const authenticate = async (req, res, next) => {
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET); const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
const user = await index_1.prisma.user.findUnique({ const user = await index_1.prisma.user.findUnique({
where: { id: decoded.userId }, where: { id: decoded.userId },
select: { id: true, email: true, name: true } select: { id: true, email: true, name: true, isAdmin: true, isApiEnabled: true }
}); });
if (!user) { if (!user) {
return res.status(401).json({ error: 'User not found' }); return res.status(401).json({ error: 'User not found' });

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'; const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
try { try {
const { email, password, name } = req.body; const { email, password, name, inviteCode } = req.body;
if (!email || !password || !name) { if (!email || !password || !name) {
return res.status(400).json({ error: 'Email, password, and name are required' }); 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 } }); const existingUser = await index_1.prisma.user.findUnique({ where: { email } });
if (existingUser) { if (existingUser) {
return res.status(400).json({ error: 'Email already registered' }); return res.status(400).json({ error: 'Email already registered' });
} }
const passwordHash = await bcryptjs_1.default.hash(password, 10); 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({ 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' }); const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({ res.json({
token, 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) { 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' }); const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
res.json({ res.json({
token, 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) { catch (error) {
@ -59,6 +80,19 @@ router.post('/login', async (req, res) => {
res.status(500).json({ error: 'Failed to login' }); 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) => { router.get('/me', async (req, res) => {
try { try {
const authHeader = req.headers.authorization; 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 decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
const user = await index_1.prisma.user.findUnique({ const user = await index_1.prisma.user.findUnique({
where: { id: decoded.userId }, 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) { if (!user) {
return res.status(401).json({ error: 'User not found' }); 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) { if (game.gameMasterId !== req.user.id) {
return res.status(403).json({ error: 'Not authorized' }); 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 } }); await index_1.prisma.game.delete({ where: { id } });
res.json({ message: 'Game deleted' }); 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, name: true,
screenName: true, screenName: true,
avatarUrl: true, avatarUrl: true,
unitPreference: true,
createdAt: 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) => { router.put('/me', auth_1.authenticate, async (req, res) => {
try { try {
const { name, screenName, avatarUrl } = req.body; const { name, screenName, avatarUrl, unitPreference } = req.body;
const updated = await index_1.prisma.user.update({ const updated = await index_1.prisma.user.update({
where: { id: req.user.id }, where: { id: req.user.id },
data: { data: {
name: name || undefined, name: name || undefined,
screenName: screenName !== undefined ? screenName || null : undefined, screenName: screenName !== undefined ? screenName || null : undefined,
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined,
unitPreference: unitPreference || undefined
}, },
select: { select: {
id: true, id: true,
@ -43,6 +45,7 @@ router.put('/me', auth_1.authenticate, async (req, res) => {
name: true, name: true,
screenName: true, screenName: true,
avatarUrl: true, avatarUrl: true,
unitPreference: true,
createdAt: 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);
});
});
});

View file

@ -27,19 +27,27 @@ function setupSocket(io) {
const chatMessage = await index_1.prisma.chatMessage.create({ const chatMessage = await index_1.prisma.chatMessage.create({
data: { data: {
gameId: data.gameId, gameId: data.gameId,
teamId: data.teamId, teamId: data.teamId || null,
userId: data.userId, userId: data.userId,
message: data.message message: data.message,
isDirect: data.isDirect || false
} }
}); });
io.to(`game:${data.gameId}`).emit('chat-message', { const messageData = {
id: chatMessage.id, id: chatMessage.id,
teamId: data.teamId, teamId: data.teamId,
isDirect: chatMessage.isDirect,
userId: data.userId, userId: data.userId,
userName: data.userName, userName: data.userName,
message: data.message, message: data.message,
sentAt: chatMessage.sentAt sentAt: chatMessage.sentAt
}); };
if (data.isDirect && data.teamId) {
io.to(`game:${data.gameId}`).emit('chat-message', messageData);
}
else {
io.to(`game:${data.gameId}`).emit('chat-message', messageData);
}
}); });
socket.on('team-advanced', async (data) => { socket.on('team-advanced', async (data) => {
io.to(`game:${data.gameId}`).emit('team-advanced', { io.to(`game:${data.gameId}`).emit('team-advanced', {

2074
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,10 +5,13 @@
"scripts": { "scripts": {
"dev": "nodemon --exec 'npx ts-node --transpile-only src/index.ts'", "dev": "nodemon --exec 'npx ts-node --transpile-only src/index.ts'",
"build": "tsc", "build": "tsc",
"build:test": "vitest run --no-file-parallelism && tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:migrate": "prisma migrate dev" "db:migrate": "prisma migrate dev",
"test": "vitest --no-file-parallelism",
"test:run": "vitest run --no-file-parallelism"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -32,11 +35,15 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.15.21", "@types/node": "^22.19.15",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"supertest": "^7.2.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.2" "tsx": "^4.21.0",
"typescript": "^5.8.2",
"vitest": "^4.1.1"
} }
} }

View file

@ -10,6 +10,7 @@ export interface AuthRequest extends Request {
email: string; email: string;
name: string; name: string;
isAdmin?: boolean; isAdmin?: boolean;
isApiEnabled?: boolean;
}; };
} }
@ -25,7 +26,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.userId }, where: { id: decoded.userId },
select: { id: true, email: true, name: true, isAdmin: true } select: { id: true, email: true, name: true, isAdmin: true, isApiEnabled: true }
}); });
if (!user) { if (!user) {

View file

@ -0,0 +1,380 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import express from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import request from 'supertest';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const JWT_SECRET = 'test-secret-key';
function createApp() {
const app = express();
app.use(express.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 bcrypt.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 = jwt.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 bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.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 = jwt.verify(token, JWT_SECRET) as { userId: string };
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;
}
describe('Auth Routes', () => {
let app: express.Express;
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();
}
beforeAll(async () => {
app = createApp();
await cleanup();
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
beforeEach(async () => {
await cleanup();
});
afterAll(async () => {
await prisma.user.deleteMany();
await prisma.systemSettings.deleteMany();
await prisma.bannedEmail.deleteMany();
await prisma.$disconnect();
});
beforeEach(async () => {
await prisma.user.deleteMany();
await prisma.systemSettings.deleteMany();
await prisma.bannedEmail.deleteMany();
});
describe('POST /register', () => {
it('should register a new user successfully', async () => {
const res = await request(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User'
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
expect(res.body.user).toHaveProperty('id');
expect(res.body.user.email).toBe('test@example.com');
expect(res.body.user.name).toBe('Test User');
});
it('should return error when email is missing', async () => {
const res = await request(app)
.post('/register')
.send({
password: 'password123',
name: 'Test User'
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('required');
});
it('should return error when email already exists', async () => {
await request(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User'
});
const res = await request(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password456',
name: 'Another User'
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('already registered');
});
it('should not register a banned email', async () => {
await prisma.bannedEmail.create({
data: { email: 'banned@example.com', reason: 'Test ban' }
});
const res = await request(app)
.post('/register')
.send({
email: 'banned@example.com',
password: 'password123',
name: 'Banned User'
});
expect(res.status).toBe(403);
expect(res.body.error).toContain('not allowed');
});
it('should require invite code when registration is disabled', async () => {
await prisma.systemSettings.create({
data: { id: 'default', registrationEnabled: false }
});
const res = await request(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User'
});
expect(res.status).toBe(403);
expect(res.body.error).toContain('invite code');
});
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 request(app)
.post('/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User',
inviteCode: 'VALID123'
});
expect(res.status).toBe(200);
});
});
describe('POST /login', () => {
beforeEach(async () => {
const passwordHash = await bcrypt.hash('password123', 10);
await prisma.user.create({
data: {
email: 'test@example.com',
passwordHash,
name: 'Test User'
}
});
});
it('should login successfully with valid credentials', async () => {
const res = await request(app)
.post('/login')
.send({
email: 'test@example.com',
password: 'password123'
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
expect(res.body.user.email).toBe('test@example.com');
});
it('should return error with invalid password', async () => {
const res = await request(app)
.post('/login')
.send({
email: 'test@example.com',
password: 'wrongpassword'
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('Invalid credentials');
});
it('should return error for non-existent user', async () => {
const res = await request(app)
.post('/login')
.send({
email: 'nonexistent@example.com',
password: 'password123'
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('Invalid credentials');
});
it('should return error when email is missing', async () => {
const res = await request(app)
.post('/login')
.send({
password: 'password123'
});
expect(res.status).toBe(400);
});
});
describe('GET /me', () => {
let token: string;
let userId: string;
beforeEach(async () => {
const passwordHash = await bcrypt.hash('password123', 10);
const user = await prisma.user.create({
data: {
email: 'test@example.com',
passwordHash,
name: 'Test User',
isAdmin: true
}
});
userId = user.id;
token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
});
it('should return user data with valid token', async () => {
const res = await request(app)
.get('/me')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(userId);
expect(res.body.email).toBe('test@example.com');
expect(res.body.isAdmin).toBe(true);
});
it('should return 401 without token', async () => {
const res = await request(app).get('/me');
expect(res.status).toBe(401);
});
it('should return 401 with invalid token', async () => {
const res = await request(app)
.get('/me')
.set('Authorization', 'Bearer invalid-token');
expect(res.status).toBe(401);
});
});
});

View file

@ -0,0 +1,520 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import express from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import request from 'supertest';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const JWT_SECRET = 'test-secret-key';
function createApp() {
const app = express();
app.use(express.json());
const authenticate = async (req: any, res: any, next: any) => {
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 = jwt.verify(token, JWT_SECRET) as { userId: string };
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: any = { visibility: 'PUBLIC' };
if (status) where.status = status;
if (search) where.name = { contains: search as string, 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: any, 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: any, 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: any, 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: any, 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: any, 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: any, 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: any) => 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: any, 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: any, 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;
}
describe('Games API', () => {
let app: express.Express;
let userToken: string;
let userId: string;
let otherToken: string;
let otherUserId: string;
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();
}
beforeAll(async () => {
app = createApp();
await cleanup();
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
beforeEach(async () => {
await cleanup();
const passwordHash = await bcrypt.hash('password123', 10);
const user = await prisma.user.create({
data: { email: 'owner@test.com', passwordHash, name: 'Owner User' }
});
userId = user.id;
userToken = jwt.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 = jwt.sign({ userId: otherUser.id }, JWT_SECRET, { expiresIn: '7d' });
});
describe('GET /games', () => {
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 request(app).get('/games');
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].name).toBe('Public Game');
});
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 request(app).get('/games?status=LIVE');
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].name).toBe('Live Game');
});
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 request(app).get('/games?search=treasure');
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].name).toBe('Treasure Hunt');
});
});
describe('GET /games/my-games', () => {
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 request(app)
.get('/games/my-games')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].name).toBe('My Game');
});
it('should return 401 without token', async () => {
const res = await request(app).get('/games/my-games');
expect(res.status).toBe(401);
});
});
describe('POST /games', () => {
it('should create a game', async () => {
const res = await request(app)
.post('/games')
.set('Authorization', `Bearer ${userToken}`)
.send({ name: 'New Game', description: 'A test game' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('New Game');
expect(res.body.gameMasterId).toBe(userId);
});
it('should require name', async () => {
const res = await request(app)
.post('/games')
.set('Authorization', `Bearer ${userToken}`)
.send({ description: 'No name' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Name is required');
});
it('should return 401 without token', async () => {
const res = await request(app)
.post('/games')
.send({ name: 'Test' });
expect(res.status).toBe(401);
});
});
describe('GET /games/:id', () => {
it('should get a game by id', async () => {
const game = await prisma.game.create({
data: { name: 'Test Game', gameMasterId: userId }
});
const res = await request(app).get(`/games/${game.id}`);
expect(res.status).toBe(200);
expect(res.body.name).toBe('Test Game');
});
it('should return 404 for non-existent game', async () => {
const res = await request(app).get('/games/non-existent-id');
expect(res.status).toBe(404);
});
});
describe('PUT /games/:id', () => {
it('should update a game', async () => {
const game = await prisma.game.create({
data: { name: 'Original Name', gameMasterId: userId }
});
const res = await request(app)
.put(`/games/${game.id}`)
.set('Authorization', `Bearer ${userToken}`)
.send({ name: 'Updated Name' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('Updated Name');
});
it('should not allow update by non-owner', async () => {
const game = await prisma.game.create({
data: { name: 'Test Game', gameMasterId: userId }
});
const res = await request(app)
.put(`/games/${game.id}`)
.set('Authorization', `Bearer ${otherToken}`)
.send({ name: 'Hacked' });
expect(res.status).toBe(403);
});
});
describe('DELETE /games/:id', () => {
it('should delete a draft game', async () => {
const game = await prisma.game.create({
data: { name: 'Draft Game', gameMasterId: userId, status: 'DRAFT' }
});
const res = await request(app)
.delete(`/games/${game.id}`)
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.message).toBe('Game deleted');
});
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 request(app)
.delete(`/games/${game.id}`)
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('Only draft');
});
});
describe('POST /games/:id/publish', () => {
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 request(app)
.post(`/games/${game.id}/publish`)
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('LIVE');
});
it('should not publish game without routes', async () => {
const game = await prisma.game.create({
data: { name: 'Empty Game', gameMasterId: userId }
});
const res = await request(app)
.post(`/games/${game.id}/publish`)
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('at least one route');
});
});
describe('POST /games/:id/end', () => {
it('should end a live game', async () => {
const game = await prisma.game.create({
data: { name: 'Live Game', gameMasterId: userId, status: 'LIVE' }
});
const res = await request(app)
.post(`/games/${game.id}/end`)
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('ENDED');
});
});
describe('POST /games/:id/archive', () => {
it('should archive an ended game', async () => {
const game = await prisma.game.create({
data: { name: 'Ended Game', gameMasterId: userId, status: 'ENDED' }
});
const res = await request(app)
.post(`/games/${game.id}/archive`)
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('ARCHIVED');
});
});
describe('GET /games/invite/:code', () => {
it('should find game by invite code', async () => {
await prisma.game.create({
data: { name: 'Invite Game', gameMasterId: userId, inviteCode: 'TESTCODE123' }
});
const res = await request(app).get('/games/invite/TESTCODE123');
expect(res.status).toBe(200);
expect(res.body.name).toBe('Invite Game');
});
it('should return 404 for invalid code', async () => {
const res = await request(app).get('/games/invite/INVALID');
expect(res.status).toBe(404);
});
});
});

View file

@ -0,0 +1,999 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import express from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import request from 'supertest';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const JWT_SECRET = 'test-secret-key';
function createApp() {
const app = express();
app.use(express.json());
const authenticate = async (req: any, res: any, next: any) => {
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 = jwt.verify(token, JWT_SECRET) as { userId: string };
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: any, res: any) => {
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: any, 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: any, 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: any, 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: any, 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: any, 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: any, 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: any, 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: any, 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: any, 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;
}
describe('Teams API', () => {
let app: express.Express;
let gameMasterToken: string;
let gameMasterId: string;
let playerToken: string;
let playerId: string;
let gameId: string;
let routeId: string;
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();
}
beforeAll(async () => {
app = createApp();
await cleanup();
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
beforeEach(async () => {
await cleanup();
const passwordHash = await bcrypt.hash('password123', 10);
const gm = await prisma.user.create({
data: { email: 'gm@test.com', passwordHash, name: 'Game Master', isAdmin: true }
});
gameMasterId = gm.id;
gameMasterToken = jwt.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 = jwt.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' }
]
});
});
describe('GET /game/:gameId', () => {
it('should list teams for a game', async () => {
await prisma.team.create({
data: { name: 'Team Alpha', gameId, captainId: gameMasterId }
});
const res = await request(app).get(`/game/${gameId}`);
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].name).toBe('Team Alpha');
});
it('should return empty array for game with no teams', async () => {
const res = await request(app).get(`/game/${gameId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe('POST /game/:gameId', () => {
it('should create a team', async () => {
const res = await request(app)
.post(`/game/${gameId}`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ name: 'New Team' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('New Team');
expect(res.body.captain.id).toBe(playerId);
expect(res.body.members.length).toBe(1);
});
it('should return 401 without token', async () => {
const res = await request(app)
.post(`/game/${gameId}`)
.send({ name: 'New Team' });
expect(res.status).toBe(401);
});
it('should return 404 for non-existent game', async () => {
const res = await request(app)
.post('/game/non-existent-id')
.set('Authorization', `Bearer ${playerToken}`)
.send({ name: 'New Team' });
expect(res.status).toBe(404);
});
it('should not allow joining ended game', async () => {
await prisma.game.update({
where: { id: gameId },
data: { status: 'ENDED' }
});
const res = await request(app)
.post(`/game/${gameId}`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ name: 'Late Team' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Cannot join game');
});
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 request(app)
.post(`/game/${gameId}`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ name: 'Second Team' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Already in a team');
});
});
describe('POST /:teamId/join', () => {
let teamId: string;
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 }
});
});
it('should allow player to join team', async () => {
const res = await request(app)
.post(`/${teamId}/join`)
.set('Authorization', `Bearer ${playerToken}`);
expect(res.status).toBe(200);
expect(res.body.message).toBe('Joined team successfully');
const members = await prisma.teamMember.findMany({ where: { teamId } });
expect(members.length).toBe(2);
});
it('should return 401 without token', async () => {
const res = await request(app).post(`/${teamId}/join`);
expect(res.status).toBe(401);
});
it('should return 404 for non-existent team', async () => {
const res = await request(app)
.post('/non-existent-team/join')
.set('Authorization', `Bearer ${playerToken}`);
expect(res.status).toBe(404);
});
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 bcrypt.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 bcrypt.hash('pass', 10), name: 'Overflow' }
});
const newToken = jwt.sign({ userId: newPlayer.id }, JWT_SECRET);
const res = await request(app)
.post(`/${teamId}/join`)
.set('Authorization', `Bearer ${newToken}`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('Team is full');
});
it('should not allow joining same team twice', async () => {
await request(app)
.post(`/${teamId}/join`)
.set('Authorization', `Bearer ${playerToken}`);
const res = await request(app)
.post(`/${teamId}/join`)
.set('Authorization', `Bearer ${playerToken}`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('Already in this team');
});
});
describe('POST /:teamId/leave', () => {
let teamId: string;
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 }
});
});
it('should allow player to leave team', async () => {
const res = await request(app)
.post(`/${teamId}/leave`)
.set('Authorization', `Bearer ${playerToken}`);
expect(res.status).toBe(200);
expect(res.body.message).toBe('Left team successfully');
const members = await prisma.teamMember.findMany({ where: { teamId } });
expect(members.length).toBe(1);
});
it('should return 401 without token', async () => {
const res = await request(app).post(`/${teamId}/leave`);
expect(res.status).toBe(401);
});
it('should not allow captain to leave', async () => {
const res = await request(app)
.post(`/${teamId}/leave`)
.set('Authorization', `Bearer ${gameMasterToken}`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('Captain cannot leave');
});
});
describe('POST /:teamId/assign-route', () => {
let teamId: string;
beforeEach(async () => {
const team = await prisma.team.create({
data: { name: 'Route Team', gameId, captainId: gameMasterId }
});
teamId = team.id;
});
it('should assign route to team', async () => {
const res = await request(app)
.post(`/${teamId}/assign-route`)
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ routeId });
expect(res.status).toBe(200);
expect(res.body.route.id).toBe(routeId);
});
it('should return 403 for non-game-master', async () => {
const res = await request(app)
.post(`/${teamId}/assign-route`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ routeId });
expect(res.status).toBe(403);
});
it('should return 404 for non-existent team', async () => {
const res = await request(app)
.post('/non-existent/assign-route')
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ routeId });
expect(res.status).toBe(404);
});
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 request(app)
.post(`/${teamId}/assign-route`)
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ routeId: otherRoute.id });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid route');
});
});
describe('POST /:teamId/advance', () => {
let teamId: string;
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 }
});
});
it('should advance team to next leg', async () => {
const res = await request(app)
.post(`/${teamId}/advance`)
.set('Authorization', `Bearer ${gameMasterToken}`);
expect(res.status).toBe(200);
expect(res.body.currentLegIndex).toBe(1);
const updated = await prisma.team.findUnique({ where: { id: teamId } });
expect(updated!.currentLegIndex).toBe(1);
});
it('should mark team as finished on last leg', async () => {
await prisma.team.update({
where: { id: teamId },
data: { currentLegIndex: 2 }
});
const res = await request(app)
.post(`/${teamId}/advance`)
.set('Authorization', `Bearer ${gameMasterToken}`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('FINISHED');
});
it('should return 400 for team without route', async () => {
await prisma.teamRoute.deleteMany({ where: { teamId } });
const res = await request(app)
.post(`/${teamId}/advance`)
.set('Authorization', `Bearer ${gameMasterToken}`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('no assigned route');
});
it('should return 403 for non-game-master', async () => {
const res = await request(app)
.post(`/${teamId}/advance`)
.set('Authorization', `Bearer ${playerToken}`);
expect(res.status).toBe(403);
});
});
describe('POST /:teamId/deduct', () => {
let teamId: string;
beforeEach(async () => {
const team = await prisma.team.create({
data: { name: 'Time Team', gameId, captainId: gameMasterId, totalTimeDeduction: 0 }
});
teamId = team.id;
});
it('should deduct time from team', async () => {
const res = await request(app)
.post(`/${teamId}/deduct`)
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ seconds: 120 });
expect(res.status).toBe(200);
expect(res.body.totalTimeDeduction).toBe(120);
});
it('should use default deduction of 60 seconds', async () => {
const res = await request(app)
.post(`/${teamId}/deduct`)
.set('Authorization', `Bearer ${gameMasterToken}`);
expect(res.status).toBe(200);
expect(res.body.totalTimeDeduction).toBe(60);
});
it('should return 403 for non-game-master', async () => {
const res = await request(app)
.post(`/${teamId}/deduct`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ seconds: 30 });
expect(res.status).toBe(403);
});
});
describe('POST /:teamId/disqualify', () => {
let teamId: string;
beforeEach(async () => {
const team = await prisma.team.create({
data: { name: 'DQ Team', gameId, captainId: gameMasterId, status: 'ACTIVE' }
});
teamId = team.id;
});
it('should disqualify team', async () => {
const res = await request(app)
.post(`/${teamId}/disqualify`)
.set('Authorization', `Bearer ${gameMasterToken}`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('DISQUALIFIED');
});
it('should return 403 for non-game-master', async () => {
const res = await request(app)
.post(`/${teamId}/disqualify`)
.set('Authorization', `Bearer ${playerToken}`);
expect(res.status).toBe(403);
});
it('should return 404 for non-existent team', async () => {
const res = await request(app)
.post('/non-existent/disqualify')
.set('Authorization', `Bearer ${gameMasterToken}`);
expect(res.status).toBe(404);
});
});
describe('POST /:teamId/location', () => {
let teamId: string;
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 }
});
});
it('should update team location for member', async () => {
const res = await request(app)
.post(`/${teamId}/location`)
.set('Authorization', `Bearer ${playerToken}`)
.send({ lat: 40.7128, lng: -74.0060 });
expect(res.status).toBe(200);
expect(res.body.lat).toBe(40.7128);
expect(res.body.lng).toBe(-74.0060);
});
it('should update team location for captain', async () => {
const res = await request(app)
.post(`/${teamId}/location`)
.set('Authorization', `Bearer ${gameMasterToken}`)
.send({ lat: 51.5074, lng: -0.1278 });
expect(res.status).toBe(200);
expect(res.body.lat).toBe(51.5074);
});
it('should return 403 for non-member non-captain', async () => {
const outsider = await prisma.user.create({
data: { email: 'outsider@test.com', passwordHash: await bcrypt.hash('pass', 10), name: 'Outsider' }
});
const outsiderToken = jwt.sign({ userId: outsider.id }, JWT_SECRET);
const res = await request(app)
.post(`/${teamId}/location`)
.set('Authorization', `Bearer ${outsiderToken}`)
.send({ lat: 0, lng: 0 });
expect(res.status).toBe(403);
});
});
describe('GET /:teamId', () => {
let teamId: string;
beforeEach(async () => {
const team = await prisma.team.create({
data: { name: 'Get Team', gameId, captainId: gameMasterId }
});
teamId = team.id;
});
it('should get team details', async () => {
const res = await request(app)
.get(`/${teamId}`)
.set('Authorization', `Bearer ${playerToken}`);
expect(res.status).toBe(200);
expect(res.body.name).toBe('Get Team');
expect(res.body.captain.id).toBe(gameMasterId);
});
it('should return 401 without token', async () => {
const res = await request(app).get(`/${teamId}`);
expect(res.status).toBe(401);
});
it('should return 404 for non-existent team', async () => {
const res = await request(app)
.get('/non-existent')
.set('Authorization', `Bearer ${playerToken}`);
expect(res.status).toBe(404);
});
});
});

View file

@ -0,0 +1,611 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import express from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import request from 'supertest';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const JWT_SECRET = 'test-secret-key';
function createApp() {
const app = express();
app.use(express.json());
const authenticate = async (req: any, res: any, next: any) => {
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 = jwt.verify(token, JWT_SECRET) as { userId: string };
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: any, res: any) => {
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: any, res: any) => {
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: any, res: any) => {
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: any, res: any) => {
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: any, res: any) => {
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: any, res: any) => {
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;
}
describe('Users API', () => {
let app: express.Express;
let userToken: string;
let userId: string;
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();
}
beforeAll(async () => {
app = createApp();
await cleanup();
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
beforeEach(async () => {
await cleanup();
const passwordHash = await bcrypt.hash('password123', 10);
const user = await prisma.user.create({
data: {
email: 'testuser@test.com',
passwordHash,
name: 'Test User',
unitPreference: 'METRIC'
}
});
userId = user.id;
userToken = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
});
describe('GET /me', () => {
it('should get current user profile', async () => {
const res = await request(app)
.get('/me')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(userId);
expect(res.body.email).toBe('testuser@test.com');
expect(res.body.name).toBe('Test User');
expect(res.body.unitPreference).toBe('METRIC');
});
it('should return 401 without token', async () => {
const res = await request(app).get('/me');
expect(res.status).toBe(401);
});
it('should return 401 with invalid token', async () => {
const res = await request(app)
.get('/me')
.set('Authorization', 'Bearer invalid-token');
expect(res.status).toBe(401);
});
});
describe('PUT /me', () => {
it('should update user name', async () => {
const res = await request(app)
.put('/me')
.set('Authorization', `Bearer ${userToken}`)
.send({ name: 'Updated Name' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('Updated Name');
expect(res.body.email).toBe('testuser@test.com');
});
it('should update screen name', async () => {
const res = await request(app)
.put('/me')
.set('Authorization', `Bearer ${userToken}`)
.send({ screenName: 'CoolPlayer' });
expect(res.status).toBe(200);
expect(res.body.screenName).toBe('CoolPlayer');
});
it('should update avatar URL', async () => {
const res = await request(app)
.put('/me')
.set('Authorization', `Bearer ${userToken}`)
.send({ avatarUrl: 'https://example.com/avatar.png' });
expect(res.status).toBe(200);
expect(res.body.avatarUrl).toBe('https://example.com/avatar.png');
});
it('should update unit preference to imperial', async () => {
const res = await request(app)
.put('/me')
.set('Authorization', `Bearer ${userToken}`)
.send({ unitPreference: 'IMPERIAL' });
expect(res.status).toBe(200);
expect(res.body.unitPreference).toBe('IMPERIAL');
});
it('should allow clearing optional fields with empty string', async () => {
await prisma.user.update({
where: { id: userId },
data: { screenName: 'HasScreenName' }
});
const res = await request(app)
.put('/me')
.set('Authorization', `Bearer ${userToken}`)
.send({ screenName: '' });
expect(res.status).toBe(200);
expect(res.body.screenName).toBe(null);
});
it('should update multiple fields at once', async () => {
const res = await request(app)
.put('/me')
.set('Authorization', `Bearer ${userToken}`)
.send({
name: 'Multi Update',
screenName: 'Multi',
unitPreference: 'IMPERIAL'
});
expect(res.status).toBe(200);
expect(res.body.name).toBe('Multi Update');
expect(res.body.screenName).toBe('Multi');
expect(res.body.unitPreference).toBe('IMPERIAL');
});
it('should return 401 without token', async () => {
const res = await request(app)
.put('/me')
.send({ name: 'Hacker' });
expect(res.status).toBe(401);
});
});
describe('GET /me/location-history', () => {
it('should return location history summary', async () => {
const res = await request(app)
.get('/me/location-history')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('totalLocations');
expect(res.body).toHaveProperty('byGame');
expect(res.body.totalLocations).toBe(0);
expect(res.body.byGame).toEqual([]);
});
it('should include location history with game info', async () => {
const gm = await prisma.user.create({
data: {
email: 'gm@test.com',
passwordHash: await bcrypt.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 request(app)
.get('/me/location-history')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.totalLocations).toBe(1);
expect(res.body.byGame.length).toBe(1);
expect(res.body.byGame[0].game.name).toBe('Location Game');
expect(res.body.byGame[0].locationCount).toBe(1);
});
it('should return 401 without token', async () => {
const res = await request(app).get('/me/location-history');
expect(res.status).toBe(401);
});
});
describe('GET /me/games', () => {
it('should return empty array when user has no games', async () => {
const res = await request(app)
.get('/me/games')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('should return user games with details', async () => {
const gm = await prisma.user.create({
data: {
email: 'gm@test.com',
passwordHash: await bcrypt.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 request(app)
.get('/me/games')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].gameName).toBe('My Game');
expect(res.body[0].teamName).toBe('My Team');
expect(res.body[0].routeName).toBe('My Route');
expect(res.body[0].totalLegs).toBe(1);
expect(res.body[0].teamStatus).toBe('ACTIVE');
});
it('should return 401 without token', async () => {
const res = await request(app).get('/me/games');
expect(res.status).toBe(401);
});
});
describe('DELETE /me/location-data', () => {
it('should delete user location history', async () => {
const gm = await prisma.user.create({
data: {
email: 'gm@test.com',
passwordHash: await bcrypt.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 request(app)
.delete('/me/location-data')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.message).toBe('Location data deleted');
const locations = await prisma.locationHistory.count({
where: { userId }
});
expect(locations).toBe(0);
});
it('should return 401 without token', async () => {
const res = await request(app).delete('/me/location-data');
expect(res.status).toBe(401);
});
});
describe('DELETE /me/account', () => {
it('should delete user account', async () => {
const res = await request(app)
.delete('/me/account')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(200);
expect(res.body.message).toBe('Account deleted');
const user = await prisma.user.findUnique({ where: { id: userId } });
expect(user).toBeNull();
});
it('should return 401 without token', async () => {
const res = await request(app).delete('/me/account');
expect(res.status).toBe(401);
});
it('should not allow login after account deletion', async () => {
await request(app)
.delete('/me/account')
.set('Authorization', `Bearer ${userToken}`);
const res = await request(app)
.get('/me')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(401);
});
});
});

14
backend/vitest.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.ts', '!src/**/*.d.ts'],
},
},
});

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,11 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"build:test": "vitest run && vue-tsc -b && vite build",
"build:fast": "vite build", "build:fast": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
@ -21,9 +24,12 @@
"devDependencies": { "devDependencies": {
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.0", "@vue/tsconfig": "^0.9.0",
"jsdom": "^29.0.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.0", "vite": "^8.0.0",
"vitest": "^4.1.1",
"vue-tsc": "^3.2.5" "vue-tsc": "^3.2.5"
} }
} }

View file

@ -0,0 +1,206 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import Modal from './Modal.vue';
describe('Modal Component', () => {
describe('Rendering', () => {
it('renders when open is true', () => {
const wrapper = mount(Modal, {
props: { open: true }
});
expect(wrapper.find('dialog').exists()).toBe(true);
});
it('dialog is not visible when open is false', () => {
const wrapper = mount(Modal, {
props: { open: false }
});
const dialog = wrapper.find('dialog');
expect(dialog.exists()).toBe(true);
expect(dialog.attributes('open')).toBeUndefined();
});
it('displays title when provided', () => {
const wrapper = mount(Modal, {
props: { open: true, title: 'Test Title' }
});
expect(wrapper.find('header h3').text()).toBe('Test Title');
});
it('does not show header when title is empty', () => {
const wrapper = mount(Modal, {
props: { open: true, title: '' }
});
expect(wrapper.find('header').exists()).toBe(false);
});
it('displays message when provided', () => {
const wrapper = mount(Modal, {
props: { open: true, message: 'Test message content' }
});
expect(wrapper.find('p').text()).toBe('Test message content');
});
it('renders slot content', () => {
const wrapper = mount(Modal, {
props: { open: true },
slots: {
default: h('div', { class: 'custom-content' }, 'Custom slot')
}
});
expect(wrapper.find('.custom-content').text()).toBe('Custom slot');
});
});
describe('Alert Mode', () => {
it('shows only confirm button in alert mode', () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'alert' }
});
const buttons = wrapper.findAll('footer button');
expect(buttons.length).toBe(1);
});
it('uses default confirm text "OK"', () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'alert' }
});
expect(wrapper.find('footer button').text()).toBe('OK');
});
it('uses custom confirm text when provided', () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'alert', confirmText: 'Got it' }
});
expect(wrapper.find('footer button').text()).toBe('Got it');
});
});
describe('Confirm Mode', () => {
it('shows both confirm and cancel buttons', () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'confirm' }
});
const buttons = wrapper.findAll('footer button');
expect(buttons.length).toBe(2);
});
it('shows cancel button first', () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'confirm' }
});
const buttons = wrapper.findAll('footer button');
expect(buttons[0].classes()).toContain('secondary');
expect(buttons[0].text()).toBe('Cancel');
});
it('uses custom cancel text', () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'confirm', cancelText: 'No Way' }
});
const buttons = wrapper.findAll('footer button');
expect(buttons[0].text()).toBe('No Way');
});
it('confirm button is not secondary by default', () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'confirm' }
});
const confirmBtn = wrapper.findAll('footer button')[1];
expect(confirmBtn.classes()).not.toContain('secondary');
});
});
describe('Danger Mode', () => {
it('confirm button has secondary class when danger is true', () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'confirm', danger: true }
});
const confirmBtn = wrapper.findAll('footer button')[1];
expect(confirmBtn.classes()).toContain('secondary');
});
});
describe('Events', () => {
it('emits confirm event when confirm button clicked', async () => {
const wrapper = mount(Modal, {
props: { open: true }
});
await wrapper.find('footer button').trigger('click');
expect(wrapper.emitted('confirm')).toBeTruthy();
});
it('emits cancel event when cancel button clicked', async () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'confirm' }
});
await wrapper.findAll('footer button')[0].trigger('click');
expect(wrapper.emitted('cancel')).toBeTruthy();
});
it('emits update:open false when confirm clicked', async () => {
const wrapper = mount(Modal, {
props: { open: true },
attrs: { 'onUpdate:open': vi.fn() }
});
await wrapper.find('footer button').trigger('click');
expect(wrapper.emitted()['update:open']).toBeTruthy();
expect(wrapper.emitted()['update:open'][0]).toEqual([false]);
});
it('emits update:open false when cancel clicked', async () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'confirm' },
attrs: { 'onUpdate:open': vi.fn() }
});
await wrapper.findAll('footer button')[0].trigger('click');
expect(wrapper.emitted()['update:open']).toBeTruthy();
});
});
describe('Keyboard Handling', () => {
it('emits update:open when Escape is pressed', async () => {
const wrapper = mount(Modal, {
props: { open: true }
});
await wrapper.trigger('keydown', { key: 'Escape' });
expect(wrapper.emitted()['update:open']).toBeTruthy();
});
it('emits cancel and closes on backdrop click in confirm mode', async () => {
const wrapper = mount(Modal, {
props: { open: true, type: 'confirm' }
});
await wrapper.find('dialog').trigger('click.self');
expect(wrapper.emitted()['cancel']).toBeTruthy();
expect(wrapper.emitted()['update:open']).toBeTruthy();
});
});
});

View file

@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { state, handleConfirm, handleCancel, alert, confirm } from './useModal';
describe('useModal Composable', () => {
beforeEach(() => {
state.open = false;
state.title = '';
state.message = '';
state.type = 'alert';
state.confirmText = 'OK';
state.cancelText = 'Cancel';
state.danger = false;
state.resolve = null;
});
describe('alert', () => {
it('should set state for alert dialog', async () => {
const alertPromise = alert('Test message', 'Test Title');
expect(state.open).toBe(true);
expect(state.message).toBe('Test message');
expect(state.title).toBe('Test Title');
expect(state.type).toBe('alert');
handleConfirm();
await alertPromise;
});
it('should use default title when not provided', async () => {
const alertPromise = alert('Message only');
expect(state.title).toBe('');
handleConfirm();
await alertPromise;
});
});
describe('confirm', () => {
it('should set state for confirm dialog', async () => {
const confirmPromise = confirm('Are you sure?', 'Confirm Action');
expect(state.open).toBe(true);
expect(state.message).toBe('Are you sure?');
expect(state.title).toBe('Confirm Action');
expect(state.type).toBe('confirm');
handleConfirm();
const result = await confirmPromise;
expect(result).toBe(true);
});
it('should set danger mode', async () => {
const confirmPromise = confirm('Delete this?', 'Danger', true);
expect(state.danger).toBe(true);
handleCancel();
const result = await confirmPromise;
expect(result).toBe(false);
});
it('should resolve true on confirm', async () => {
const confirmPromise = confirm('Continue?');
handleConfirm();
const result = await confirmPromise;
expect(result).toBe(true);
});
it('should resolve false on cancel', async () => {
const confirmPromise = confirm('Cancel this?');
handleCancel();
const result = await confirmPromise;
expect(result).toBe(false);
});
});
describe('handleConfirm', () => {
it('should resolve promise with true', async () => {
const confirmPromise = confirm('Test');
handleConfirm();
const result = await confirmPromise;
expect(result).toBe(true);
});
it('should close the modal', () => {
confirm('Test');
handleConfirm();
expect(state.open).toBe(false);
});
it('should not throw when resolve is null', () => {
state.resolve = null;
expect(() => handleConfirm()).not.toThrow();
});
});
describe('handleCancel', () => {
it('should resolve promise with false', async () => {
const confirmPromise = confirm('Test');
handleCancel();
const result = await confirmPromise;
expect(result).toBe(false);
});
it('should close the modal', () => {
confirm('Test');
handleCancel();
expect(state.open).toBe(false);
});
it('should not throw when resolve is null', () => {
state.resolve = null;
expect(() => handleCancel()).not.toThrow();
});
});
describe('state defaults', () => {
it('should have correct default values', () => {
expect(state.confirmText).toBe('OK');
expect(state.cancelText).toBe('Cancel');
expect(state.type).toBe('alert');
expect(state.danger).toBe(false);
});
it('should use custom confirm text', async () => {
confirm('Test', 'Title');
expect(state.confirmText).toBe('OK');
state.confirmText = 'Yes';
expect(state.confirmText).toBe('Yes');
});
it('should use custom cancel text', async () => {
confirm('Test', 'Title');
expect(state.cancelText).toBe('Cancel');
});
});
});

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { adminService } from '../services/api'; import { adminService } from '../services/api';
import { alert, confirm } from '../composables/useModal'; import { alert, confirm } from '../composables/useModal';

View file

@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { formatDistance, formatRadius, kmToMiles, metersToFeet, formatDistanceShort } from './units';
describe('Units Conversion', () => {
describe('kmToMiles', () => {
it('converts 1 km to approximately 0.621 miles', () => {
expect(kmToMiles(1)).toBeCloseTo(0.621371, 5);
});
it('converts 10 km to approximately 6.214 miles', () => {
expect(kmToMiles(10)).toBeCloseTo(6.21371, 4);
});
it('handles zero', () => {
expect(kmToMiles(0)).toBe(0);
});
it('handles fractional values', () => {
expect(kmToMiles(0.5)).toBeCloseTo(0.3106855, 4);
});
});
describe('metersToFeet', () => {
it('converts 1 meter to approximately 3.281 feet', () => {
expect(metersToFeet(1)).toBeCloseTo(3.28084, 4);
});
it('converts 100 meters to approximately 328 feet', () => {
expect(metersToFeet(100)).toBeCloseTo(328.084, 2);
});
it('handles zero', () => {
expect(metersToFeet(0)).toBe(0);
});
});
});
describe('Distance Formatting', () => {
describe('formatDistance (metric)', () => {
it('formats small distances (<1km) in meters', () => {
expect(formatDistance(0.1, 'METRIC')).toBe('100 m');
expect(formatDistance(0.5, 'METRIC')).toBe('500 m');
expect(formatDistance(0.05, 'METRIC')).toBe('50 m');
});
it('formats distances >= 1km in kilometers', () => {
expect(formatDistance(1, 'METRIC')).toBe('1.00 km');
expect(formatDistance(2.5, 'METRIC')).toBe('2.50 km');
});
});
describe('formatDistance (imperial)', () => {
it('formats small distances in feet', () => {
const result = formatDistance(0.01, 'IMPERIAL');
expect(result).toMatch(/^\d+ ft$/);
});
it('formats larger distances in miles', () => {
expect(formatDistance(1, 'IMPERIAL')).toBe('0.62 mi');
});
});
describe('formatRadius', () => {
it('formats small metric radii in meters', () => {
expect(formatRadius(100, 'METRIC')).toBe('100 m');
expect(formatRadius(500, 'METRIC')).toBe('500 m');
});
it('formats larger metric radii in kilometers', () => {
expect(formatRadius(1500, 'METRIC')).toBe('1.5 km');
});
it('formats small imperial radii in feet', () => {
const result = formatRadius(500, 'IMPERIAL');
expect(result).toMatch(/^\d+ ft$/);
});
it('formats larger imperial radii in miles', () => {
const result = formatRadius(8000, 'IMPERIAL');
expect(result).toMatch(/^\d+\.\d+ mi$/);
});
});
describe('formatDistanceShort', () => {
it('formats small metric distances in meters', () => {
expect(formatDistanceShort(0.1, 'METRIC')).toBe('100 m');
expect(formatDistanceShort(0.5, 'METRIC')).toBe('500 m');
});
it('formats larger metric distances in km with 1 decimal', () => {
expect(formatDistanceShort(1, 'METRIC')).toBe('1.0 km');
expect(formatDistanceShort(2.5, 'METRIC')).toBe('2.5 km');
});
it('formats small imperial distances in feet', () => {
expect(formatDistanceShort(0.005, 'IMPERIAL')).toBe('16 ft');
expect(formatDistanceShort(0.02, 'IMPERIAL')).toBe('66 ft');
});
it('formats larger imperial distances in miles with 1 decimal', () => {
expect(formatDistanceShort(1, 'IMPERIAL')).toBe('0.6 mi');
expect(formatDistanceShort(5.5, 'IMPERIAL')).toBe('3.4 mi');
});
it('handles zero', () => {
expect(formatDistanceShort(0, 'METRIC')).toBe('0 m');
expect(formatDistanceShort(0, 'IMPERIAL')).toBe('0 ft');
});
});
});

16
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.{ts,vue}', '!src/**/*.d.ts'],
},
},
});