Skip to main content

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 con mongodb-memory-server)
  • src/app/modules/chat/helpers/presence.helper.spec.ts
  • src/app/modules/socket-broadcast/socket-broadcast.service.spec.ts
  • src/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).

note

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​

TestEscenarioResultado esperadoMocks
should disconnect the client after timeout if it never authenticatesEl cliente se conecta y no se autentica; avanza AUTH_TIMEOUT_MSEmite ERROR con AUTH_TIMEOUT y desconectatimers falsos, createUnauthClient
should NOT disconnect if the client authenticates before timeoutSe limpia el authTimer y se fija client.data.user antes del timeoutNo se desconecta tras avanzar 10 stimers falsos

handleDisconnect​

TestEscenarioResultado esperadoMocks
should clear authTimer on disconnecthandleConnection y luego handleDisconnectclearTimeout se invoca con el authTimer del clientespy de clearTimeout

handleAuthenticate​

TestEscenarioResultado esperadoMocks
should emit authenticated and set client.data.user on valid tokensendType: 'system' con token de tenant válidoEmite AUTHENTICATED (tipo, tenantId, mensaje) y fija client.data.user; no desconectatenantRepository
should authenticate with a connection token (SPEC 04) without touching tenant.tokensToken de conexión web conectada y origin permitidoAutentica sin llamar findByTokenValue del tenant y tokenValue queda con el token de conexiónconnectionRepository.findByToken, tenantRepository.findByMongoId
should reject a connection token when the handshake origin is not whitelistedToken de conexión, origen https://sitio-ajeno.comEmite ERROR ORIGIN_NOT_ALLOWED, desconecta y no consulta el tenantconnectionRepository
should reject a connection token when the handshake has no origin at allToken de conexión sin cabecera originEmite ERROR ORIGIN_NOT_ALLOWED y desconectaconnectionRepository
should reject a disconnected connection token that is not a tenant token eitherConexión en estado disconnected y sin tenant por tokenEmite ERROR UNAUTHORIZED y desconectaconnectionRepository, tenantRepository
should emit error and disconnect when tenant is not foundToken desconocidoERROR UNAUTHORIZED y desconexióntenantRepository
should emit error and disconnect when tenant isActive is falseTenant inactivoERROR UNAUTHORIZED y desconexióntenantRepository
should emit error and disconnect when tenant status is not activeTenant con status: 'suspended'ERROR UNAUTHORIZED y desconexióntenantRepository
should emit error and disconnect when the JWT is expired or malformedjwtService.verify lanza jwt expiredERROR TOKEN_EXPIRED y desconexiónjwtService
should emit error and disconnect when the JWT resolves to no userfindByEmail devuelve nullERROR UNAUTHORIZED y desconexiónuserRepository
should emit error and disconnect when the JWT user is inactiveUsuario del JWT con isActive: falseERROR UNAUTHORIZED y desconexiónuserRepository
should reject an agent socket claiming a userId other than the JWT ownerEl userId del payload no es el del dueño del JWTERROR UNAUTHORIZED y desconexión (un agente no puede abrir un socket como otro)userRepository, jwtService
should emit error and disconnect on unexpected repository errorfindByEmail rechaza con DB errorERROR AUTH_ERROR y desconexiónuserRepository
contact: should authenticate and emit authenticated with contact dataContacto con externalId y username válidosfindOrCreate con channelType WEB; emite AUTHENTICATED con datos del contacto y fija client.data.usercontactRepository.findOrCreate
contact: joins the ticket room when the contact already has an OPEN ticketExiste un ticket abierto del contactoclient.join('ticket:<id>')ticketRepository.findLatestByContact
contact: does NOT join any ticket room when there is no existing OPEN ticketSin ticket abiertoNo se une a ninguna sala ticket:ticketRepository.findLatestByContact
contact: should create a new contact when externalId does not exist for the tenantfindOrCreate devuelve created: trueSe llama a findOrCreate y no se desconectacontactRepository
contact: should reuse existing contact when externalId already exists for the tenantfindOrCreate devuelve un contacto existenteSe reutiliza (externalId correcto), sin desconexióncontactRepository
contact: verifies against the host when externalIntegration is configured and uses its identityTenant con externalIntegration y searchContact devuelve identidad verificadasearchContact con url, método, token y id numérico; findOrCreate con el nombre y email del hostexternalRequestService.searchContact
contact: falls back to the browser-supplied identity when the host has no record of the contactsearchContact devuelve nullUsa la identidad enviada por el navegador y no desconectaexternalRequestService
contact: should emit error and disconnect when token is invalidToken inexistenteERROR UNAUTHORIZED y desconexióntenantRepository
contact: should emit error and disconnect when tenant isActive is falseTenant inactivoERROR UNAUTHORIZED y desconexióntenantRepository
contact: should emit error and disconnect when contact is blockedContacto con isBlocked: trueERROR CONTACT_BLOCKED y desconexióncontactRepository
contact: should emit error when externalId or username are missingFalta externalId y usernameERROR CONTACT_FIELDS_REQUIRED y desconexióntenantRepository

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.

