Casos de prueba — Chat gateway y broadcast
Casos de prueba del ChatGateway (Socket.IO) y de los componentes de tiempo real que lo acompañan. Fuentes:
src/app/modules/chat/gateways/chat.gateway.spec.ts(unitario, 4773 líneas)src/app/modules/chat/gateways/chat.gateway.integration.spec.ts(integración conmongodb-memory-server)src/app/modules/chat/helpers/presence.helper.spec.tssrc/app/modules/socket-broadcast/socket-broadcast.service.spec.tssrc/app/modules/socket-broadcast/socket-broadcast.service.presence.spec.ts
Resumen: 5 archivos spec, 51 bloques describe (incluidos los anidados) y 175 tests it activos: 142 en chat.gateway.spec.ts (más 2 it.skip omitidos), 3 de integración, 11 en presence.helper.spec.ts (15 ejecuciones al expandir it.each) y 19 en socket-broadcast (14 + 5).
En chat.gateway.spec.ts se usan timers falsos de Jest (jest.useFakeTimers()), un AuthSocket falso (createMockClient autenticado y createUnauthClient sin autenticar) y el server del gateway reemplazado con Object.defineProperty. Los mocks base son TenantRepository, ContactRepository, TicketRepository, TicketService, MessageRepository, FileRepository, UserRepository, JwtService, ConnectionRepository, TranslateService, SocketBroadcastService, ExternalRequestService, StorageService, TenantSettingsService, MessageTemplateService, ConversationService, InternalMessageService, ConversationMemberRepository, InternalChatBroadcastService, OutboundRouterService y SystemMessageService. La columna Mocks solo nombra los relevantes por fila.
chat.gateway.spec.ts
handleConnection
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should disconnect the client after timeout if it never authenticates | El cliente se conecta y no se autentica; avanza AUTH_TIMEOUT_MS | Emite ERROR con AUTH_TIMEOUT y desconecta | timers falsos, createUnauthClient |
should NOT disconnect if the client authenticates before timeout | Se limpia el authTimer y se fija client.data.user antes del timeout | No se desconecta tras avanzar 10 s | timers falsos |
handleDisconnect
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should clear authTimer on disconnect | handleConnection y luego handleDisconnect | clearTimeout se invoca con el authTimer del cliente | spy de clearTimeout |
handleAuthenticate
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should emit authenticated and set client.data.user on valid token | sendType: 'system' con token de tenant válido | Emite AUTHENTICATED (tipo, tenantId, mensaje) y fija client.data.user; no desconecta | tenantRepository |
should authenticate with a connection token (SPEC 04) without touching tenant.tokens | Token de conexión web conectada y origin permitido | Autentica sin llamar findByTokenValue del tenant y tokenValue queda con el token de conexión | connectionRepository.findByToken, tenantRepository.findByMongoId |
should reject a connection token when the handshake origin is not whitelisted | Token de conexión, origen https://sitio-ajeno.com | Emite ERROR ORIGIN_NOT_ALLOWED, desconecta y no consulta el tenant | connectionRepository |
should reject a connection token when the handshake has no origin at all | Token de conexión sin cabecera origin | Emite ERROR ORIGIN_NOT_ALLOWED y desconecta | connectionRepository |
should reject a disconnected connection token that is not a tenant token either | Conexión en estado disconnected y sin tenant por token | Emite ERROR UNAUTHORIZED y desconecta | connectionRepository, tenantRepository |
should emit error and disconnect when tenant is not found | Token desconocido | ERROR UNAUTHORIZED y desconexión | tenantRepository |
should emit error and disconnect when tenant isActive is false | Tenant inactivo | ERROR UNAUTHORIZED y desconexión | tenantRepository |
should emit error and disconnect when tenant status is not active | Tenant con status: 'suspended' | ERROR UNAUTHORIZED y desconexión | tenantRepository |
should emit error and disconnect when the JWT is expired or malformed | jwtService.verify lanza jwt expired | ERROR TOKEN_EXPIRED y desconexión | jwtService |
should emit error and disconnect when the JWT resolves to no user | findByEmail devuelve null | ERROR UNAUTHORIZED y desconexión | userRepository |
should emit error and disconnect when the JWT user is inactive | Usuario del JWT con isActive: false | ERROR UNAUTHORIZED y desconexión | userRepository |
should reject an agent socket claiming a userId other than the JWT owner | El userId del payload no es el del dueño del JWT | ERROR UNAUTHORIZED y desconexión (un agente no puede abrir un socket como otro) | userRepository, jwtService |
should emit error and disconnect on unexpected repository error | findByEmail rechaza con DB error | ERROR AUTH_ERROR y desconexión | userRepository |
contact: should authenticate and emit authenticated with contact data | Contacto con externalId y username válidos | findOrCreate con channelType WEB; emite AUTHENTICATED con datos del contacto y fija client.data.user | contactRepository.findOrCreate |
contact: joins the ticket room when the contact already has an OPEN ticket | Existe un ticket abierto del contacto | client.join('ticket:<id>') | ticketRepository.findLatestByContact |
contact: does NOT join any ticket room when there is no existing OPEN ticket | Sin ticket abierto | No se une a ninguna sala ticket: | ticketRepository.findLatestByContact |
contact: should create a new contact when externalId does not exist for the tenant | findOrCreate devuelve created: true | Se llama a findOrCreate y no se desconecta | contactRepository |
contact: should reuse existing contact when externalId already exists for the tenant | findOrCreate devuelve un contacto existente | Se reutiliza (externalId correcto), sin desconexión | contactRepository |
contact: verifies against the host when externalIntegration is configured and uses its identity | Tenant con externalIntegration y searchContact devuelve identidad verificada | searchContact con url, método, token y id numérico; findOrCreate con el nombre y email del host | externalRequestService.searchContact |
contact: falls back to the browser-supplied identity when the host has no record of the contact | searchContact devuelve null | Usa la identidad enviada por el navegador y no desconecta | externalRequestService |
contact: should emit error and disconnect when token is invalid | Token inexistente | ERROR UNAUTHORIZED y desconexión | tenantRepository |
contact: should emit error and disconnect when tenant isActive is false | Tenant inactivo | ERROR UNAUTHORIZED y desconexión | tenantRepository |
contact: should emit error and disconnect when contact is blocked | Contacto con isBlocked: true | ERROR CONTACT_BLOCKED y desconexión | contactRepository |
contact: should emit error when externalId or username are missing | Falta externalId y username | ERROR CONTACT_FIELDS_REQUIRED y desconexión | tenantRepository |
ChatGateway (nivel raíz) — autenticación user
Estos tests están definidos directamente bajo describe('ChatGateway'). Hay además 2 casos con it.skip (no se ejecutan): user: should authenticate, call searchUser and emit authenticated with externalId + username y user: should authenticate without userId when no User doc exists in MongoDB.
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
user: should emit error and disconnect when id is missing | sendType: 'user' sin userId | ERROR USER_ID_REQUIRED y desconexión | tenantRepository |
user: should emit error and disconnect when tenant has no externalIntegration | Tenant con externalIntegration: null | ERROR UNAUTHORIZED y desconexión | tenantRepository |
user: should emit USER_NOT_FOUND and disconnect when external API returns null | searchUser devuelve null | ERROR USER_NOT_FOUND, desconexión y no se consulta findByExternalIdAndTenant | externalRequestService.searchUser |
user: should emit error and disconnect when tenant is inactive | Tenant inactivo | ERROR UNAUTHORIZED y desconexión | tenantRepository |
user: should emit error and disconnect when the JWT is expired | verify lanza jwt expired | ERROR TOKEN_EXPIRED y desconexión | jwtService |
getUser — token expired in memory
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should emit error and disconnect when tokenExpiration is in the past | Cliente con tokenExpiration vencido invoca handleJoinTicket | ERROR TOKEN_EXPIRED_RECONNECT y desconexión | createMockClient |
handleJoinTicket — guard
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should emit error when client is not authenticated | Cliente sin autenticar | ERROR NOT_AUTHENTICATED | createUnauthClient |
handleSendMessage
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should emit error when client is not authenticated | Cliente sin autenticar envía mensaje | ERROR NOT_AUTHENTICATED | createUnauthClient |
agent without ticketId: should emit error (no ticket to resolve) | Agente sin ticketId | ERROR MESSAGE_ROOM_ERROR y no se persiste | messageRepository |
handleSendMessage — firma del operador
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
persiste y emite el body firmado cuando escribe un agente | Agente Richard Garcia envía respuesta del agente | Persiste y emite NEW_MESSAGE/TICKET_UPDATED con el cuerpo *Richard Garcia*:\nrespuesta del agente | ticketRepository, messageRepository.create, server.to |
enruta el mensaje del agente hacia afuera del CRM | Agente envía un mensaje | outboundRouter.route se invoca una vez (corrige el bug de que el mensaje no salía a WhatsApp) | outboundRouter.route |
no reenvía al canal lo que escribió el propio contacto | Contacto envía un mensaje | outboundRouter.route no se invoca | outboundRouter |
no firma cuando el switch del composer viene apagado | Agente con signature: false | El cuerpo persistido es sin firma | messageRepository.create |
no firma los mensajes de sistema (no los escribe una persona) | sendType: 'system' | El cuerpo persistido no lleva firma | messageRepository.create |
handleSendNote
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should emit error when client is not authenticated | Cliente sin autenticar | ERROR NOT_AUTHENTICATED y no se persiste | messageRepository |
rejects/ignores the call when the calling socket sendType is contact: does NOT persist, does NOT emit new_note | Un socket de contacto intenta enviar una nota | No se persiste ni se emite NEW_NOTE | messageRepository, server.to |
agent: persists a Message with messageType note and sendType user | Agente envía nota en un ticket del tenant | create con messageType: 'note', sendType: 'user', cuerpo, ticketId y tenantId | ticketRepository.findByIdAndTenant, messageRepository.create |
agent: emits new_note ONLY to tenant:{tenantId}, never to ticket:{ticketId} | Agente envía nota | Emite NEW_NOTE a tenant:<id> y nunca a ticket:<id> | server.to |
agent: does NOT call updateLastMessage and does NOT emit ticket_updated/ticket_created | Agente envía nota | No actualiza último mensaje ni emite TICKET_UPDATED/TICKET_CREATED | ticketRepository, server.to |
cross-tenant ticketId: emits error, does NOT persist | findByIdAndTenant devuelve null | Emite ERROR y no persiste | ticketRepository |
handleLeaveTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should leave ticket room and notify others | Cliente autenticado abandona un ticket | client.leave('ticket:<id>'), TICKET_LEFT al cliente y USER_LEFT a la sala | server.to |
should emit error when client is not authenticated | Cliente sin autenticar | ERROR NOT_AUTHENTICATED | createUnauthClient |
handleSendMessage — lastContactMessageAt denormalization
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
contact message: calls updateLastMessage with isFromContact=true | Un contacto envía un mensaje | updateLastMessage(ticketId, messageId, true) | ticketRepository.findOrCreateForContact/updateLastMessage |
agent message: calls updateLastMessage with isFromContact=false — does NOT denormalize lastContactMessageAt | Un agente envía un mensaje | updateLastMessage(ticketId, messageId, false) | ticketRepository |
handleSendMessage — CSAT ingest
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
regression: does nothing special when ticket.csat is not present (existing behavior untouched) | Ticket sin csat, contacto escribe 5 | No se llama recordCsatRating ni recordCsatComment | ticketRepository |
pending + rating "5" + requestComment=false: records rating, sets status=answered, sends thank-you | CSAT pending, el contacto responde 5 | recordCsatRating con rating: 5 y status: 'answered' | ticketRepository.recordCsatRating, tenantSettingsService, systemMessageService |
pending + rating "5" trimmed with surrounding whitespace still matches | Contacto responde 4 | recordCsatRating con rating: 4 | ticketRepository |
pending + requestComment=true and no prior comment: sets status=awaiting_comment | requestComment: true; contacto responde 3 | recordCsatRating con rating: 3 y status: 'awaiting_comment' | tenantSettingsService |
pending + non-matching text: does NOT record a rating, message flows normally | Contacto escribe hola, gracias! | No se registra calificación y el mensaje se persiste | ticketRepository, messageRepository |
pending + a number outside 1-5 range: does NOT record a rating | Contacto escribe 9 | No se registra calificación | ticketRepository |
awaiting_comment + no comment yet: next contact message is captured as the comment, status=answered | CSAT awaiting_comment sin comentario | recordCsatComment(ticketId, 'Muy buena atención') | ticketRepository.recordCsatComment |
awaiting_comment + comment already recorded: does NOT re-capture, message flows normally | CSAT awaiting_comment con comentario ya guardado | No se llama recordCsatComment | ticketRepository |
answered/expired/not_applicable status: never intercepts, no CSAT repository call | Itera los estados answered, expired y not_applicable con el contacto escribiendo 5 | Nunca se invoca el repositorio CSAT | ticketRepository |
does not intercept agent messages even when ticket.csat.status=pending (only contact messages are CSAT answers) | Agente escribe 5 con CSAT pendiente | No se registra calificación | ticketRepository |
consumed CSAT rating message does NOT trigger auto-assign, even when ticket has no UserId | Calificación consumida en ticket sin UserId | No se llama assignOnContactMessage | ticketService.assignOnContactMessage |
still persists the incoming rating message normally as chat history | Contacto responde 5 | Se persiste el mensaje con cuerpo 5 y sendType: 'contact' | messageRepository.create |
handleSendFile
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should emit error when client is not authenticated | Cliente sin autenticar | ERROR NOT_AUTHENTICATED | createUnauthClient |
should emit error when file is not found for the tenant | findByIdAndTenant devuelve null | ERROR FILE_NOT_FOUND | fileRepository |
agent without ticketId: file found but resolveTicket fails — emits error, no persist | Archivo válido y agente sin ticketId | Emite ERROR y no persiste | fileRepository, messageRepository |
resolveTicket (via handleSendMessage)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
contact path: calls findOrCreateForContact and returns ticket, persists message | Contacto envía hello | findOrCreateForContact(tenantOid, contactOid, undefined, undefined, contenido); persiste y emite NEW_MESSAGE | ticketRepository.findOrCreateForContact, messageRepository |
W2: threads the raw message content through to findOrCreateForContact so CSAT-pending routing can be judged by content | Contacto envía 5 | El contenido crudo 5 llega como último argumento de findOrCreateForContact | ticketRepository |
agent path with no dtoTicketId: emits ERROR, does NOT persist | Agente sin ticketId | Emite ERROR y no persiste | messageRepository |
agent path with valid dtoTicketId: calls findByIdAndTenant and persists | Agente con ticketId válido | findByIdAndTenant con ObjectIds, persiste y emite NEW_MESSAGE | ticketRepository.findByIdAndTenant |
agent path with cross-tenant ticketId: emits ERROR, does NOT persist | findByIdAndTenant devuelve null | Emite ERROR y no persiste | ticketRepository |
handleSendMessage (PR-2 rewrite)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
persists with required fields (typed payload, no cast) | Contacto envía mensaje | create con tenantId, ticketId, sendType: 'contact', messageType: 'chat', body y ack: 'sent' | messageRepository.create |
emits NEW_MESSAGE to ticket:{ticketId} and TICKET_UPDATED to tenant:{tenantMongoId} | Contacto envía mensaje | server.to a ticket:<id> y tenant:<id>; emite NEW_MESSAGE y TICKET_UPDATED | server.to |
NEW_MESSAGE payload includes contactId from ticket.ContactId (not a persisted field) | Contacto envía mensaje | El payload NEW_MESSAGE trae contactId del ticket | server.to |
parentId referring to a message in the SAME ticket: persists parentId and includes parent snippet | Respuesta a un mensaje del mismo ticket | Persiste parentId y el payload incluye parent (_id, cuerpo, tipo, sendType) | messageRepository.findById, ticketRepository.findByIdAndTenant |
parentId referring to a message in a DIFFERENT ticket but the SAME contact (closed/reopened ticket history): persists parentId and includes parent snippet | El padre está en un ticket anterior del mismo contacto | Persiste parentId e incluye parent | messageRepository.findById, ticketRepository.findByIdAndTenant |
parentId referring to a message in a DIFFERENT contact: does NOT persist parentId nor include parent (security) | El padre pertenece a un ticket de otro contacto | No se persiste parentId; el mensaje se envía normal con parentId y parent en null | messageRepository.findById, ticketRepository.findByIdAndTenant |
no parentId at all: behaves as before — no parent, no parentId, no findById lookup | Mensaje sin parentId | No consulta findById, no persiste parentId y el payload lo emite en null | messageRepository |
absent parentId emits as null in NEW_MESSAGE payload | Mensaje sin parentId | parentId es null en NEW_MESSAGE | server.to |
calls client.join ticket room (idempotent) | Contacto envía mensaje | client.join('ticket:<id>') | client.join |
TICKET_UPDATED payload is flat {ticketId, lastMessage, updatedAt} | Contacto envía mensaje | TICKET_UPDATED plano con ticketId, lastMessage y updatedAt | server.to |
handleSendFile (PR-2 rewrite)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits error FILE_NOT_FOUND when file lookup returns null | findByIdAndTenant devuelve null | ERROR FILE_NOT_FOUND y no persiste | fileRepository |
file found but resolveTicket fails (no ticketId for agent): emits ERROR, no persist | Agente sin ticketId | Emite ERROR y no persiste | fileRepository |
persists MEDIA message with correct fields and emits new-file-message + ticket_updated | Contacto envía un archivo | Persiste mensaje de tipo media y emite NEW_FILE_MESSAGE a ticket:<id> y TICKET_UPDATED a tenant:<id> | fileRepository, messageRepository, server.to |
persiste y emite el caption firmado cuando el agente adjunta con texto | Agente adjunta con caption te mando la captura | Persiste y emite *Richard Garcia*:\nte mando la captura | messageRepository, server.to |
usa el caption como preview del ticket, no el nombre del archivo | Agente adjunta con caption la factura del mes | TICKET_UPDATED usa el caption como lastMessage | server.to |
emite caption null y no lo persiste cuando el adjunto va sin texto | Caption vacío (' ') | El caption no se persiste y se emite null | messageRepository, server.to |
enruta el adjunto del agente hacia afuera del CRM | Agente envía un archivo | outboundRouter.route se invoca una vez | outboundRouter.route |
no reenvía al canal el adjunto que subió el propio contacto | Contacto envía un archivo | outboundRouter.route no se invoca | outboundRouter |
new-file-message payload has contactId from ticket.ContactId, parentId, file sub-object | Contacto envía archivo respondiendo a un mensaje | Payload con contactId, parentId, parent y subobjeto file | server.to, messageRepository.findById |
resolves the file url dynamically via storageService.getSignedUrl(storageKey) instead of reusing the stored (permanent) url | Archivo con URL permanente guardada | Llama getSignedUrl(storageKey) y file.url es la URL firmada, distinta de la almacenada | storageService.getSignedUrl |
new-file-message parentId from a DIFFERENT ticket but the SAME contact (closed/reopened ticket history): includes parent and parentId | Padre en ticket previo del mismo contacto | Persiste e incluye parent y parentId | messageRepository.findById, ticketRepository |
new-file-message parentId from a DIFFERENT contact: does NOT include parent nor parentId (security) | Padre de otro contacto | No persiste parentId; el payload lo emite null junto con parent | messageRepository.findById, ticketRepository |
absent parentId emits as null in new-file-message | Archivo sin parentId | parentId es null | server.to |
calls client.join ticket room after file send | Contacto envía archivo | client.join('ticket:<id>') | client.join |
PR-3: authenticateAgentOrSystem joins tenant room on auth
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
agent: client.join is called with tenant:{_id} (Mongo _id, not identifier) | Agente se autentica | Se une a tenant:<_id> y no a tenant:<identifier> | tenantRepository, userRepository |
system: client.join is called with tenant:{_id} | Conexión de sistema se autentica | Se une a tenant:<_id> | tenantRepository |
PR-3: authenticateContact does NOT join any tenant room
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
contact: client.join is never called with tenant:* prefix | Contacto se autentica | Ningún join con prefijo tenant: | tenantRepository, externalRequestService, contactRepository |
handleJoinTicket (ticket-based)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
valid ticketId for same tenant: joins ticket room and emits TICKET_JOINED | Ticket del mismo tenant | findByIdAndTenant, join('ticket:<id>') y TICKET_JOINED | ticketRepository |
does NOT return message history (history is REST-only) | Unión válida | No se lee historial; TICKET_JOINED es solo { ticketId } sin messages | messageRepository.findAllByTicket |
cross-tenant ticketId: emits error and does NOT join any room | findByIdAndTenant devuelve null | ERROR y ningún join a ticket: | ticketRepository |
unauthenticated client: emits error | Cliente sin autenticar | ERROR NOT_AUTHENTICATED | createUnauthClient |
reactive disconnect helpers
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
disconnectUser targets user:{id} room and disconnects its sockets | disconnectUser('principal-123') | server.to('user:principal-123') y disconnectSockets(true) | server.to |
disconnectTenant targets tenant-sessions:{id} control room | disconnectTenant('tenant-abc') | server.to('tenant-sessions:tenant-abc') y disconnectSockets(true) | server.to |
session-control room joins on auth
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
agent joins user:{userId} and tenant-sessions:{_id} control rooms | Agente se autentica | join('user:<userId>') y join('tenant-sessions:<_id>') | tenantRepository, userRepository |
contact joins user:{contactId} + tenant-sessions:{_id} but NOT the agents-only fan-out tenant:{_id} | Contacto se autentica | Se une a user:<contactId> y tenant-sessions:<_id>, nunca a tenant:<_id> | externalRequestService, contactRepository |
PR-4: handleMarkRead (tenant guard + dual-room emit)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
unauthenticated client: emits error, advanceTicketAck NOT called | Cliente sin autenticar | ERROR NOT_AUTHENTICATED y no se llama advanceTicketAck | messageRepository |
cross-tenant ticketId: findByIdAndTenant returns null → no markRead, no MESSAGE_ACK_CHANGED emitted | Ticket de otro tenant | No se avanza ack, no se emite MESSAGE_ACK_CHANGED, no se usa server.emit global y el emisor recibe ERROR | ticketRepository, messageRepository |
same-tenant ticketId: advanceTicketAck called, MESSAGE_ACK_CHANGED emitted to ticket + tenant rooms | Agente marca leído | advanceTicketAck(ticketId, 'read', 'agent'); emite a ticket:<id> y tenant:<id> | messageRepository.advanceTicketAck, server.to |
MESSAGE_ACK_CHANGED payload carries the advanced ids, the new ack and a Date | Agente marca leído | Payload con ticketId, messageIds, ack: 'read' y at de tipo Date | server.to |
global server.emit is NEVER called (no cross-tenant leak) | Agente marca leído | No se llama server.emit global | server.emit |
handleMessageDelivered
Bloque anidado dentro de PR-4: handleMarkRead (tenant guard + dual-room emit).
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
unauthenticated client: emits error, nothing advanced | Cliente sin autenticar | ERROR NOT_AUTHENTICATED y no se llama advanceMessagesAck | messageRepository |
cross-tenant ticketId: nothing advanced, no MESSAGE_ACK_CHANGED emitted | Ticket de otro tenant | No avanza ni emite MESSAGE_ACK_CHANGED | ticketRepository, messageRepository |
contact socket: advances the backoffice-authored messages it acknowledged | Socket de contacto confirma entrega | advanceMessagesAck(ticketId, ids, 'delivered', 'contact') y emite MESSAGE_ACK_CHANGED (ack: 'delivered') a ambas salas | messageRepository.advanceMessagesAck, server.to |
agent socket: the audience flips to the contact-authored side | Socket de agente confirma entrega | advanceMessagesAck(..., 'delivered', 'agent') | messageRepository |
nothing actually advanced: no event is emitted at all | advanceMessagesAck devuelve [] | No se emite ningún evento | messageRepository, server.to |
handleMarkRead from a contact socket marks the backoffice side, not its own messages | Contacto invoca handleMarkRead | advanceTicketAck(ticketId, 'read', 'contact') | messageRepository.advanceTicketAck |
handleSendMessage — auto mark-as-read on reply (both directions)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
agent replies (sendType USER) on a ticket with unread contact messages: advanceTicketAck called + MESSAGE_ACK_CHANGED emitted to ticket + tenant rooms | Un usuario (sendType: 'user') responde | advanceTicketAck(ticketId, 'read', 'agent') y emite MESSAGE_ACK_CHANGED (ack: 'read') a ticket: y tenant: | messageRepository.advanceTicketAck, server.to |
contact replies: advanceTicketAck called with the contact audience + MESSAGE_ACK_CHANGED emitted to ticket + tenant rooms | Un contacto responde | advanceTicketAck(ticketId, 'read', 'contact') y emite MESSAGE_ACK_CHANGED | messageRepository, server.to |
system connection sends a message (sendType SYSTEM): advanceTicketAck NOT called, MESSAGE_ACK_CHANGED NOT emitted (guard is explicit USER/AGENT, not ``!== CONTACT``) | Conexión de sistema envía un mensaje | No se llama advanceTicketAck ni se emite MESSAGE_ACK_CHANGED | messageRepository, server.to |
advanceTicketAck rejects: handleSendMessage still resolves and persists the message without throwing | advanceTicketAck rechaza con db down | El mensaje se persiste y handleSendMessage no lanza | messageRepository.advanceTicketAck rechazado |
PR-4: persistence — no contactId key on persisted Message doc
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
send_message persisted doc does NOT contain a contactId field | Contacto envía mensaje | El documento enviado a create no tiene la clave contactId | messageRepository.create |
send_message two contact sends with no prior ticket → findOrCreateForContact called twice, both persisted | Dos envíos consecutivos | findOrCreateForContact y create se llaman dos veces | ticketRepository, messageRepository |
room targeting: new_message → server.to(ticket:id); ticket_updated → server.to(tenant:_id); NEVER bare server.emit | Contacto envía mensaje | new_message a ticket:<id>, ticket_updated a tenant:<id> y nunca server.emit | server.to, server.emit |
handleImTyping / handleImStopTyping — membership check
Bloque contenedor; sus tests están en los dos bloques siguientes.
handleImTyping
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
member: broadcasts im:user-typing to the conversation room | El usuario es miembro de la conversación | Consulta la membresía con ObjectIds y emite USER_TYPING a conversation:<id> | conversationMemberRepository.findOne, client.to |
non-member / other tenant: does NOT broadcast, emits NOT_FOUND instead | findOne devuelve null | No difunde; emite ERROR con INTERNAL_CHAT_RESPONSES.NOT_FOUND | conversationMemberRepository |
unauthenticated: does NOT call the membership repository | Cliente sin autenticar | No consulta la membresía; emite ERROR NOT_AUTHENTICATED | conversationMemberRepository |
handleImStopTyping
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
member: broadcasts im:user-stop-typing to the conversation room | Usuario miembro | Emite USER_STOP_TYPING a conversation:<id> | conversationMemberRepository, client.to |
non-member / other tenant: does NOT broadcast, emits NOT_FOUND instead | No miembro | No difunde; emite ERROR NOT_FOUND | conversationMemberRepository |
Contact presence — raw disconnect listener
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
registers the raw disconnect listener only once even if the same socket re-authenticates as a contact (guard flag) | El mismo socket se autentica dos veces como contacto | Solo se registra un listener disconnect | client.on |
Contact presence — disconnect classification + broadcast
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
classifies a clean close (leave-intent) and broadcasts isOnline:false to tenant:{id} only | El contacto avisa handleContactLeaving y cierra con transport close; sin sockets restantes | setPresence con isOnline: false y lastDisconnectReason: 'clean'; broadcastContactPresenceChanged con disconnectReason: 'clean' | contactRepository.setPresence, socketBroadcast, server.in |
classifies a silent network drop (no leave-intent) as network-loss | Cierre con ping timeout sin aviso | Broadcast con disconnectReason: 'network-loss' | contactRepository.setPresence, socketBroadcast |
does NOT persist/broadcast offline when another tab of the same contact is still connected | Quedan otras pestañas en la sala del contacto | No se persiste ni se difunde el estado offline | server.in().fetchSockets, contactRepository, socketBroadcast |
still broadcasts offline even when the persistence write throws (live UX outranks the persisted mirror) | setPresence rechaza con db write error | Se difunde igualmente isOnline: false | contactRepository.setPresence rechazado |
Contact presence — heartbeat sweep (D4)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
reads ONLY this.server.local.fetchSockets() (this instance) and never the room/adapter fetchSockets used for cross-instance operations | afterInit y avanza PRESENCE_HEARTBEAT_MS | Usa server.local.fetchSockets y no el de sala/adaptador | server.local.fetchSockets, server.in |
touches lastSeen for every local contact socket found on the tick | Hay un socket de contacto y uno de agente | touchLastSeen(['c1'], Date) (solo contactos) | contactRepository.touchLastSeen |
skips the write entirely when this instance holds zero contact sockets | Sin sockets locales | No se llama touchLastSeen | contactRepository |
clears the interval on onModuleDestroy — no leaked timer | afterInit y luego onModuleDestroy | Se llama clearInterval y un tick posterior no toca el repositorio | spy de clearInterval, timers falsos |
Contact presence — regression guard (agent/internal-chat presence unaffected)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
agent disconnect still fires im:presence-changed (broadcastPresenceChanged) exactly as before | Un agente se desconecta sin sockets restantes | broadcastPresenceChanged(tenantId, userId, false) | internalChatBroadcastService, server.in |
does not bind a contact-presence disconnect listener when a socket authenticates as agent/system | Autenticación como agente | No se registra listener disconnect | client.on |
handleDisconnect body remains untouched: still clears authTimer for every client regardless of sendType | handleConnection y handleDisconnect | clearTimeout con el authTimer | spy de clearTimeout |
Contact presence — tenant isolation (AC5)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
broadcastContactPresenceChanged targets only the contact tenant room — never a second tenant | Un contacto se conecta como primera pestaña | broadcastContactPresenceChanged con el tenant del contacto y payload { contactId, isOnline: true, lastSeen, disconnectReason: null }; nunca con otro tenant | socketBroadcast, contactRepository.setPresence, server.in |
chat.gateway.integration.spec.ts
Pruebas de integración con mongodb-memory-server y un registro de salas en memoria que imita socket.io (FakeSocket, FakeRoomRegistry), usando los repositorios reales.
ChatGateway.handleSendNote — integration (mongodb-memory-server)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
a contact socket joined to ticket:{ticketId} receives nothing when an agent sends a note; an agent socket joined to tenant:{tenantId} receives new_note | Un contacto en ticket:<id>, un agente en tenant:<id> y otro agente envía una nota | El contacto no recibe NEW_NOTE; el agente de la sala del tenant sí; la nota se persiste de verdad (messageType: 'note', sendType: 'user') | MessageRepository y TicketRepository reales, registro de salas falso |
ChatGateway — contact presence integration (mongodb-memory-server)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
contact disconnect → classifies clean/network-loss correctly, persists via the REAL ContactRepository, and emits exactly once to tenant:{id} | El contacto se desconecta con ping timeout | Persiste isOnline: false y lastDisconnectReason: 'network-loss'; el agente del tenant recibe CONTACT_PRESENCE_CHANGED una sola vez | ContactRepository y SocketBroadcastService reales |
AC5 tenant isolation: tenant-A contact presence change reaches only tenant-A agents, never tenant-B | Desconexión limpia (client namespace disconnect) del contacto del tenant A | El agente del tenant A recibe el evento y el del tenant B no | ContactRepository y SocketBroadcastService reales |
presence.helper.spec.ts
Los dos it.each se expanden en 4 y 2 ejecuciones adicionales; aquí se documentan las definiciones de test.
classifyContactDisconnect
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
classifies as clean whenever leaveIntent is true, regardless of reason | leaveIntent verdadero con ping timeout, transport error, transport close y un motivo desconocido | clean en todos los casos | Ninguno |
leaveIntent = false
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
classifies "%s" as clean (it.each) | Motivos client namespace disconnect, server namespace disconnect, server shutting down, forced close, forced server close | clean | Ninguno |
classifies "%s" as network-loss (it.each) | Motivos ping timeout, transport error, parse error | network-loss | Ninguno |
classifies "transport close" as network-loss (no leave-intent arrived) | transport close sin intención de salida | network-loss | Ninguno |
classifies an unknown reason as network-loss (explicit product bias toward offline) | Motivo desconocido | network-loss (sesgo explícito hacia offline) | Ninguno |
selectContactIdsForHeartbeat
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns contactMongoId for every socket with sendType contact | Dos sockets de contacto | ['c1', 'c2'] | Ninguno |
filters out non-contact sockets (agent/user/system) | Mezcla de agente, contacto, usuario y sistema | Solo ['c1'] | Ninguno |
dedupes multi-tab sockets sharing the same contactMongoId | Dos sockets con el mismo contacto | ['c1'] | Ninguno |
tolerates sockets with missing data.user | Sockets sin data.user | Los ignora y devuelve ['c1'] | Ninguno |
tolerates a contact socket with no contactMongoId yet | Contacto sin contactMongoId | [] | Ninguno |
returns an empty array for empty input | Lista vacía | [] | Ninguno |
socket-broadcast.service.spec.ts
Se instancia SocketBroadcastService con un servidor falso (setServer) cuyas funciones to/in son mocks. Los métodos deben ser seguros cuando aún no se ha asignado un servidor.
SocketBroadcastService — delete-ticket capabilities
Es el describe raíz del archivo; sus pruebas están en los bloques siguientes.
evictTicketRoom
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
calls in(room).socketsLeave(room) for the given ticket room | evictTicketRoom('ticket-123') | in('ticket:ticket-123') y socketsLeave('ticket:ticket-123') | server.in, socketsLeave |
does nothing when no server has been set | Servicio sin servidor | No lanza error | Ninguno |
broadcastTicketClosed
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits ticket_closed to the ticket room with ticketId, contactId and reason | broadcastTicketClosed('ticket-123', 'contact-456', 'deleted') | Emite ticket_closed a ticket:ticket-123 con ticketId, contactId y reason | server.to |
broadcastTicketDeleted
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits ticket_deleted to the tenant fan-out room with ticketId only | broadcastTicketDeleted('tenant-789', 'ticket-123') | Emite ticket_deleted a tenant:tenant-789 solo con ticketId | server.to |
broadcastContactPresenceChanged
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits contact_presence_changed to tenant:{tenantMongoId} ONLY, with the full payload | Cambio de presencia en línea con lastSeen | Una sola llamada a to('tenant:tenant-abc') y evento con el payload completo | server.to |
does not throw when no server has been set | Sin servidor | No lanza error | Ninguno |
broadcastTicketLockChanged
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits ticket_lock_changed to tenant:{tenantMongoId} ONLY, with the full payload | Ticket bloqueado por un agente | Emite ticket_lock_changed solo a tenant:tenant-abc con lockedBy y lockedByName | server.to |
emits with lockedBy/lockedByName null on unlock | Desbloqueo | El payload lleva lockedBy y lockedByName en null | server.to |
does not throw when no server has been set | Sin servidor | No lanza error | Ninguno |
broadcastAgentMessage (scheduled-messages fire time, chat)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits new_message to ticket:{id}, authored by the snapshot sender | Mensaje programado de un agente al ejecutarse | Emite new_message a ticket:ticket-123 con dbId, ticketId, contactId, remitente, cuerpo, timestamp y parentId | server.to |
broadcastAgentNote (scheduled-messages fire time, note)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits new_note to tenant:{id} ONLY — mirrors handleSendNote, no ticket room, no updateLastMessage side effect | Nota programada | Emite new_note solo a tenant:tenant-abc (una llamada a to) con el payload completo | server.to |
broadcastTicketUpdated (scheduled-messages fire time, chat branch)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits ticket_updated to tenant:{id}, same payload shape as a live send | Actualización tras un mensaje programado | Emite ticket_updated a tenant:tenant-abc con ticketId, lastMessage, sendType y updatedAt | server.to |
broadcastScheduledMessageEvent (agent-only allow-list channel)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
emits to user:{authorUserId} ONLY — a contact can never be in this room | Evento scheduled_message_sent | Emite solo a user:user-1 con el payload recibido | server.to |
does not throw when no server has been set | Evento scheduled_message_cancelled sin servidor | No lanza error | Ninguno |
socket-broadcast.service.presence.spec.ts
El servidor falso devuelve sockets de agente en línea, agente pausado (isPaused), contacto, sistema, agente sin id y un socket sin data.user.
SocketBroadcastService — backoffice presence
Es el describe raíz; sus pruebas están en los bloques siguientes.
getConnectedAgentIds
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns the non-paused backoffice users of the tenant room | getConnectedAgentIds('tenant-1') | Consulta in('tenant:tenant-1') y devuelve solo ['agent-online'] (excluye pausados, contactos y sistema) | server.in().fetchSockets |
returns an empty list when no server has been set | Servicio sin servidor | [] | Ninguno |
isUserOnline
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
is true for a connected backoffice user, even when paused | Agente en línea y agente pausado | true en ambos | server.in().fetchSockets |
is false for a user without a backoffice socket in the tenant room | Agente desconectado y un contacto | false en ambos | server.in().fetchSockets |
is false when no server has been set | Sin servidor | false | Ninguno |