diff --git a/frontend/src/socket/types.spec.ts b/frontend/src/socket/types.spec.ts new file mode 100644 index 0000000..3411837 --- /dev/null +++ b/frontend/src/socket/types.spec.ts @@ -0,0 +1,367 @@ +/** + * Tests for WebSocket message types and factory functions. + * + * Verifies message creation, structure, and unique ID generation. + */ + +import { describe, it, expect } from 'vitest' + +import { + generateMessageId, + createJoinGameMessage, + createActionMessage, + createResignMessage, + createHeartbeatMessage, +} from './types' +import type { GameAction } from './types' + +describe('Socket Message Types', () => { + describe('generateMessageId', () => { + it('generates a unique message ID', () => { + /** + * Test that generateMessageId returns a UUID. + * + * Message IDs must be unique for tracking and deduplication. + */ + const id = generateMessageId() + + expect(id).toBeTruthy() + expect(typeof id).toBe('string') + // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + }) + + it('generates different IDs on successive calls', () => { + /** + * Test that generateMessageId produces unique values. + * + * Each message must have a unique ID for proper tracking. + */ + const id1 = generateMessageId() + const id2 = generateMessageId() + const id3 = generateMessageId() + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + }) + + describe('createJoinGameMessage', () => { + it('creates a join game message with game ID', () => { + /** + * Test createJoinGameMessage basic structure. + * + * Join messages must include type, message_id, and game_id. + */ + const gameId = 'game-123' + + const message = createJoinGameMessage(gameId) + + expect(message.type).toBe('join_game') + expect(message.game_id).toBe(gameId) + expect(message.message_id).toBeTruthy() + expect(message.message_id).toMatch(/^[0-9a-f-]+$/i) + }) + + it('includes last_event_id when provided', () => { + /** + * Test createJoinGameMessage with last_event_id for reconnection. + * + * When reconnecting, clients send the last event ID they received + * to resume from that point. + */ + const gameId = 'game-123' + const lastEventId = 'event-456' + + const message = createJoinGameMessage(gameId, lastEventId) + + expect(message.type).toBe('join_game') + expect(message.game_id).toBe(gameId) + expect(message.last_event_id).toBe(lastEventId) + }) + + it('omits last_event_id when not provided', () => { + /** + * Test createJoinGameMessage without last_event_id for new connections. + * + * Initial joins don't have a last_event_id. + */ + const gameId = 'game-123' + + const message = createJoinGameMessage(gameId) + + expect(message.last_event_id).toBeUndefined() + }) + + it('generates unique message IDs', () => { + /** + * Test that each join message has a unique ID. + * + * Message IDs must be unique even for the same game. + */ + const gameId = 'game-123' + + const message1 = createJoinGameMessage(gameId) + const message2 = createJoinGameMessage(gameId) + + expect(message1.message_id).not.toBe(message2.message_id) + }) + }) + + describe('createActionMessage', () => { + it('creates an action message with game ID and action', () => { + /** + * Test createActionMessage basic structure. + * + * Action messages must include type, message_id, game_id, and action. + */ + const gameId = 'game-123' + const action: GameAction = { + action_type: 'play_card', + card_id: 'card-456', + } + + const message = createActionMessage(gameId, action) + + expect(message.type).toBe('action') + expect(message.game_id).toBe(gameId) + expect(message.action).toEqual(action) + expect(message.message_id).toBeTruthy() + }) + + it('preserves action data structure', () => { + /** + * Test that action data is preserved without modification. + * + * The action object should be included as-is in the message. + */ + const gameId = 'game-123' + const action: GameAction = { + action_type: 'attack', + attacker_id: 'attacker-1', + defender_id: 'defender-2', + attack_index: 0, + } + + const message = createActionMessage(gameId, action) + + expect(message.action).toEqual(action) + expect(message.action.action_type).toBe('attack') + expect(message.action.attacker_id).toBe('attacker-1') + expect(message.action.defender_id).toBe('defender-2') + expect(message.action.attack_index).toBe(0) + }) + + it('handles different action types', () => { + /** + * Test createActionMessage with various action types. + * + * Different game actions should all be properly wrapped. + */ + const gameId = 'game-123' + + const playCardMessage = createActionMessage(gameId, { + action_type: 'play_card', + card_id: 'card-1', + }) + + const attachEnergyMessage = createActionMessage(gameId, { + action_type: 'attach_energy', + card_id: 'energy-1', + target_id: 'pokemon-1', + }) + + const endTurnMessage = createActionMessage(gameId, { + action_type: 'end_turn', + }) + + expect(playCardMessage.action.action_type).toBe('play_card') + expect(attachEnergyMessage.action.action_type).toBe('attach_energy') + expect(endTurnMessage.action.action_type).toBe('end_turn') + }) + + it('generates unique message IDs for different actions', () => { + /** + * Test that each action message has a unique ID. + * + * Even identical actions should have different message IDs. + */ + const gameId = 'game-123' + const action: GameAction = { action_type: 'end_turn' } + + const message1 = createActionMessage(gameId, action) + const message2 = createActionMessage(gameId, action) + + expect(message1.message_id).not.toBe(message2.message_id) + }) + }) + + describe('createResignMessage', () => { + it('creates a resign message with game ID', () => { + /** + * Test createResignMessage basic structure. + * + * Resign messages must include type, message_id, and game_id. + */ + const gameId = 'game-123' + + const message = createResignMessage(gameId) + + expect(message.type).toBe('resign') + expect(message.game_id).toBe(gameId) + expect(message.message_id).toBeTruthy() + }) + + it('generates unique message IDs', () => { + /** + * Test that each resign message has a unique ID. + * + * Message IDs must be unique even for the same game. + */ + const gameId = 'game-123' + + const message1 = createResignMessage(gameId) + const message2 = createResignMessage(gameId) + + expect(message1.message_id).not.toBe(message2.message_id) + }) + + it('includes only required fields', () => { + /** + * Test that resign messages don't include extra fields. + * + * Resign is simple - just type, message_id, and game_id. + */ + const gameId = 'game-123' + + const message = createResignMessage(gameId) + + const keys = Object.keys(message) + expect(keys).toContain('type') + expect(keys).toContain('message_id') + expect(keys).toContain('game_id') + expect(keys.length).toBe(3) + }) + }) + + describe('createHeartbeatMessage', () => { + it('creates a heartbeat message', () => { + /** + * Test createHeartbeatMessage basic structure. + * + * Heartbeat messages must include type and message_id. + */ + const message = createHeartbeatMessage() + + expect(message.type).toBe('heartbeat') + expect(message.message_id).toBeTruthy() + }) + + it('does not include game_id', () => { + /** + * Test that heartbeat messages are game-independent. + * + * Heartbeats maintain the connection regardless of active game. + */ + const message = createHeartbeatMessage() + + expect('game_id' in message).toBe(false) + }) + + it('generates unique message IDs', () => { + /** + * Test that each heartbeat has a unique ID. + * + * Message IDs must be unique for tracking. + */ + const message1 = createHeartbeatMessage() + const message2 = createHeartbeatMessage() + + expect(message1.message_id).not.toBe(message2.message_id) + }) + + it('includes only required fields', () => { + /** + * Test that heartbeat messages don't include extra fields. + * + * Heartbeat is minimal - just type and message_id. + */ + const message = createHeartbeatMessage() + + const keys = Object.keys(message) + expect(keys).toContain('type') + expect(keys).toContain('message_id') + expect(keys.length).toBe(2) + }) + }) + + describe('message factories integration', () => { + it('all factories generate valid UUID message IDs', () => { + /** + * Test that all message factories use proper UUID format. + * + * Consistent ID format ensures reliable message tracking. + */ + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + const joinMessage = createJoinGameMessage('game-1') + const actionMessage = createActionMessage('game-1', { action_type: 'end_turn' }) + const resignMessage = createResignMessage('game-1') + const heartbeatMessage = createHeartbeatMessage() + + expect(joinMessage.message_id).toMatch(uuidRegex) + expect(actionMessage.message_id).toMatch(uuidRegex) + expect(resignMessage.message_id).toMatch(uuidRegex) + expect(heartbeatMessage.message_id).toMatch(uuidRegex) + }) + + it('messages for the same game have different IDs', () => { + /** + * Test that multiple messages for one game have unique IDs. + * + * This ensures proper message tracking within a game session. + */ + const gameId = 'game-123' + + const message1 = createJoinGameMessage(gameId) + const message2 = createActionMessage(gameId, { action_type: 'end_turn' }) + const message3 = createResignMessage(gameId) + + const ids = [message1.message_id, message2.message_id, message3.message_id] + const uniqueIds = new Set(ids) + + expect(uniqueIds.size).toBe(3) // All IDs should be unique + }) + + it('message structure matches expected types', () => { + /** + * Test that factory output matches TypeScript type definitions. + * + * This validates that the factories produce correctly-typed messages. + */ + const gameId = 'game-123' + + const joinMessage = createJoinGameMessage(gameId) + expect(joinMessage).toHaveProperty('type') + expect(joinMessage).toHaveProperty('message_id') + expect(joinMessage).toHaveProperty('game_id') + + const actionMessage = createActionMessage(gameId, { action_type: 'end_turn' }) + expect(actionMessage).toHaveProperty('type') + expect(actionMessage).toHaveProperty('message_id') + expect(actionMessage).toHaveProperty('game_id') + expect(actionMessage).toHaveProperty('action') + + const resignMessage = createResignMessage(gameId) + expect(resignMessage).toHaveProperty('type') + expect(resignMessage).toHaveProperty('message_id') + expect(resignMessage).toHaveProperty('game_id') + + const heartbeatMessage = createHeartbeatMessage() + expect(heartbeatMessage).toHaveProperty('type') + expect(heartbeatMessage).toHaveProperty('message_id') + }) + }) + +})