TestEscenarioResultado esperadoMocks
user: should emit error and disconnect when id is missingsendType: 'user' sin userIdERROR USER_ID_REQUIRED y desconexióntenantRepository
user: should emit error and disconnect when tenant has no externalIntegrationTenant con externalIntegration: nullERROR UNAUTHORIZED y desconexióntenantRepository
user: should emit USER_NOT_FOUND and disconnect when external API returns nullsearchUser devuelve nullERROR USER_NOT_FOUND, desconexión y no se consulta findByExternalIdAndTenantexternalRequestService.searchUser
user: should emit error and disconnect when tenant is inactiveTenant inactivoERROR UNAUTHORIZED y desconexióntenantRepository
user: should emit error and disconnect when the JWT is expiredverify lanza jwt expiredERROR TOKEN_EXPIRED y desconexiónjwtService

getUser — token expired in memory​

TestEscenarioResultado esperadoMocks
should emit error and disconnect when tokenExpiration is in the pastCliente con tokenExpiration vencido invoca handleJoinTicketERROR TOKEN_EXPIRED_RECONNECT y desconexióncreateMockClient

handleJoinTicket — guard​

TestEscenarioResultado esperadoMocks
should emit error when client is not authenticatedCliente sin autenticarERROR NOT_AUTHENTICATEDcreateUnauthClient

handleSendMessage​

TestEscenarioResultado esperadoMocks
should emit error when client is not authenticatedCliente sin autenticar envía mensajeERROR NOT_AUTHENTICATEDcreateUnauthClient
agent without ticketId: should emit error (no ticket to resolve)Agente sin ticketIdERROR MESSAGE_ROOM_ERROR y no se persistemessageRepository

handleSendMessage — firma del operador​

TestEscenarioResultado esperadoMocks
persiste y emite el body firmado cuando escribe un agenteAgente Richard Garcia envía respuesta del agentePersiste y emite NEW_MESSAGE/TICKET_UPDATED con el cuerpo *Richard Garcia*:\nrespuesta del agenteticketRepository, messageRepository.create, server.to
enruta el mensaje del agente hacia afuera del CRMAgente envía un mensajeoutboundRouter.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 contactoContacto envía un mensajeoutboundRouter.route no se invocaoutboundRouter
no firma cuando el switch del composer viene apagadoAgente con signature: falseEl cuerpo persistido es sin firmamessageRepository.create
no firma los mensajes de sistema (no los escribe una persona)sendType: 'system'El cuerpo persistido no lleva firmamessageRepository.create

handleSendNote​

TestEscenarioResultado esperadoMocks
should emit error when client is not authenticatedCliente sin autenticarERROR NOT_AUTHENTICATED y no se persistemessageRepository
rejects/ignores the call when the calling socket sendType is contact: does NOT persist, does NOT emit new_noteUn socket de contacto intenta enviar una notaNo se persiste ni se emite NEW_NOTEmessageRepository, server.to
agent: persists a Message with messageType note and sendType userAgente envía nota en un ticket del tenantcreate con messageType: 'note', sendType: 'user', cuerpo, ticketId y tenantIdticketRepository.findByIdAndTenant, messageRepository.create
agent: emits new_note ONLY to tenant:{tenantId}, never to ticket:{ticketId}Agente envía notaEmite NEW_NOTE a tenant:<id> y nunca a ticket:<id>server.to
agent: does NOT call updateLastMessage and does NOT emit ticket_updated/ticket_createdAgente envía notaNo actualiza último mensaje ni emite TICKET_UPDATED/TICKET_CREATEDticketRepository, server.to
cross-tenant ticketId: emits error, does NOT persistfindByIdAndTenant devuelve nullEmite ERROR y no persisteticketRepository

