Added unit and api tests
This commit is contained in:
parent
9f4204cc73
commit
fedf1eb4c5
34 changed files with 9205 additions and 20 deletions
4
backend/dist/index.js
vendored
4
backend/dist/index.js
vendored
|
|
@ -15,6 +15,8 @@ const teams_1 = __importDefault(require("./routes/teams"));
|
|||
const routes_1 = __importDefault(require("./routes/routes"));
|
||||
const users_1 = __importDefault(require("./routes/users"));
|
||||
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 app = (0, express_1.default)();
|
||||
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/users', users_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) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
|
|
|||
2
backend/dist/middleware/auth.d.ts
vendored
2
backend/dist/middleware/auth.d.ts
vendored
|
|
@ -4,6 +4,8 @@ export interface AuthRequest extends Request {
|
|||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isAdmin?: boolean;
|
||||
isApiEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
export declare const authenticate: (req: AuthRequest, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>>>;
|
||||
|
|
|
|||
2
backend/dist/middleware/auth.js
vendored
2
backend/dist/middleware/auth.js
vendored
|
|
@ -17,7 +17,7 @@ const authenticate = async (req, res, next) => {
|
|||
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
|
||||
const user = await index_1.prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true }
|
||||
select: { id: true, email: true, name: true, isAdmin: true, isApiEnabled: true }
|
||||
});
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
|
|
|
|||
2
backend/dist/routes/admin.d.ts
vendored
Normal file
2
backend/dist/routes/admin.d.ts
vendored
Normal 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
213
backend/dist/routes/admin.js
vendored
Normal 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
2
backend/dist/routes/apikeys.d.ts
vendored
Normal 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
85
backend/dist/routes/apikeys.js
vendored
Normal 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;
|
||||
44
backend/dist/routes/auth.js
vendored
44
backend/dist/routes/auth.js
vendored
|
|
@ -11,22 +11,43 @@ const router = (0, express_1.Router)();
|
|||
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
const { email, password, name, inviteCode } = req.body;
|
||||
if (!email || !password || !name) {
|
||||
return res.status(400).json({ error: 'Email, password, and name are required' });
|
||||
}
|
||||
const settings = await index_1.prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' }
|
||||
});
|
||||
const isBanned = await index_1.prisma.bannedEmail.findUnique({
|
||||
where: { email: email.toLowerCase() }
|
||||
});
|
||||
if (isBanned) {
|
||||
return res.status(403).json({ error: 'This email is not allowed to register' });
|
||||
}
|
||||
if (settings && !settings.registrationEnabled) {
|
||||
if (!inviteCode || settings.inviteCode !== inviteCode) {
|
||||
return res.status(403).json({ error: 'Registration is currently closed. An invite code may be required.' });
|
||||
}
|
||||
}
|
||||
const existingUser = await index_1.prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: 'Email already registered' });
|
||||
}
|
||||
const passwordHash = await bcryptjs_1.default.hash(password, 10);
|
||||
const userCount = await index_1.prisma.user.count();
|
||||
const isFirstUser = userCount === 0;
|
||||
const user = await index_1.prisma.user.create({
|
||||
data: { email, passwordHash, name }
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
isAdmin: isFirstUser
|
||||
}
|
||||
});
|
||||
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, name: user.name }
|
||||
user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin }
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
|
|
@ -51,7 +72,7 @@ router.post('/login', async (req, res) => {
|
|||
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, name: user.name }
|
||||
user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin }
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
|
|
@ -59,6 +80,19 @@ router.post('/login', async (req, res) => {
|
|||
res.status(500).json({ error: 'Failed to login' });
|
||||
}
|
||||
});
|
||||
router.get('/registration-status', async (req, res) => {
|
||||
try {
|
||||
const settings = await index_1.prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' }
|
||||
});
|
||||
res.json({
|
||||
enabled: !settings || settings.registrationEnabled
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
res.json({ enabled: true });
|
||||
}
|
||||
});
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
|
@ -69,7 +103,7 @@ router.get('/me', async (req, res) => {
|
|||
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
|
||||
const user = await index_1.prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true, createdAt: true }
|
||||
select: { id: true, email: true, name: true, isAdmin: true, isApiEnabled: true, createdAt: true }
|
||||
});
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
|
|
|
|||
1
backend/dist/routes/auth.test.d.ts
vendored
Normal file
1
backend/dist/routes/auth.test.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
323
backend/dist/routes/auth.test.js
vendored
Normal file
323
backend/dist/routes/auth.test.js
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
backend/dist/routes/games.js
vendored
3
backend/dist/routes/games.js
vendored
|
|
@ -171,6 +171,9 @@ router.delete('/:id', auth_1.authenticate, async (req, res) => {
|
|||
if (game.gameMasterId !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
if (game.status !== 'DRAFT') {
|
||||
return res.status(400).json({ error: 'Only draft games can be deleted' });
|
||||
}
|
||||
await index_1.prisma.game.delete({ where: { id } });
|
||||
res.json({ message: 'Game deleted' });
|
||||
}
|
||||
|
|
|
|||
1
backend/dist/routes/games.test.d.ts
vendored
Normal file
1
backend/dist/routes/games.test.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
474
backend/dist/routes/games.test.js
vendored
Normal file
474
backend/dist/routes/games.test.js
vendored
Normal 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
1
backend/dist/routes/teams.test.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
835
backend/dist/routes/teams.test.js
vendored
Normal file
835
backend/dist/routes/teams.test.js
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
7
backend/dist/routes/users.js
vendored
7
backend/dist/routes/users.js
vendored
|
|
@ -14,6 +14,7 @@ router.get('/me', auth_1.authenticate, async (req, res) => {
|
|||
name: true,
|
||||
screenName: true,
|
||||
avatarUrl: true,
|
||||
unitPreference: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
|
@ -29,13 +30,14 @@ router.get('/me', auth_1.authenticate, async (req, res) => {
|
|||
});
|
||||
router.put('/me', auth_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { name, screenName, avatarUrl } = req.body;
|
||||
const { name, screenName, avatarUrl, unitPreference } = req.body;
|
||||
const updated = await index_1.prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: name || undefined,
|
||||
screenName: screenName !== undefined ? screenName || null : undefined,
|
||||
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined
|
||||
avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined,
|
||||
unitPreference: unitPreference || undefined
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
|
@ -43,6 +45,7 @@ router.put('/me', auth_1.authenticate, async (req, res) => {
|
|||
name: true,
|
||||
screenName: true,
|
||||
avatarUrl: true,
|
||||
unitPreference: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
|
|
|||
1
backend/dist/routes/users.test.d.ts
vendored
Normal file
1
backend/dist/routes/users.test.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
549
backend/dist/routes/users.test.js
vendored
Normal file
549
backend/dist/routes/users.test.js
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
16
backend/dist/socket/index.js
vendored
16
backend/dist/socket/index.js
vendored
|
|
@ -27,19 +27,27 @@ function setupSocket(io) {
|
|||
const chatMessage = await index_1.prisma.chatMessage.create({
|
||||
data: {
|
||||
gameId: data.gameId,
|
||||
teamId: data.teamId,
|
||||
teamId: data.teamId || null,
|
||||
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,
|
||||
teamId: data.teamId,
|
||||
isDirect: chatMessage.isDirect,
|
||||
userId: data.userId,
|
||||
userName: data.userName,
|
||||
message: data.message,
|
||||
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) => {
|
||||
io.to(`game:${data.gameId}`).emit('team-advanced', {
|
||||
|
|
|
|||
2074
backend/package-lock.json
generated
2074
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,10 +5,13 @@
|
|||
"scripts": {
|
||||
"dev": "nodemon --exec 'npx ts-node --transpile-only src/index.ts'",
|
||||
"build": "tsc",
|
||||
"build:test": "vitest run --no-file-parallelism && tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"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": [],
|
||||
"author": "",
|
||||
|
|
@ -32,11 +35,15 @@
|
|||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@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",
|
||||
"nodemon": "^3.1.9",
|
||||
"prisma": "^5.22.0",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.2"
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^4.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface AuthRequest extends Request {
|
|||
email: string;
|
||||
name: string;
|
||||
isAdmin?: boolean;
|
||||
isApiEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
|
|||
|
||||
const user = await prisma.user.findUnique({
|
||||
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) {
|
||||
|
|
|
|||
380
backend/src/routes/auth.test.ts
Normal file
380
backend/src/routes/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
520
backend/src/routes/games.test.ts
Normal file
520
backend/src/routes/games.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
999
backend/src/routes/teams.test.ts
Normal file
999
backend/src/routes/teams.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
611
backend/src/routes/users.test.ts
Normal file
611
backend/src/routes/users.test.ts
Normal 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
14
backend/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
1553
frontend/package-lock.json
generated
1553
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,8 +6,11 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:test": "vitest run && vue-tsc -b && vite build",
|
||||
"build:fast": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
|
|
@ -21,9 +24,12 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.1",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
206
frontend/src/components/Modal.test.ts
Normal file
206
frontend/src/components/Modal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
151
frontend/src/composables/useModal.test.ts
Normal file
151
frontend/src/composables/useModal.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { adminService } from '../services/api';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
|
|
|
|||
110
frontend/src/utils/units.test.ts
Normal file
110
frontend/src/utils/units.test.ts
Normal 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
16
frontend/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue