diff --git a/backend/dist/index.js b/backend/dist/index.js index 193bcc6..1fbb3f3 100644 --- a/backend/dist/index.js +++ b/backend/dist/index.js @@ -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' }); }); diff --git a/backend/dist/middleware/auth.d.ts b/backend/dist/middleware/auth.d.ts index d9a995b..3eaf75d 100644 --- a/backend/dist/middleware/auth.d.ts +++ b/backend/dist/middleware/auth.d.ts @@ -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>>; diff --git a/backend/dist/middleware/auth.js b/backend/dist/middleware/auth.js index a7d6936..df70f9f 100644 --- a/backend/dist/middleware/auth.js +++ b/backend/dist/middleware/auth.js @@ -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' }); diff --git a/backend/dist/routes/admin.d.ts b/backend/dist/routes/admin.d.ts new file mode 100644 index 0000000..ae2ab41 --- /dev/null +++ b/backend/dist/routes/admin.d.ts @@ -0,0 +1,2 @@ +declare const router: import("express-serve-static-core").Router; +export default router; diff --git a/backend/dist/routes/admin.js b/backend/dist/routes/admin.js new file mode 100644 index 0000000..9d0986d --- /dev/null +++ b/backend/dist/routes/admin.js @@ -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; diff --git a/backend/dist/routes/apikeys.d.ts b/backend/dist/routes/apikeys.d.ts new file mode 100644 index 0000000..ae2ab41 --- /dev/null +++ b/backend/dist/routes/apikeys.d.ts @@ -0,0 +1,2 @@ +declare const router: import("express-serve-static-core").Router; +export default router; diff --git a/backend/dist/routes/apikeys.js b/backend/dist/routes/apikeys.js new file mode 100644 index 0000000..675a18b --- /dev/null +++ b/backend/dist/routes/apikeys.js @@ -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; diff --git a/backend/dist/routes/auth.js b/backend/dist/routes/auth.js index bbe6dcf..a8bbbd9 100644 --- a/backend/dist/routes/auth.js +++ b/backend/dist/routes/auth.js @@ -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' }); diff --git a/backend/dist/routes/auth.test.d.ts b/backend/dist/routes/auth.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/backend/dist/routes/auth.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/backend/dist/routes/auth.test.js b/backend/dist/routes/auth.test.js new file mode 100644 index 0000000..f77c165 --- /dev/null +++ b/backend/dist/routes/auth.test.js @@ -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); + }); + }); +}); diff --git a/backend/dist/routes/games.js b/backend/dist/routes/games.js index a0cd6f6..7178ac7 100644 --- a/backend/dist/routes/games.js +++ b/backend/dist/routes/games.js @@ -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' }); } diff --git a/backend/dist/routes/games.test.d.ts b/backend/dist/routes/games.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/backend/dist/routes/games.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/backend/dist/routes/games.test.js b/backend/dist/routes/games.test.js new file mode 100644 index 0000000..934db76 --- /dev/null +++ b/backend/dist/routes/games.test.js @@ -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); + }); + }); +}); diff --git a/backend/dist/routes/teams.test.d.ts b/backend/dist/routes/teams.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/backend/dist/routes/teams.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/backend/dist/routes/teams.test.js b/backend/dist/routes/teams.test.js new file mode 100644 index 0000000..0c4973b --- /dev/null +++ b/backend/dist/routes/teams.test.js @@ -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); + }); + }); +}); diff --git a/backend/dist/routes/users.js b/backend/dist/routes/users.js index a7cfe62..a92136c 100644 --- a/backend/dist/routes/users.js +++ b/backend/dist/routes/users.js @@ -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 } }); diff --git a/backend/dist/routes/users.test.d.ts b/backend/dist/routes/users.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/backend/dist/routes/users.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/backend/dist/routes/users.test.js b/backend/dist/routes/users.test.js new file mode 100644 index 0000000..b56ddee --- /dev/null +++ b/backend/dist/routes/users.test.js @@ -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); + }); + }); +}); diff --git a/backend/dist/socket/index.js b/backend/dist/socket/index.js index b0a5354..302567c 100644 --- a/backend/dist/socket/index.js +++ b/backend/dist/socket/index.js @@ -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', { diff --git a/backend/package-lock.json b/backend/package-lock.json index 3d0058f..5850bd8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,12 +26,16 @@ "@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" } }, "node_modules/@cspotcode/source-map-support": { @@ -47,6 +51,482 @@ "node": ">=12" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -75,6 +555,56 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", @@ -143,12 +673,281 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -177,6 +976,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -195,6 +1005,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -205,6 +1026,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -214,6 +1042,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -258,6 +1100,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -319,6 +1168,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -335,6 +1208,119 @@ "@types/node": "*" } }, + "node_modules/@vitest/expect": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.1", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -407,6 +1393,30 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -556,6 +1566,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -581,6 +1601,29 @@ "fsevents": "~2.3.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -617,6 +1660,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -632,6 +1682,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -671,6 +1728,16 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -690,6 +1757,27 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -821,6 +1909,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -833,12 +1928,80 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -848,6 +2011,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -894,6 +2067,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -925,6 +2105,41 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1004,6 +2219,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1051,6 +2279,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1218,6 +2462,267 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1260,6 +2765,16 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -1398,6 +2913,25 @@ "node": ">= 6.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1492,6 +3026,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1504,6 +3049,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1519,6 +3074,13 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -1608,6 +3170,13 @@ "split2": "^4.1.0" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1621,6 +3190,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1779,6 +3377,50 @@ "node": ">=8.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1940,6 +3582,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -2063,6 +3712,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2072,6 +3731,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2081,6 +3747,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -2104,6 +3777,90 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2117,6 +3874,81 @@ "node": ">=4" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2193,6 +4025,34 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2292,6 +4152,216 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/backend/package.json b/backend/package.json index f728e37..b3708aa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index b4c004a..5f4361e 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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) { diff --git a/backend/src/routes/auth.test.ts b/backend/src/routes/auth.test.ts new file mode 100644 index 0000000..0c8a484 --- /dev/null +++ b/backend/src/routes/auth.test.ts @@ -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); + }); + }); +}); diff --git a/backend/src/routes/games.test.ts b/backend/src/routes/games.test.ts new file mode 100644 index 0000000..24462a2 --- /dev/null +++ b/backend/src/routes/games.test.ts @@ -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); + }); + }); +}); diff --git a/backend/src/routes/teams.test.ts b/backend/src/routes/teams.test.ts new file mode 100644 index 0000000..2daa7e7 --- /dev/null +++ b/backend/src/routes/teams.test.ts @@ -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); + }); + }); +}); diff --git a/backend/src/routes/users.test.ts b/backend/src/routes/users.test.ts new file mode 100644 index 0000000..6f011fa --- /dev/null +++ b/backend/src/routes/users.test.ts @@ -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); + }); + }); +}); diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..13e1606 --- /dev/null +++ b/backend/vitest.config.ts @@ -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'], + }, + }, +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c2d3699..00e0cb8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,12 +19,56 @@ "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" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -71,6 +115,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -105,6 +302,42 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -128,6 +361,13 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/runtime": { "version": "0.115.0", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", @@ -148,6 +388,17 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.9", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", @@ -416,6 +667,13 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -427,6 +685,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -469,6 +752,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.1", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -647,6 +1053,17 @@ "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/@vue/tsconfig": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.0.tgz", @@ -666,6 +1083,16 @@ } } }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/alien-signals": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", @@ -673,6 +1100,42 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -690,6 +1153,23 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -699,6 +1179,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -712,6 +1202,36 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -724,6 +1244,34 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -739,12 +1287,55 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -762,6 +1353,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -795,6 +1393,39 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/engine.io-client": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", @@ -847,6 +1478,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -880,6 +1518,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -918,6 +1566,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -995,6 +1660,28 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1052,6 +1739,43 @@ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -1064,6 +1788,102 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -1331,6 +2151,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1349,6 +2179,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1370,6 +2207,32 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -1407,6 +2270,66 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1414,6 +2337,47 @@ "dev": true, "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -1488,12 +2452,39 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -1541,6 +2532,75 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -1587,6 +2647,124 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/superjson": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", @@ -1599,6 +2777,30 @@ "node": ">=16" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1616,6 +2818,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1638,6 +2896,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -1724,6 +2992,88 @@ } } }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -1752,6 +3102,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-router": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", @@ -1790,6 +3147,185 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -1811,6 +3347,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a8a30c8..26f916e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/components/Modal.test.ts b/frontend/src/components/Modal.test.ts new file mode 100644 index 0000000..c7210c8 --- /dev/null +++ b/frontend/src/components/Modal.test.ts @@ -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(); + }); + }); +}); diff --git a/frontend/src/composables/useModal.test.ts b/frontend/src/composables/useModal.test.ts new file mode 100644 index 0000000..78ba638 --- /dev/null +++ b/frontend/src/composables/useModal.test.ts @@ -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'); + }); + }); +}); diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue index d217be5..d160140 100644 --- a/frontend/src/pages/AdminPage.vue +++ b/frontend/src/pages/AdminPage.vue @@ -1,5 +1,5 @@