handleLeaveTicket​

TestEscenarioResultado esperadoMocks
should leave ticket room and notify othersCliente autenticado abandona un ticketclient.leave('ticket:<id>'), TICKET_LEFT al cliente y USER_LEFT a la salaserver.to
should emit error when client is not authenticatedCliente sin autenticarERROR NOT_AUTHENTICATEDcreateUnauthClient

handleSendMessage — lastContactMessageAt denormalization​

TestEscenarioResultado esperadoMocks
contact message: calls updateLastMessage with isFromContact=trueUn contacto envía un mensajeupdateLastMessage(ticketId, messageId, true)ticketRepository.findOrCreateForContact/updateLastMessage
agent message: calls updateLastMessage with isFromContact=false — does NOT denormalize lastContactMessageAtUn agente envía un mensajeupdateLastMessage(ticketId, messageId, false)ticketRepository

handleSendMessage — CSAT ingest​

TestEscenarioResultado esperadoMocks
regression: does nothing special when ticket.csat is not present (existing behavior untouched)Ticket sin csat, contacto escribe 5No se llama recordCsatRating ni recordCsatCommentticketRepository
pending + rating "5" + requestComment=false: records rating, sets status=answered, sends thank-youCSAT pending, el contacto responde 5recordCsatRating con rating: 5 y status: 'answered'ticketRepository.recordCsatRating, tenantSettingsService, systemMessageService
pending + rating "5" trimmed with surrounding whitespace still matchesContacto responde 4 recordCsatRating con rating: 4ticketRepository
pending + requestComment=true and no prior comment: sets status=awaiting_commentrequestComment: true; contacto responde 3recordCsatRating con rating: 3 y status: 'awaiting_comment'tenantSettingsService
pending + non-matching text: does NOT record a rating, message flows normallyContacto escribe hola, gracias!No se registra calificación y el mensaje se persisteticketRepository, messageRepository
pending + a number outside 1-5 range: does NOT record a ratingContacto escribe 9No se registra calificaciónticketRepository
awaiting_comment + no comment yet: next contact message is captured as the comment, status=answeredCSAT awaiting_comment sin comentariorecordCsatComment(ticketId, 'Muy buena atención')ticketRepository.recordCsatComment
awaiting_comment + comment already recorded: does NOT re-capture, message flows normallyCSAT awaiting_comment con comentario ya guardadoNo se llama recordCsatCommentticketRepository
answered/expired/not_applicable status: never intercepts, no CSAT repository callItera los estados answered, expired y not_applicable con el contacto escribiendo 5Nunca se invoca el repositorio CSATticketRepository
does not intercept agent messages even when ticket.csat.status=pending (only contact messages are CSAT answers)Agente escribe 5 con CSAT pendienteNo se registra calificaciónticketRepository
consumed CSAT rating message does NOT trigger auto-assign, even when ticket has no UserIdCalificación consumida en ticket sin UserIdNo se llama assignOnContactMessageticketService.assignOnContactMessage
still persists the incoming rating message normally as chat historyContacto responde 5Se persiste el mensaje con cuerpo 5 y sendType: 'contact'messageRepository.create

handleSendFile​

TestEscenarioResultado esperadoMocks
should emit error when client is not authenticatedCliente sin autenticarERROR NOT_AUTHENTICATEDcreateUnauthClient
should emit error when file is not found for the tenantfindByIdAndTenant devuelve nullERROR FILE_NOT_FOUNDfileRepository
agent without ticketId: file found but resolveTicket fails — emits error, no persistArchivo válido y agente sin ticketIdEmite ERROR y no persistefileRepository, messageRepository

resolveTicket (via handleSendMessage)​

