diff --git a/src/http/middleware.ts b/src/http/middleware.ts index a243b72..9973b60 100644 --- a/src/http/middleware.ts +++ b/src/http/middleware.ts @@ -1,6 +1,7 @@ import { IncomingMessage, ServerResponse } from 'node:http'; import { ENV } from '../config/env'; import { sendJson, getHeader } from './router'; +import { maintenanceService } from '../services/MaintenanceService'; // Augment the native type so `playerId` can travel through the request lifecycle. declare module 'node:http' { @@ -40,7 +41,8 @@ export async function apiKeyMiddleware( // requireAdmin() inside the route handler will validate the key itself. const isAdminRoute = pathname.startsWith('/telemetry/') || - pathname.startsWith('/api/v1/mystery-gift/admin'); + pathname.startsWith('/api/v1/mystery-gift/admin') || + pathname.startsWith('/api/v1/maintenance/admin'); if (isAdminRoute) { const adminKey = getHeader(req, 'x-admin-key'); if (adminKey && adminKey === ENV.ADMIN_KEY) return true; @@ -56,6 +58,37 @@ export async function apiKeyMiddleware( return true; } +/** + * Blocks player-facing HTTP routes while maintenance mode is enabled. + * + * @remarks + * Admin maintenance routes remain available so the mode can be disabled again. + * The public health check and docs are handled outside the router. + */ +export async function maintenanceModeMiddleware( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const pathname = (req.url || '/').split('?')[0]; + + const isAllowedDuringMaintenance = + pathname === '/api/v1/maintenance' || + pathname.startsWith('/api/v1/maintenance/admin') || + pathname === '/telemetry' || + pathname.startsWith('/telemetry/'); + + if (isAllowedDuringMaintenance) return true; + + const status = await maintenanceService.getStatus(); + if (!status.enabled) return true; + + sendJson(res, 503, { + error: 503, + maintenance: status, + }); + return false; +} + // ─── Guards ─────────────────────────────────────────────────────────────────── /** diff --git a/src/http/router.ts b/src/http/router.ts index d33a0dd..de60077 100644 --- a/src/http/router.ts +++ b/src/http/router.ts @@ -122,8 +122,8 @@ export class Router { async handle(req: IncomingMessage, res: ServerResponse): Promise { // CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key, x-player-id'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key, x-player-id, x-admin-key'); res.setHeader('Access-Control-Max-Age', '86400'); if (req.method === 'OPTIONS') { diff --git a/src/http/routes/maintenance.routes.ts b/src/http/routes/maintenance.routes.ts new file mode 100644 index 0000000..1395058 --- /dev/null +++ b/src/http/routes/maintenance.routes.ts @@ -0,0 +1,68 @@ +import { Router, sendJson, readBody } from '../router'; +import { requireAdmin } from '../middleware'; +import { maintenanceService } from '../../services/MaintenanceService'; +import { z } from 'zod'; + +const UpdateMaintenanceSchema = z + .object({ + enabled: z.boolean(), + message: z.string().trim().max(500).optional(), + endAt: z.union([z.iso.datetime(), z.null()]).optional(), + }) + .superRefine((data, ctx) => { + if (data.enabled && (!data.message || data.message.trim() === '')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['message'], + message: 'A maintenance message is required when maintenance is enabled', + }); + } + }); + +export function registerMaintenanceRoutes(router: Router): void { + router.get('/api/v1/maintenance', async (_req, res) => { + const status = await maintenanceService.getStatus(); + sendJson(res, 200, status); + }); + + router.patch('/api/v1/maintenance/admin', async (req, res) => { + if (!requireAdmin(req, res)) return; + + let body: unknown; + try { + body = await readBody(req); + } catch { + sendJson(res, 400, { error: 'Invalid request body' }); + return; + } + + const parsed = UpdateMaintenanceSchema.safeParse(body); + if (!parsed.success) { + sendJson(res, 400, { + error: 'Invalid data', + details: z.treeifyError(parsed.error), + }); + return; + } + + const endAt = + parsed.data.endAt === null || parsed.data.endAt === undefined + ? parsed.data.endAt + : new Date(parsed.data.endAt); + + const status = await maintenanceService.update({ + enabled: parsed.data.enabled, + message: parsed.data.message, + endAt, + }); + + sendJson(res, 200, status); + }); + + router.delete('/api/v1/maintenance/admin', async (req, res) => { + if (!requireAdmin(req, res)) return; + + const status = await maintenanceService.disable(); + sendJson(res, 200, status); + }); +} diff --git a/src/index.ts b/src/index.ts index c3807a5..2e19990 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,11 +6,12 @@ import { WebSocketServer } from 'ws'; import { connectDatabase } from './config/database'; import { ENV } from './config/env'; import { Router, sendJson } from './http/router'; -import { apiKeyMiddleware } from './http/middleware'; +import { apiKeyMiddleware, maintenanceModeMiddleware } from './http/middleware'; import { registerAuthRoutes } from './http/routes/auth.routes'; import { registerGtsRoutes } from './http/routes/gts.routes'; import { registerMysteryGiftRoutes } from './http/routes/mysteryGift.routes'; import { registerFriendRoutes } from './http/routes/friends.routes'; +import { registerMaintenanceRoutes } from './http/routes/maintenance.routes'; import { registerTelemetryRoutes } from './http/routes/telemetry.routes'; import { openApiSpec, swaggerUiHtml } from './swagger'; import { createWsServer } from './ws/WsServer'; @@ -53,11 +54,15 @@ async function bootstrap(): Promise { // Middleware 2: API Key router.use(apiKeyMiddleware); + // Middleware 3: maintenance gate for player-facing routes + router.use(maintenanceModeMiddleware); + // Register routes registerAuthRoutes(router); registerGtsRoutes(router); registerMysteryGiftRoutes(router); registerFriendRoutes(router); + registerMaintenanceRoutes(router); registerTelemetryRoutes(router); // ── Native THTP server ──────────────────────────────── diff --git a/src/models/MaintenanceState.ts b/src/models/MaintenanceState.ts new file mode 100644 index 0000000..2f20f9c --- /dev/null +++ b/src/models/MaintenanceState.ts @@ -0,0 +1,27 @@ +import { Schema, model, Document } from 'mongoose'; + +export interface MaintenanceStateData { + key: string; + enabled: boolean; + message: string; + endAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface IMaintenanceState extends MaintenanceStateData, Document {} + +const MaintenanceStateSchema = new Schema( + { + key: { type: String, required: true, unique: true, default: 'global' }, + enabled: { type: Boolean, default: false }, + message: { type: String, default: '', trim: true, maxlength: 500 }, + endAt: { type: Date, default: null }, + }, + { timestamps: true }, +); + +export const MaintenanceState = model( + 'MaintenanceState', + MaintenanceStateSchema, +); diff --git a/src/services/MaintenanceService.ts b/src/services/MaintenanceService.ts new file mode 100644 index 0000000..fd201ba --- /dev/null +++ b/src/services/MaintenanceService.ts @@ -0,0 +1,71 @@ +import { MaintenanceState } from '../models/MaintenanceState'; +import { clients } from '../ws/clients'; +import { send } from '../ws/types'; + +export interface MaintenanceStatus { + enabled: boolean; + message: string; + endAt: string | null; +} + +const DEFAULT_STATUS: MaintenanceStatus = { + enabled: false, + message: '', + endAt: null, +}; + +export class MaintenanceService { + async getStatus(): Promise { + const doc = await MaintenanceState.findOne({ key: 'global' }).lean(); + if (!doc) return DEFAULT_STATUS; + + return { + enabled: doc.enabled, + message: doc.message, + endAt: doc.endAt ? new Date(doc.endAt).toISOString() : null, + }; + } + + async update(input: { + enabled: boolean; + message?: string; + endAt?: Date | null; + }): Promise { + const doc = await MaintenanceState.findOneAndUpdate( + { key: 'global' }, + { + $set: { + enabled: input.enabled, + message: input.enabled ? (input.message ?? '').trim() : '', + endAt: input.enabled ? (input.endAt ?? null) : null, + }, + }, + { + upsert: true, + new: true, + setDefaultsOnInsert: true, + }, + ).lean(); + + const status: MaintenanceStatus = { + enabled: doc.enabled, + message: doc.message, + endAt: doc.endAt ? new Date(doc.endAt).toISOString() : null, + }; + + this.broadcast(status); + return status; + } + + async disable(): Promise { + return this.update({ enabled: false }); + } + + broadcast(status: MaintenanceStatus): void { + for (const ws of clients.values()) { + send(ws, 'MAINTENANCE_STATUS', status); + } + } +} + +export const maintenanceService = new MaintenanceService(); diff --git a/src/swagger.ts b/src/swagger.ts index 6e116fd..f71fd8b 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -109,6 +109,41 @@ See the **WebSocket** tag below for all message types and payloads. }, }, }, + MaintenanceStatus: { + type: 'object', + required: ['enabled', 'message', 'endAt'], + properties: { + enabled: { type: 'boolean', example: true }, + message: { + type: 'string', + example: 'Server maintenance in progress.', + }, + endAt: { + type: 'string', + format: 'date-time', + nullable: true, + example: '2026-04-28T20:00:00.000Z', + }, + }, + }, + MaintenanceUpdateBody: { + type: 'object', + required: ['enabled'], + properties: { + enabled: { type: 'boolean', example: true }, + message: { + type: 'string', + maxLength: 500, + example: 'Online services are temporarily unavailable.', + }, + endAt: { + type: 'string', + format: 'date-time', + nullable: true, + example: '2026-04-28T20:00:00.000Z', + }, + }, + }, // ── Player / Auth ───────────────────────────────────────────────────── RegisterBody: { @@ -1673,6 +1708,73 @@ See the **WebSocket** tag below for all message types and payloads. }, // ── Telemetry ──────────────────────────────────────────────────────────── + '/api/v1/maintenance': { + get: { + tags: ['Maintenance'], + summary: 'Read the current maintenance status', + security: [{ ApiKey: [] }], + description: + 'Returns the current maintenance flag, warning message, and optional expected end date/time for PSDK clients.', + responses: { + 200: { + description: 'Maintenance status payload.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MaintenanceStatus' }, + }, + }, + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/api/v1/maintenance/admin': { + patch: { + tags: ['Maintenance (Admin)'], + summary: 'Enable or update maintenance mode', + security: [{ AdminKey: [] }], + description: + 'Creates or updates the global maintenance state and broadcasts the new value to connected WebSocket clients.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MaintenanceUpdateBody' }, + }, + }, + }, + responses: { + 200: { + description: 'Updated maintenance status.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MaintenanceStatus' }, + }, + }, + }, + 400: { $ref: '#/components/responses/ValidationError' }, + 401: { $ref: '#/components/responses/Unauthorized' }, + }, + }, + delete: { + tags: ['Maintenance (Admin)'], + summary: 'Disable maintenance mode', + security: [{ AdminKey: [] }], + description: + 'Turns maintenance mode off, clears the message and end date, and broadcasts the new value to connected WebSocket clients.', + responses: { + 200: { + description: 'Maintenance disabled.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MaintenanceStatus' }, + }, + }, + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, '/telemetry/summary': { get: { tags: ['Telemetry'], @@ -1902,6 +2004,24 @@ Keepalive probe. The server replies immediately with \`PONG\`. ### \`PONG\` *(server → client)* Keepalive response. +### \`MAINTENANCE_STATUS\` *(client → server or server → client)* +Use this message to fetch or receive the current maintenance state. +\`\`\`json +{ "type": "MAINTENANCE_STATUS" } +\`\`\` +The server replies with: +\`\`\`json +{ + "type": "MAINTENANCE_STATUS", + "payload": { + "enabled": true, + "message": "Server maintenance in progress.", + "endAt": "2026-04-28T20:00:00.000Z" + } +} +\`\`\` +When maintenance mode is active, the same payload is also pushed automatically on connection. Any admin update is broadcast live to connected clients. + ### \`ERROR\` *(server → client)* Sent whenever a message cannot be processed. \`\`\`json diff --git a/src/ws/WsServer.ts b/src/ws/WsServer.ts index 16c644d..3b6a119 100644 --- a/src/ws/WsServer.ts +++ b/src/ws/WsServer.ts @@ -1,17 +1,17 @@ -import { WebSocketServer, WebSocket } from 'ws'; import { IncomingMessage } from 'node:http'; +import { WebSocketServer, WebSocket } from 'ws'; import { verifyWsApiKey } from '../http/middleware'; -import { AuthenticatedWs, send } from './types'; +import { telemetry } from '../telemetry/store'; +import { maintenanceService } from '../services/MaintenanceService'; import { handleBattleMessage, cleanupBattle } from './handlers/battleHandler'; import { handleTradeMessage, cleanupTrade } from './handlers/tradeHandler'; -import { telemetry } from '../telemetry/store'; +import { clients } from './clients'; +import { AuthenticatedWs, send } from './types'; -/** Global map of connected clients: playerId → socket */ -export const clients = new Map(); +export { clients } from './clients'; export function createWsServer(wss: WebSocketServer): void { - wss.on('connection', (rawWs: WebSocket, req: IncomingMessage) => { - // ── Authentication ───────────────────────────────────── + wss.on('connection', async (rawWs: WebSocket, req: IncomingMessage) => { const url = new URL(req.url || '/', 'http://localhost'); const apiKey = url.searchParams.get('apiKey') ?? ''; @@ -32,23 +32,26 @@ export function createWsServer(wss: WebSocketServer): void { ws.playerId = playerId; ws.trainerName = decodeURIComponent(trainerName); - // Close previous session if the player reconnects + const maintenanceStatus = await maintenanceService.getStatus(); + if (maintenanceStatus.enabled) { + send(ws, 'MAINTENANCE_STATUS', maintenanceStatus); + rawWs.close(4004, 'Server in maintenance'); + return; + } + const existing = clients.get(playerId); if (existing && existing.readyState === WebSocket.OPEN) { existing.close(4003, 'Replaced by a new connection'); } clients.set(playerId, ws); - - // ── Connection telemetry ─────────────────────────────── telemetry.recordWsConnect(playerId); console.log( - `[WS] ✅ ${ws.trainerName} (${ws.playerId}) connected — ${clients.size} client(s)`, + `[WS] connected ${ws.trainerName} (${ws.playerId}) - ${clients.size} client(s)`, ); - // ── Incoming messages ───────────────────────────────── - ws.on('message', (raw) => { + ws.on('message', async (raw) => { let type: string; let payload: unknown; @@ -68,15 +71,31 @@ export function createWsServer(wss: WebSocketServer): void { return; } - // ── Message telemetry ─────────────────────────────── telemetry.recordWsMessage(type, playerId); - // Keepalive if (type === 'PING') { send(ws, 'PONG'); return; } + if (type === 'MAINTENANCE_STATUS') { + maintenanceService + .getStatus() + .then((status) => send(ws, 'MAINTENANCE_STATUS', status)) + .catch((err: Error) => { + send(ws, 'ERROR', { message: 'Unable to load maintenance status' }); + telemetry.recordWsError(playerId, err.message); + }); + return; + } + + const currentMaintenance = await maintenanceService.getStatus(); + if (currentMaintenance.enabled) { + send(ws, 'MAINTENANCE_STATUS', currentMaintenance); + send(ws, 'ERROR', { message: 'Server is in maintenance mode' }); + return; + } + if (type.startsWith('BATTLE_')) { handleBattleMessage(ws, type, payload, clients); return; @@ -91,28 +110,22 @@ export function createWsServer(wss: WebSocketServer): void { telemetry.recordWsError(playerId, `Unknown type: ${type}`); }); - // ── Disconnection ──────────────────────────────────── ws.on('close', (code, reason) => { clients.delete(playerId); cleanupBattle(ws); cleanupTrade(ws); - - // ── Disconnection telemetry ───────────────────────── telemetry.recordWsDisconnect(playerId, code); console.log( - `[WS] ❌ ${ws.trainerName} (${ws.playerId}) disconnected` + - ` — code ${code}${reason.length ? ` (${reason})` : ''}` + - ` — ${clients.size} client(s) remaining`, + `[WS] disconnected ${ws.trainerName} (${ws.playerId})` + + ` - code ${code}${reason.length ? ` (${reason})` : ''}` + + ` - ${clients.size} client(s) remaining`, ); }); ws.on('error', (err) => { telemetry.recordWsError(playerId, err.message); - console.error( - `[WS] Error for ${ws.trainerName} (${ws.playerId}):`, - err.message, - ); + console.error(`[WS] Error for ${ws.trainerName} (${ws.playerId}):`, err.message); }); }); } diff --git a/src/ws/clients.ts b/src/ws/clients.ts new file mode 100644 index 0000000..3be611b --- /dev/null +++ b/src/ws/clients.ts @@ -0,0 +1,4 @@ +import { AuthenticatedWs } from './types'; + +/** Global map of connected clients: playerId → socket */ +export const clients = new Map(); diff --git a/src/ws/types.ts b/src/ws/types.ts index 831e519..cbcae29 100644 --- a/src/ws/types.ts +++ b/src/ws/types.ts @@ -1,77 +1,43 @@ import { WebSocket } from 'ws'; -// ─── Socket type ────────────────────────────────────────────────────────────── - /** - * Authenticated WebSocket — enriched with player metadata after the WS handshake. - * - * @remarks - * The raw `WebSocket` is cast to this interface once `playerId` and `trainerName` - * have been validated. `roomId` is set by battle/trade handlers when the player - * joins a room and cleared when they leave. + * Authenticated WebSocket enriched with player metadata after the WS handshake. */ export interface AuthenticatedWs extends WebSocket { - /** Immutable game-side player identifier. */ - playerId: string; - /** Display name — may differ from the registered one if updated since login. */ + playerId: string; trainerName: string; - /** ID of the active battle or trade room, or `undefined` when idle. */ - roomId?: string; + roomId?: string; } -// ─── Message types ──────────────────────────────────────────────────────────── - /** * All WebSocket message type literals exchanged between clients and server. - * - * @remarks - * Naming convention: `_`. - * - `A → server` messages are sent by a client to initiate an action. - * - `server → players` messages are broadcast by the server to one or both players. */ export type WsMessageType = - // ── Battle ──────────────────────────────────────────────────────────────── - | 'BATTLE_CHALLENGE' // client A → server : challenge player B - | 'BATTLE_ACCEPT' // client B → server : accept the challenge - | 'BATTLE_DECLINE' // client B → server : decline the challenge - | 'BATTLE_ACTION' // client → server : submit a battle action (relayed to opponent) - | 'BATTLE_STATE' // server → clients : broadcast the current room state - | 'BATTLE_END' // server → clients : notify that the battle has ended - // ── Trade ───────────────────────────────────────────────────────────────── - | 'TRADE_REQUEST' // client A → server : propose a trade to player B - | 'TRADE_ACCEPT' // client B → server : accept the trade request - | 'TRADE_DECLINE' // client B → server : decline the trade request - | 'TRADE_OFFER' // client → server : place/update a creature on the trade table - | 'TRADE_CONFIRM' // client → server : lock in the current offer - | 'TRADE_CANCEL' // client → server : cancel the ongoing trade - | 'TRADE_COMPLETE' // server → clients : trade executed, creatures swapped - // ── System ──────────────────────────────────────────────────────────────── - | 'PING' // client → server : keepalive probe - | 'PONG' // server → client : keepalive response - | 'ERROR'; // server → client : error notification - -// ─── Message envelope ───────────────────────────────────────────────────────── + | 'BATTLE_CHALLENGE' + | 'BATTLE_ACCEPT' + | 'BATTLE_DECLINE' + | 'BATTLE_ACTION' + | 'BATTLE_STATE' + | 'BATTLE_END' + | 'TRADE_REQUEST' + | 'TRADE_ACCEPT' + | 'TRADE_DECLINE' + | 'TRADE_OFFER' + | 'TRADE_CONFIRM' + | 'TRADE_CANCEL' + | 'TRADE_COMPLETE' + | 'MAINTENANCE_STATUS' + | 'PING' + | 'PONG' + | 'ERROR'; -/** - * Standard message envelope sent over the WebSocket connection. - */ export interface WsMessage { - type: WsMessageType; + type: WsMessageType; payload?: unknown; } -// ─── Helper ─────────────────────────────────────────────────────────────────── - /** - * Sends a JSON-encoded message to a socket **only if** the connection is open. - * - * @remarks - * Silently drops the message if `ws.readyState !== OPEN` to avoid throwing - * on disconnected sockets. - * - * @param ws - Target socket. - * @param type - Message type. - * @param payload - Optional payload (must be JSON-serialisable). + * Sends a JSON-encoded message to a socket only if the connection is open. */ export function send(ws: WebSocket, type: WsMessageType, payload?: unknown): void { if (ws.readyState === WebSocket.OPEN) { diff --git a/tests/http/middleware.test.ts b/tests/http/middleware.test.ts index b328d77..63a9777 100644 --- a/tests/http/middleware.test.ts +++ b/tests/http/middleware.test.ts @@ -10,10 +10,19 @@ import { EventEmitter } from 'node:events'; import { apiKeyMiddleware, extractPlayer, + maintenanceModeMiddleware, requireAdmin, verifyWsApiKey, } from '../../src/http/middleware'; +const mockGetStatus = vi.fn(); + +vi.mock('../../src/services/MaintenanceService', () => ({ + maintenanceService: { + getStatus: (...a: any[]) => mockGetStatus(...a), + }, +})); + // ── Helpers ─────────────────────────────────────────────────────────────────── function makeReq(url: string, headers: Record = {}): IncomingMessage { @@ -41,6 +50,11 @@ function makeRes() { // ── apiKeyMiddleware ────────────────────────────────────────────────────────── describe('apiKeyMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetStatus.mockResolvedValue({ enabled: false, message: '', endAt: null }); + }); + it('allows requests with the correct x-api-key', async () => { const req = makeReq('/api/v1/gts/deposit', { 'x-api-key': 'test-api-key' }); const res = makeRes(); @@ -89,6 +103,15 @@ describe('apiKeyMiddleware', () => { expect(result).toBe(true); }); + it('allows maintenance admin route with valid admin key', async () => { + const req = makeReq('/api/v1/maintenance/admin', { + 'x-admin-key': 'test-admin-key', + }); + const res = makeRes(); + const result = await apiKeyMiddleware(req, res); + expect(result).toBe(true); + }); + it('falls through to API_KEY check for admin route with wrong admin key', async () => { const req = makeReq('/api/v1/mystery-gift/admin/create', { 'x-admin-key': 'bad-admin-key', @@ -100,6 +123,49 @@ describe('apiKeyMiddleware', () => { }); }); +describe('maintenanceModeMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('allows player routes when maintenance is disabled', async () => { + mockGetStatus.mockResolvedValue({ enabled: false, message: '', endAt: null }); + const req = makeReq('/api/v1/gts/deposit', { 'x-api-key': 'test-api-key' }); + const res = makeRes(); + const result = await maintenanceModeMiddleware(req, res); + expect(result).toBe(true); + }); + + it('blocks player routes with 503 when maintenance is enabled', async () => { + mockGetStatus.mockResolvedValue({ + enabled: true, + message: 'Maintenance in progress', + endAt: '2026-04-28T20:00:00.000Z', + }); + const req = makeReq('/api/v1/gts/deposit', { 'x-api-key': 'test-api-key' }); + const res = makeRes(); + const result = await maintenanceModeMiddleware(req, res); + expect(result).toBe(false); + expect(res.statusCode).toBe(503); + expect(JSON.parse(res.body)).toMatchObject({ + error: 503, + maintenance: { enabled: true }, + }); + }); + + it('keeps maintenance admin route accessible while maintenance is enabled', async () => { + mockGetStatus.mockResolvedValue({ + enabled: true, + message: 'Maintenance in progress', + endAt: '2026-04-28T20:00:00.000Z', + }); + const req = makeReq('/api/v1/maintenance/admin', { 'x-admin-key': 'test-admin-key' }); + const res = makeRes(); + const result = await maintenanceModeMiddleware(req, res); + expect(result).toBe(true); + }); +}); + // ── extractPlayer ───────────────────────────────────────────────────────────── describe('extractPlayer', () => { diff --git a/tests/http/routes/auth.routes.test.ts b/tests/http/routes/auth.routes.test.ts index 886eaf1..60358c4 100644 --- a/tests/http/routes/auth.routes.test.ts +++ b/tests/http/routes/auth.routes.test.ts @@ -25,7 +25,9 @@ vi.mock('../../../src/models/Players', () => ({ })); vi.mock('../../../src/services/FriendService', () => ({ - FriendService: { generateFriendCode: () => '12345678' }, + FriendService: { + createPlayerWithUniqueFriendCode: (...a: any[]) => mockPlayerCreate(...a), + }, })); vi.mock('../../../src/services/PlayerService', () => ({ diff --git a/tests/http/routes/friends.routes.test.ts b/tests/http/routes/friends.routes.test.ts index f2f845b..b94f8dc 100644 --- a/tests/http/routes/friends.routes.test.ts +++ b/tests/http/routes/friends.routes.test.ts @@ -60,7 +60,11 @@ function makeRes() { if (h) Object.assign(res.headers, h); }), end: vi.fn((b?: string) => { res.body = b ?? ''; }), - } as unknown as ServerResponse & { statusCode: number; body: string }; + } as unknown as ServerResponse & { + statusCode: number; + body: string; + headers: Record; + }; return res; } diff --git a/tests/http/routes/gts.routes.test.ts b/tests/http/routes/gts.routes.test.ts index c33d98a..3160d8d 100644 --- a/tests/http/routes/gts.routes.test.ts +++ b/tests/http/routes/gts.routes.test.ts @@ -65,7 +65,11 @@ function makeRes() { res.statusCode = s; if (h) Object.assign(res.headers, h); }), end: vi.fn((b?: string) => { res.body = b ?? ''; }), - } as unknown as ServerResponse & { statusCode: number; body: string }; + } as unknown as ServerResponse & { + statusCode: number; + body: string; + headers: Record; + }; return res; } diff --git a/tests/http/routes/maintenance.routes.test.ts b/tests/http/routes/maintenance.routes.test.ts new file mode 100644 index 0000000..9d4ffac --- /dev/null +++ b/tests/http/routes/maintenance.routes.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { IncomingMessage, ServerResponse } from 'node:http'; +import { EventEmitter } from 'node:events'; +import { Router } from '../../../src/http/router'; +import { registerMaintenanceRoutes } from '../../../src/http/routes/maintenance.routes'; + +const mockGetStatus = vi.fn(); +const mockUpdate = vi.fn(); +const mockDisable = vi.fn(); + +vi.mock('../../../src/services/MaintenanceService', () => ({ + maintenanceService: { + getStatus: (...a: any[]) => mockGetStatus(...a), + update: (...a: any[]) => mockUpdate(...a), + disable: (...a: any[]) => mockDisable(...a), + }, +})); + +function makeReq( + method: string, + url: string, + body?: unknown, + extraHeaders: Record = {}, +): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.url = url; + req.headers = extraHeaders; + + const json = JSON.stringify(body ?? {}); + process.nextTick(() => { + req.emit('data', Buffer.from(json)); + req.emit('end'); + }); + + return req; +} + +function makeRes() { + const res = { + statusCode: 0, + body: '', + headers: {} as Record, + setHeader: vi.fn((k: string, v: string | number) => { + res.headers[k] = v; + }), + writeHead: vi.fn((s: number, h?: Record) => { + res.statusCode = s; + if (h) Object.assign(res.headers, h); + }), + end: vi.fn((b?: string) => { + res.body = b ?? ''; + }), + } as unknown as ServerResponse & { + statusCode: number; + body: string; + headers: Record; + }; + + return res; +} + +async function call(req: IncomingMessage) { + const router = new Router(); + registerMaintenanceRoutes(router); + const res = makeRes(); + await router.handle(req, res); + return { res, data: res.body ? JSON.parse(res.body) : null }; +} + +describe('GET /api/v1/maintenance', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns the current maintenance status', async () => { + mockGetStatus.mockResolvedValue({ + enabled: true, + message: 'Maintenance in progress', + endAt: '2026-04-28T20:00:00.000Z', + }); + + const { res, data } = await call(makeReq('GET', '/api/v1/maintenance')); + + expect(res.statusCode).toBe(200); + expect(data).toEqual({ + enabled: true, + message: 'Maintenance in progress', + endAt: '2026-04-28T20:00:00.000Z', + }); + }); +}); + +describe('PATCH /api/v1/maintenance/admin', () => { + beforeEach(() => vi.clearAllMocks()); + + it('updates the maintenance state with a valid admin key', async () => { + mockUpdate.mockResolvedValue({ + enabled: true, + message: 'Back soon', + endAt: '2026-04-28T18:00:00.000Z', + }); + + const { res, data } = await call( + makeReq( + 'PATCH', + '/api/v1/maintenance/admin', + { + enabled: true, + message: 'Back soon', + endAt: '2026-04-28T18:00:00.000Z', + }, + { 'x-admin-key': 'test-admin-key' }, + ), + ); + + expect(res.statusCode).toBe(200); + expect(data.enabled).toBe(true); + expect(mockUpdate).toHaveBeenCalledWith({ + enabled: true, + message: 'Back soon', + endAt: new Date('2026-04-28T18:00:00.000Z'), + }); + }); + + it('requires a message when enabling maintenance', async () => { + const { res, data } = await call( + makeReq( + 'PATCH', + '/api/v1/maintenance/admin', + { enabled: true, endAt: '2026-04-28T18:00:00.000Z' }, + { 'x-admin-key': 'test-admin-key' }, + ), + ); + + expect(res.statusCode).toBe(400); + expect(data.error).toBe('Invalid data'); + }); + + it('returns 401 when the admin key is missing', async () => { + const { res } = await call( + makeReq('PATCH', '/api/v1/maintenance/admin', { enabled: false }), + ); + + expect(res.statusCode).toBe(401); + }); +}); + +describe('DELETE /api/v1/maintenance/admin', () => { + beforeEach(() => vi.clearAllMocks()); + + it('disables maintenance mode', async () => { + mockDisable.mockResolvedValue({ + enabled: false, + message: '', + endAt: null, + }); + + const { res, data } = await call( + makeReq( + 'DELETE', + '/api/v1/maintenance/admin', + undefined, + { 'x-admin-key': 'test-admin-key' }, + ), + ); + + expect(res.statusCode).toBe(200); + expect(data).toEqual({ enabled: false, message: '', endAt: null }); + expect(mockDisable).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/http/routes/mysteryGift.routes.test.ts b/tests/http/routes/mysteryGift.routes.test.ts index 0a5d93b..1794016 100644 --- a/tests/http/routes/mysteryGift.routes.test.ts +++ b/tests/http/routes/mysteryGift.routes.test.ts @@ -62,7 +62,11 @@ function makeRes() { res.statusCode = s; if (h) Object.assign(res.headers, h); }), end: vi.fn((b?: string) => { res.body = b ?? ''; }), - } as unknown as ServerResponse & { statusCode: number; body: string }; + } as unknown as ServerResponse & { + statusCode: number; + body: string; + headers: Record; + }; return res; } diff --git a/tests/ws/wsServer.integration.test.ts b/tests/ws/wsServer.integration.test.ts index b2c1123..22f433f 100644 --- a/tests/ws/wsServer.integration.test.ts +++ b/tests/ws/wsServer.integration.test.ts @@ -16,11 +16,19 @@ * - Disconnection cleanup */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createServer, Server } from 'node:http'; import { WebSocketServer, WebSocket } from 'ws'; import { createWsServer, clients } from '../../src/ws/WsServer'; +const mockGetStatus = vi.fn(); + +vi.mock('../../src/services/MaintenanceService', () => ({ + maintenanceService: { + getStatus: (...a: any[]) => mockGetStatus(...a), + }, +})); + // ── Server lifecycle helpers ────────────────────────────────────────────────── let httpServer: Server; @@ -94,6 +102,27 @@ function waitForMessage(ws: WebSocket): Promise { }); } +function waitForMessages(ws: WebSocket, count: number): Promise { + return new Promise((resolve, reject) => { + const messages: any[] = []; + const timer = setTimeout(() => { + ws.off('message', onMessage); + reject(new Error('timeout waiting for message')); + }, 2000); + + const onMessage = (raw: any) => { + messages.push(JSON.parse(raw.toString())); + if (messages.length === count) { + clearTimeout(timer); + ws.off('message', onMessage); + resolve(messages); + } + }; + + ws.on('message', onMessage); + }); +} + function send(ws: WebSocket, type: string, payload?: unknown) { ws.send(JSON.stringify({ type, payload })); } @@ -102,6 +131,11 @@ function send(ws: WebSocket, type: string, payload?: unknown) { describe('WsServer', () => { beforeEach(async () => { + mockGetStatus.mockResolvedValue({ + enabled: false, + message: '', + endAt: null, + }); clients.clear(); await startServer(); }); @@ -129,6 +163,23 @@ describe('WsServer', () => { expect(reason).toContain('Missing playerId'); }); + it('closes the connection (4004) when maintenance is enabled', async () => { + mockGetStatus.mockResolvedValue({ + enabled: true, + message: 'Maintenance in progress', + endAt: '2026-04-28T20:00:00.000Z', + }); + + const ws = connect({ playerId: 'player-maint-down' }); + const msg = await waitForMessage(ws); + expect(msg.type).toBe('MAINTENANCE_STATUS'); + + const { code, reason } = await waitForClose(ws); + expect(code).toBe(4004); + expect(reason).toContain('maintenance'); + expect(clients.has('player-maint-down')).toBe(false); + }); + // ── Connection tracking ───────────────────────────────────────────────────── it('registers the client in the clients map after connection', async () => { @@ -177,6 +228,26 @@ describe('WsServer', () => { ws.close(); }); + it('returns the maintenance status when requested explicitly', async () => { + const ws = connect({ playerId: 'player-maintenance' }); + await waitForOpen(ws); + mockGetStatus.mockResolvedValue({ + enabled: true, + message: 'Maintenance in progress', + endAt: '2026-04-28T20:00:00.000Z', + }); + const messagePromise = waitForMessage(ws); + send(ws, 'MAINTENANCE_STATUS'); + const msg = await messagePromise; + expect(msg.type).toBe('MAINTENANCE_STATUS'); + expect(msg.payload).toEqual({ + enabled: true, + message: 'Maintenance in progress', + endAt: '2026-04-28T20:00:00.000Z', + }); + ws.close(); + }); + // ── Error cases ───────────────────────────────────────────────────────────── it('sends ERROR for invalid JSON', async () => { @@ -221,6 +292,24 @@ describe('WsServer', () => { ws.close(); }); + it('blocks gameplay messages when maintenance becomes enabled', async () => { + const ws = connect({ playerId: 'player-maint-live' }); + await waitForOpen(ws); + + mockGetStatus.mockResolvedValue({ + enabled: true, + message: 'Maintenance in progress', + endAt: '2026-04-28T20:00:00.000Z', + }); + + const messagesPromise = waitForMessages(ws, 2); + send(ws, 'BATTLE_CHALLENGE', { targetPlayerId: 'ghost-player' }); + const [first, second] = await messagesPromise; + expect([first.type, second.type]).toContain('MAINTENANCE_STATUS'); + expect([first.type, second.type]).toContain('ERROR'); + ws.close(); + }); + it('routes TRADE_* messages without crashing', async () => { const ws = connect({ playerId: 'player-trade-smoke' }); await waitForOpen(ws);