TestEscenarioResultado esperadoMocks
contact path: calls findOrCreateForContact and returns ticket, persists messageContacto envía hellofindOrCreateForContact(tenantOid, contactOid, undefined, undefined, contenido); persiste y emite NEW_MESSAGEticketRepository.findOrCreateForContact, messageRepository
W2: threads the raw message content through to findOrCreateForContact so CSAT-pending routing can be judged by contentContacto envía 5El contenido crudo 5 llega como último argumento de findOrCreateForContactticketRepository
agent path with no dtoTicketId: emits ERROR, does NOT persistAgente sin ticketIdEmite ERROR y no persistemessageRepository
agent path with valid dtoTicketId: calls findByIdAndTenant and persistsAgente con ticketId válidofindByIdAndTenant con ObjectIds, persiste y emite NEW_MESSAGEticketRepository.findByIdAndTenant
agent path with cross-tenant ticketId: emits ERROR, does NOT persistfindByIdAndTenant devuelve nullEmite ERROR y no persisteticketRepository

handleSendMessage (PR-2 rewrite)​

TestEscenarioResultado esperadoMocks
persists with required fields (typed payload, no cast)Contacto envía mensajecreate 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 mensajeserver.to a ticket:<id> y tenant:<id>; emite NEW_MESSAGE y TICKET_UPDATEDserver.to
NEW_MESSAGE payload includes contactId from ticket.ContactId (not a persisted field)Contacto envía mensajeEl payload NEW_MESSAGE trae contactId del ticketserver.to
parentId referring to a message in the SAME ticket: persists parentId and includes parent snippetRespuesta a un mensaje del mismo ticketPersiste 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 snippetEl padre está en un ticket anterior del mismo contactoPersiste parentId e incluye parentmessageRepository.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 contactoNo se persiste parentId; el mensaje se envía normal con parentId y parent en nullmessageRepository.findById, ticketRepository.findByIdAndTenant
no parentId at all: behaves as before — no parent, no parentId, no findById lookupMensaje sin parentIdNo consulta findById, no persiste parentId y el payload lo emite en nullmessageRepository
absent parentId emits as null in NEW_MESSAGE payloadMensaje sin parentIdparentId es null en NEW_MESSAGEserver.to
calls client.join ticket room (idempotent)Contacto envía mensajeclient.join('ticket:<id>')client.join
TICKET_UPDATED payload is flat {ticketId, lastMessage, updatedAt}Contacto envía mensajeTICKET_UPDATED plano con ticketId, lastMessage y updatedAtserver.to

handleSendFile (PR-2 rewrite)​

TestEscenarioResultado esperadoMocks
emits error FILE_NOT_FOUND when file lookup returns nullfindByIdAndTenant devuelve nullERROR FILE_NOT_FOUND y no persistefileRepository
file found but resolveTicket fails (no ticketId for agent): emits ERROR, no persistAgente sin ticketIdEmite ERROR y no persistefileRepository
persists MEDIA message with correct fields and emits new-file-message + ticket_updatedContacto envía un archivoPersiste 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 textoAgente adjunta con caption te mando la capturaPersiste y emite *Richard Garcia*:\nte mando la capturamessageRepository, server.to
usa el caption como preview del ticket, no el nombre del archivoAgente adjunta con caption la factura del mesTICKET_UPDATED usa el caption como lastMessageserver.to
emite caption null y no lo persiste cuando el adjunto va sin textoCaption vacío (' ')El caption no se persiste y se emite nullmessageRepository, server.to
enruta el adjunto del agente hacia afuera del CRMAgente envía un archivooutboundRouter.route se invoca una vezoutboundRouter.route
no reenvía al canal el adjunto que subió el propio contactoContacto envía un archivooutboundRouter.route no se invocaoutboundRouter
new-file-message payload has contactId from ticket.ContactId, parentId, file sub-objectContacto envía archivo respondiendo a un mensajePayload con contactId, parentId, parent y subobjeto fileserver.to, messageRepository.findById
resolves the file url dynamically via storageService.getSignedUrl(storageKey) instead of reusing the stored (permanent) urlArchivo con URL permanente guardadaLlama getSignedUrl(storageKey) y file.url es la URL firmada, distinta de la almacenadastorageService.getSignedUrl
new-file-message parentId from a DIFFERENT ticket but the SAME contact (closed/reopened ticket history): includes parent and parentIdPadre en ticket previo del mismo contactoPersiste e incluye parent y parentIdmessageRepository.findById, ticketRepository
new-file-message parentId from a DIFFERENT contact: does NOT include parent nor parentId (security)Padre de otro contactoNo persiste parentId; el payload lo emite null junto con parentmessageRepository.findById, ticketRepository
absent parentId emits as null in new-file-messageArchivo sin parentIdparentId es nullserver.to
calls client.join ticket room after file sendContacto envía archivoclient.join('ticket:<id>')client.join

PR-3: authenticateAgentOrSystem joins tenant room on auth​

TestEscenarioResultado esperadoMocks
agent: client.join is called with tenant:{_id} (Mongo _id, not identifier)Agente se autenticaSe une a tenant:<_id> y no a tenant:<identifier>tenantRepository, userRepository
system: client.join is called with tenant:{_id}Conexión de sistema se autenticaSe une a tenant:<_id>tenantRepository

PR-3: authenticateContact does NOT join any tenant room​

TestEscenarioResultado esperadoMocks
contact: client.join is never called with tenant:* prefixContacto se autenticaNingún join con prefijo tenant:tenantRepository, externalRequestService, contactRepository

handleJoinTicket (ticket-based)​

TestEscenarioResultado esperadoMocks
valid ticketId for same tenant: joins ticket room and emits TICKET_JOINEDTicket del mismo tenantfindByIdAndTenant, join('ticket:<id>') y TICKET_JOINEDticketRepository
does NOT return message history (history is REST-only)Unión válidaNo se lee historial; TICKET_JOINED es solo { ticketId } sin messagesmessageRepository.findAllByTicket
cross-tenant ticketId: emits error and does NOT join any roomfindByIdAndTenant devuelve nullERROR y ningún join a ticket:ticketRepository
unauthenticated client: emits errorCliente sin autenticarERROR NOT_AUTHENTICATEDcreateUnauthClient

reactive disconnect helpers​

TestEscenarioResultado esperadoMocks
disconnectUser targets user:{id} room and disconnects its socketsdisconnectUser('principal-123')server.to('user:principal-123') y disconnectSockets(true)server.to
disconnectTenant targets tenant-sessions:{id} control roomdisconnectTenant('tenant-abc')server.to('tenant-sessions:tenant-abc') y disconnectSockets(true)server.to

session-control room joins on auth​

TestEscenarioResultado esperadoMocks
agent joins user:{userId} and tenant-sessions:{_id} control roomsAgente se autenticajoin('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 autenticaSe une a user:<contactId> y tenant-sessions:<_id>, nunca a tenant:<_id>externalRequestService, contactRepository

PR-4: handleMarkRead (tenant guard + dual-room emit)​

TestEscenarioResultado esperadoMocks
unauthenticated client: emits error, advanceTicketAck NOT calledCliente sin autenticarERROR NOT_AUTHENTICATED y no se llama advanceTicketAckmessageRepository
cross-tenant ticketId: findByIdAndTenant returns null → no markRead, no MESSAGE_ACK_CHANGED emittedTicket de otro tenantNo se avanza ack, no se emite MESSAGE_ACK_CHANGED, no se usa server.emit global y el emisor recibe ERRORticketRepository, messageRepository
same-tenant ticketId: advanceTicketAck called, MESSAGE_ACK_CHANGED emitted to ticket + tenant roomsAgente marca leídoadvanceTicketAck(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 DateAgente marca leídoPayload con ticketId, messageIds, ack: 'read' y at de tipo Dateserver.to
global server.emit is NEVER called (no cross-tenant leak)Agente marca leídoNo se llama server.emit globalserver.emit

handleMessageDelivered​

Bloque anidado dentro de PR-4: handleMarkRead (tenant guard + dual-room emit).

TestEscenarioResultado esperadoMocks
unauthenticated client: emits error, nothing advancedCliente sin autenticarERROR NOT_AUTHENTICATED y no se llama advanceMessagesAckmessageRepository
cross-tenant ticketId: nothing advanced, no MESSAGE_ACK_CHANGED emittedTicket de otro tenantNo avanza ni emite MESSAGE_ACK_CHANGEDticketRepository, messageRepository
contact socket: advances the backoffice-authored messages it acknowledgedSocket de contacto confirma entregaadvanceMessagesAck(ticketId, ids, 'delivered', 'contact') y emite MESSAGE_ACK_CHANGED (ack: 'delivered') a ambas salasmessageRepository.advanceMessagesAck, server.to
agent socket: the audience flips to the contact-authored sideSocket de agente confirma entregaadvanceMessagesAck(..., 'delivered', 'agent')messageRepository
nothing actually advanced: no event is emitted at alladvanceMessagesAck devuelve []No se emite ningún eventomessageRepository, server.to
handleMarkRead from a contact socket marks the backoffice side, not its own messagesContacto invoca handleMarkReadadvanceTicketAck(ticketId, 'read', 'contact')messageRepository.advanceTicketAck

handleSendMessage — auto mark-as-read on reply (both directions)​

TestEscenarioResultado esperadoMocks
agent replies (sendType USER) on a ticket with unread contact messages: advanceTicketAck called + MESSAGE_ACK_CHANGED emitted to ticket + tenant roomsUn usuario (sendType: 'user') respondeadvanceTicketAck(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 roomsUn contacto respondeadvanceTicketAck(ticketId, 'read', 'contact') y emite MESSAGE_ACK_CHANGEDmessageRepository, 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 mensajeNo se llama advanceTicketAck ni se emite MESSAGE_ACK_CHANGEDmessageRepository, server.to
advanceTicketAck rejects: handleSendMessage still resolves and persists the message without throwingadvanceTicketAck rechaza con db downEl mensaje se persiste y handleSendMessage no lanzamessageRepository.advanceTicketAck rechazado

PR-4: persistence — no contactId key on persisted Message doc​

TestEscenarioResultado esperadoMocks
send_message persisted doc does NOT contain a contactId fieldContacto envía mensajeEl documento enviado a create no tiene la clave contactIdmessageRepository.create
send_message two contact sends with no prior ticket → findOrCreateForContact called twice, both persistedDos envíos consecutivosfindOrCreateForContact y create se llaman dos vecesticketRepository, messageRepository
room targeting: new_message → server.to(ticket:id); ticket_updated → server.to(tenant:_id); NEVER bare server.emitContacto envía mensajenew_message a ticket:<id>, ticket_updated a tenant:<id> y nunca server.emitserver.to, server.emit

handleImTyping / handleImStopTyping — membership check​

Bloque contenedor; sus tests están en los dos bloques siguientes.

handleImTyping​

TestEscenarioResultado esperadoMocks
member: broadcasts im:user-typing to the conversation roomEl usuario es miembro de la conversaciónConsulta 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 insteadfindOne devuelve nullNo difunde; emite ERROR con INTERNAL_CHAT_RESPONSES.NOT_FOUNDconversationMemberRepository
unauthenticated: does NOT call the membership repositoryCliente sin autenticarNo consulta la membresía; emite ERROR NOT_AUTHENTICATEDconversationMemberRepository

handleImStopTyping​

TestEscenarioResultado esperadoMocks
member: broadcasts im:user-stop-typing to the conversation roomUsuario miembroEmite USER_STOP_TYPING a conversation:<id>conversationMemberRepository, client.to
non-member / other tenant: does NOT broadcast, emits NOT_FOUND insteadNo miembroNo difunde; emite ERROR NOT_FOUNDconversationMemberRepository

Contact presence — raw disconnect listener​

TestEscenarioResultado esperadoMocks
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 contactoSolo se registra un listener disconnectclient.on

Contact presence — disconnect classification + broadcast​

TestEscenarioResultado esperadoMocks
classifies a clean close (leave-intent) and broadcasts isOnline:false to tenant:{id} onlyEl contacto avisa handleContactLeaving y cierra con transport close; sin sockets restantessetPresence 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-lossCierre con ping timeout sin avisoBroadcast con disconnectReason: 'network-loss'contactRepository.setPresence, socketBroadcast
does NOT persist/broadcast offline when another tab of the same contact is still connectedQuedan otras pestañas en la sala del contactoNo se persiste ni se difunde el estado offlineserver.in().fetchSockets, contactRepository, socketBroadcast
still broadcasts offline even when the persistence write throws (live UX outranks the persisted mirror)setPresence rechaza con db write errorSe difunde igualmente isOnline: falsecontactRepository.setPresence rechazado

Contact presence — heartbeat sweep (D4)​

TestEscenarioResultado esperadoMocks
reads ONLY this.server.local.fetchSockets() (this instance) and never the room/adapter fetchSockets used for cross-instance operationsafterInit y avanza PRESENCE_HEARTBEAT_MSUsa server.local.fetchSockets y no el de sala/adaptadorserver.local.fetchSockets, server.in
touches lastSeen for every local contact socket found on the tickHay un socket de contacto y uno de agentetouchLastSeen(['c1'], Date) (solo contactos)contactRepository.touchLastSeen
skips the write entirely when this instance holds zero contact socketsSin sockets localesNo se llama touchLastSeencontactRepository
clears the interval on onModuleDestroy — no leaked timerafterInit y luego onModuleDestroySe llama clearInterval y un tick posterior no toca el repositoriospy de clearInterval, timers falsos

Contact presence — regression guard (agent/internal-chat presence unaffected)​

TestEscenarioResultado esperadoMocks
agent disconnect still fires im:presence-changed (broadcastPresenceChanged) exactly as beforeUn agente se desconecta sin sockets restantesbroadcastPresenceChanged(tenantId, userId, false)internalChatBroadcastService, server.in
does not bind a contact-presence disconnect listener when a socket authenticates as agent/systemAutenticación como agenteNo se registra listener disconnectclient.on
handleDisconnect body remains untouched: still clears authTimer for every client regardless of sendTypehandleConnection y handleDisconnectclearTimeout con el authTimerspy de clearTimeout

Contact presence — tenant isolation (AC5)​

TestEscenarioResultado esperadoMocks
broadcastContactPresenceChanged targets only the contact tenant room — never a second tenantUn contacto se conecta como primera pestañabroadcastContactPresenceChanged con el tenant del contacto y payload { contactId, isOnline: true, lastSeen, disconnectReason: null }; nunca con otro tenantsocketBroadcast, 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)​

TestEscenarioResultado esperadoMocks
a contact socket joined to ticket:{ticketId} receives nothing when an agent sends a note; an agent socket joined to tenant:{tenantId} receives new_noteUn contacto en ticket:<id>, un agente en tenant:<id> y otro agente envía una notaEl 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)​

TestEscenarioResultado esperadoMocks
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 timeoutPersiste isOnline: false y lastDisconnectReason: 'network-loss'; el agente del tenant recibe CONTACT_PRESENCE_CHANGED una sola vezContactRepository y SocketBroadcastService reales
AC5 tenant isolation: tenant-A contact presence change reaches only tenant-A agents, never tenant-BDesconexión limpia (client namespace disconnect) del contacto del tenant AEl agente del tenant A recibe el evento y el del tenant B noContactRepository 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​

TestEscenarioResultado esperadoMocks
classifies as clean whenever leaveIntent is true, regardless of reasonleaveIntent verdadero con ping timeout, transport error, transport close y un motivo desconocidoclean en todos los casosNinguno

leaveIntent = false​

TestEscenarioResultado esperadoMocks
classifies "%s" as clean (it.each)Motivos client namespace disconnect, server namespace disconnect, server shutting down, forced close, forced server closecleanNinguno
classifies "%s" as network-loss (it.each)Motivos ping timeout, transport error, parse errornetwork-lossNinguno
classifies "transport close" as network-loss (no leave-intent arrived)transport close sin intención de salidanetwork-lossNinguno
classifies an unknown reason as network-loss (explicit product bias toward offline)Motivo desconocidonetwork-loss (sesgo explícito hacia offline)Ninguno

selectContactIdsForHeartbeat​

TestEscenarioResultado esperadoMocks
returns contactMongoId for every socket with sendType contactDos sockets de contacto['c1', 'c2']Ninguno
filters out non-contact sockets (agent/user/system)Mezcla de agente, contacto, usuario y sistemaSolo ['c1']Ninguno
dedupes multi-tab sockets sharing the same contactMongoIdDos sockets con el mismo contacto['c1']Ninguno
tolerates sockets with missing data.userSockets sin data.userLos ignora y devuelve ['c1']Ninguno
tolerates a contact socket with no contactMongoId yetContacto sin contactMongoId[]Ninguno
returns an empty array for empty inputLista 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​

TestEscenarioResultado esperadoMocks
calls in(room).socketsLeave(room) for the given ticket roomevictTicketRoom('ticket-123')in('ticket:ticket-123') y socketsLeave('ticket:ticket-123')server.in, socketsLeave
does nothing when no server has been setServicio sin servidorNo lanza errorNinguno

broadcastTicketClosed​

TestEscenarioResultado esperadoMocks
emits ticket_closed to the ticket room with ticketId, contactId and reasonbroadcastTicketClosed('ticket-123', 'contact-456', 'deleted')Emite ticket_closed a ticket:ticket-123 con ticketId, contactId y reasonserver.to

broadcastTicketDeleted​

TestEscenarioResultado esperadoMocks
emits ticket_deleted to the tenant fan-out room with ticketId onlybroadcastTicketDeleted('tenant-789', 'ticket-123')Emite ticket_deleted a tenant:tenant-789 solo con ticketIdserver.to

broadcastContactPresenceChanged​

TestEscenarioResultado esperadoMocks
emits contact_presence_changed to tenant:{tenantMongoId} ONLY, with the full payloadCambio de presencia en línea con lastSeenUna sola llamada a to('tenant:tenant-abc') y evento con el payload completoserver.to
does not throw when no server has been setSin servidorNo lanza errorNinguno

broadcastTicketLockChanged​

TestEscenarioResultado esperadoMocks
emits ticket_lock_changed to tenant:{tenantMongoId} ONLY, with the full payloadTicket bloqueado por un agenteEmite ticket_lock_changed solo a tenant:tenant-abc con lockedBy y lockedByNameserver.to
emits with lockedBy/lockedByName null on unlockDesbloqueoEl payload lleva lockedBy y lockedByName en nullserver.to
does not throw when no server has been setSin servidorNo lanza errorNinguno

broadcastAgentMessage (scheduled-messages fire time, chat)​

TestEscenarioResultado esperadoMocks
emits new_message to ticket:{id}, authored by the snapshot senderMensaje programado de un agente al ejecutarseEmite new_message a ticket:ticket-123 con dbId, ticketId, contactId, remitente, cuerpo, timestamp y parentIdserver.to

broadcastAgentNote (scheduled-messages fire time, note)​

TestEscenarioResultado esperadoMocks
emits new_note to tenant:{id} ONLY — mirrors handleSendNote, no ticket room, no updateLastMessage side effectNota programadaEmite new_note solo a tenant:tenant-abc (una llamada a to) con el payload completoserver.to

broadcastTicketUpdated (scheduled-messages fire time, chat branch)​

TestEscenarioResultado esperadoMocks
emits ticket_updated to tenant:{id}, same payload shape as a live sendActualización tras un mensaje programadoEmite ticket_updated a tenant:tenant-abc con ticketId, lastMessage, sendType y updatedAtserver.to

broadcastScheduledMessageEvent (agent-only allow-list channel)​

TestEscenarioResultado esperadoMocks
emits to user:{authorUserId} ONLY — a contact can never be in this roomEvento scheduled_message_sentEmite solo a user:user-1 con el payload recibidoserver.to
does not throw when no server has been setEvento scheduled_message_cancelled sin servidorNo lanza errorNinguno

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​

TestEscenarioResultado esperadoMocks
returns the non-paused backoffice users of the tenant roomgetConnectedAgentIds('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 setServicio sin servidor[]Ninguno

isUserOnline​

TestEscenarioResultado esperadoMocks
is true for a connected backoffice user, even when pausedAgente en línea y agente pausadotrue en ambosserver.in().fetchSockets
is false for a user without a backoffice socket in the tenant roomAgente desconectado y un contactofalse en ambosserver.in().fetchSockets
is false when no server has been setSin servidorfalseNinguno