Skip to main content

Casos de prueba: módulo Message (servicios y lógica de dominio)

Documenta los tests unitarios de la capa de servicios, helpers, pipeline de ingreso, enrutado saliente y bloqueo de ingreso del módulo message.

Specs fuente (src/app/modules/message/):

  • services/message.service.spec.ts
  • services/inbound-message.pipeline.spec.ts
  • services/message-template.service.spec.ts
  • services/outbound-dispatcher.registry.spec.ts
  • services/outbound-router.service.spec.ts
  • helpers/parent-message.helper.spec.ts
  • inbound-lock/inbound-lock.service.spec.ts

Los controladores y repositorios están en Message (API y repositorios).

Resumen: 7 archivos de spec, 8 suites raíz (el spec del lock define dos), 105 tests it().


message.service.spec.ts​

Suite raíz: MessageService. Mocks comunes: repositorio de mensajes, TicketService, repositorio de favoritos y traducción (TranslateService).

create​

TestEscenarioResultado esperadoMocks
should create and return a messageSe llama a create con un DTO válidoDelega en repository.create(dto) y devuelve el mensaje creadomockRepository.create

findAll​

TestEscenarioResultado esperadoMocks
should return an array of messagesEl repositorio tiene un mensajeDevuelve el arreglo con el mensajemockRepository.findAll
should return an empty array when no messages existEl repositorio devuelve []Devuelve arreglo vacíomockRepository.findAll

findAllByTicket​

TestEscenarioResultado esperadoMocks
should call repository with ticketId, page and limitSe consulta por ticket con página y límiteLlama a findAllByTicket con ticketId, page y limitmockRepository.findAllByTicket
should return results and metaEl repositorio devuelve resultados y totalRetorna results y meta (page, limit, total, totalPages)mockRepository.findAllByTicket
should calculate totalPages correctly across multiple pagesTotal mayor que el límitemeta.totalPages es 3mockRepository.findAllByTicket
should return totalPages 0 when total is 0Total igual a 0meta.totalPages es 0mockRepository.findAllByTicket

findOne​

TestEscenarioResultado esperadoMocks
should return a message when foundfindById devuelve un mensajeDevuelve el mensaje y se consulta por el idmockRepository.findById
should throw NotFoundException when message does not existfindById devuelve nullLanza NotFoundException (mensaje traducido)mockRepository.findById, traducción

update​

TestEscenarioResultado esperadoMocks
should update and return the messageupdate devuelve el mensaje actualizadoLlama a update(id, dto) y devuelve el resultadomockRepository.update
should throw NotFoundException when message does not existupdate devuelve nullLanza NotFoundExceptionmockRepository.update

remove​

TestEscenarioResultado esperadoMocks
should remove and return the message when tenant matchesEl mensaje existe y pertenece al tenantConsulta, elimina y devuelve el mensajefindById, remove
should throw NotFoundException when message does not existfindById devuelve nullLanza NotFoundExceptionfindById
should throw ForbiddenException when tenant does not matchEl mensaje pertenece a otro tenantLanza ForbiddenException y no llama a removefindById
should cascade-delete every starred row for the removed message (contact scope)Eliminación válida de un mensaje de contactoBorra todos los favoritos del mensaje (removeAllByMessage) con el id como ObjectIdfindById, remove, repositorio de favoritos
should NOT cascade-delete when the message does not existEl mensaje no existeNo se invoca el borrado en cascada de favoritosfindById, repositorio de favoritos
should NOT cascade-delete when the tenant does not match (forbidden)Tenant distintoNo se invoca el borrado en cascadafindById, repositorio de favoritos

getMessageWindow​

TestEscenarioResultado esperadoMocks
throws NotFoundException when the anchor message does not existEl mensaje ancla no existeLanza NotFoundException; no se llama a findWindowfindById
throws NotFoundException when the anchor message belongs to a different tenantEl ancla es de otro tenantLanza NotFoundException (no revela existencia); no se llama a findWindowfindById
delegates to the repository with the anchor ticketId, createdAt and radiusAncla válida con radioLlama a findWindow con ticketId, createdAt y radio; devuelve { results, anchorId }findById, findWindow

markAsRead​

TestEscenarioResultado esperadoMocks
should return the message the repository advancedEl repositorio avanza el ack a leídoDevuelve el mensaje actualizadomarkAsRead
should return the existing message when it was already read, without throwingmarkAsRead devuelve null pero el mensaje existeDevuelve el mensaje existente sin lanzarmarkAsRead, findById
should throw NotFoundException when message does not existmarkAsRead y findById devuelven nullLanza NotFoundExceptionmarkAsRead, findById

markTicketMessagesAsRead​

TestEscenarioResultado esperadoMocks
should advance the contact-authored messages of the ticket to readSe marcan como leídos los mensajes de un ticketLlama a advanceTicketAck y devuelve los ids avanzadosadvanceTicketAck

findAllByContact​

TestEscenarioResultado esperadoMocks
should delegate to repository with contactId, tenantId and filterConsulta por contacto con filtroDelegación al repositorio con los tres argumentosfindAllByContact
should return results and metaEl repositorio devuelve resultados y totalRetorna results y metafindAllByContact
should calculate totalPages correctly across multiple pagesTotal mayor que el límitemeta.totalPages es 3findAllByContact
should return totalPages 0 when total is 0Total 0meta.totalPages es 0findAllByContact
should fall back to DEFAULT_PAGE and DEFAULT_LIMIT when filter omits themEl filtro no trae page ni limitmeta.page es 1 y meta.limit es 10findAllByContact

getTicketsByContact​

TestEscenarioResultado esperadoMocks
should delegate to TicketService with contactId injected into filterSe piden tickets de un contactogetTicketsList recibe el filtro con contactId inyectadoTicketService.getTicketsList
should forward page, limit, createdFrom, createdTo and status to TicketServiceFiltro con paginación, rango de fechas y estadoTodos los campos se reenvían a TicketServiceTicketService.getTicketsList
should return the result from TicketServiceTicketService devuelve un resultadoSe devuelve tal cualTicketService.getTicketsList

findAllByContactTicket​

TestEscenarioResultado esperadoMocks
should throw NotFoundException when ticket does not belong to the contactfindByContactAndTicket devuelve nullLanza NotFoundExceptionTicketService.findByContactAndTicket
should not query repository when ticket ownership check failsLa verificación de propiedad fallaNo se consulta findAllByContactTicketTicketService, repositorio
should verify ticket ownership with correct argsTicket del contactoSe verifica propiedad con contactId, ticketId y tenantId correctosTicketService.findByContactAndTicket
should delegate to repository with ticketId, tenantId and filterPropiedad verificadaDelegación con ticketId, tenantId y filtrofindAllByContactTicket
should return the repository media (already signed) untouched, without re-fetching or overwriting itEl repositorio ya devuelve media con URL firmadaEl mensaje se devuelve idéntico (misma referencia) y la URL firmada no se alteraTicketService, repositorio
should return results and metaResultados y totalRetorna results y metafindAllByContactTicket
should calculate totalPages correctlyTotal mayor que el límitemeta.totalPages es 3findAllByContactTicket

inbound-message.pipeline.spec.ts​

Suite raíz: ingestInbound. Valida la función de ingreso con un puerto (port) que registra el orden de eventos de socket.

TestEscenarioResultado esperadoMocks
persists the message and emits new_message -> ticket_created -> ticket_updated in order on a new ticketTicket recién creadoPersiste el mensaje, emite los eventos en el orden indicado y envía el saludo de conexiónrepositorio de mensajes, TicketService, puerto
does not emit ticket_created on an existing (not created) ticketTicket ya existenteNo emite ticket_created ni envía saludo de conexiónrepositorio de mensajes, TicketService, puerto
persists shared contacts as a contact message and emits them to the backofficeMensaje con contactos compartidosSe persiste como mensaje de contacto con los sharedContacts y se emiten new_message y ticket_updatedrepositorio de mensajes, puerto
tells the ticket list which message is now the last one and its ackMensaje entrante guardadoticket_updated informa el último mensaje y su ackupdateLastMessage, puerto
keeps a plain chat message without shared contacts or a typed bubbleMensaje de chat simpleSe guarda sin contactos compartidos ni burbuja tipadarepositorio de mensajes, puerto
marks the ticket read for agent/user sends but not for contact sendsEnvío de agente/usuario y luego envío de contactoMarca el ticket como leído solo para agente/usuario; no para contactopuerto (markTicketRead)
CSAT short-circuit: a pending-survey rating reply is consumed and skips the assignment engineEl contacto responde una encuesta CSAT pendienteSe registra la calificación, se envía el mensaje de sistema y no se ejecuta el motor de asignaciónrecordCsatRating, SystemMessageService, assignOnContactMessage
runs the assignment engine for an unassigned contact ticket that is not a CSAT replyTicket sin asignar y mensaje que no es CSATSe ejecuta assignOnContactMessageTicketService.assignOnContactMessage
runs the assignment engine for an already-assigned contact ticket so a disconnected agent can be redistributedTicket ya asignado, mensaje de contactoTambién se ejecuta el motor de asignación (redistribución si el agente está desconectado)TicketService.assignOnContactMessage
does not run the assignment engine for agent sendsMensaje enviado por agenteNo se ejecuta el motor de asignaciónTicketService.assignOnContactMessage
calls the outbound dispatcher for agent sends but not for contact sendsEnvío de agente y envío de contactooutboundRouter.route se llama solo para el agenteOutboundRouterService.route
stays inert when no outbound router is wired (plain web-chat usage)No hay router saliente configuradoEl ingreso se resuelve sin errorrepositorio de mensajes

message-template.service.spec.ts​

Suite raíz: MessageTemplateService. Pruebas puras de interpolate sin dependencias externas (los saludos usan temporizadores falsos de Jest).

token resolution​

TestEscenarioResultado esperadoMocks
resolves {{username}} from contact.usernamePlantilla Hola con el token de usernameResuelve a Hola wolf77Ninguno
resolves {{fullName}} from firstname + lastNameToken de nombre completoConcatena nombre y apellidoNinguno
resolves {{phone}} as stringToken de teléfono numéricoDevuelve el teléfono como textoNinguno
resolves {{identifier}} from ticket.identifierToken de identificadorToma el valor de ticket.identifierNinguno
resolves {{email}} from contact.emailToken de correoToma el valor de contact.emailNinguno
resolves multiple tokens in one templateVarios tokens en una plantillaResultado wolf77 - Juan Pérez - juan@example.comNinguno
tolerates whitespace inside bracesToken con espacios internosSe resuelve igual que sin espaciosNinguno

Saludo {{greetings}} según la hora del servidor​

TestEscenarioResultado esperadoMocks
returns "Buenos días" before 12hHora del servidor antes de las 12Devuelve "Buenos días"jest.useFakeTimers
returns "Buenas tardes" between 12h and 18hHora entre las 12 y las 18Devuelve "Buenas tardes"jest.useFakeTimers
returns "Buenas noches" from 19hHora desde las 19Devuelve "Buenas noches"jest.useFakeTimers

fallbacks​

TestEscenarioResultado esperadoMocks
{{fullName}} falls back to username when names are emptyNombre y apellido vacíosUsa el username (wolf77)Ninguno
{{fullName}} falls back to username when names are undefinedNombre y apellido indefinidosUsa el usernameNinguno
{{fullName}} uses the available part when only one name existsSolo existe un nombreDevuelve esa parte (Juan)Ninguno
{{phone}} resolves to empty string when missingContacto sin teléfonoResuelve a cadena vacía (Tel:)Ninguno
{{email}} resolves to empty string when missingContacto sin correoResuelve a cadena vacía (Mail:)Ninguno
{{username}} resolves to empty string when missingContacto sin usernameResuelve a cadena vacía (User:)Ninguno

unknown tokens​

TestEscenarioResultado esperadoMocks
replaces unknown tokens with empty string without throwingPlantilla con un token inexistenteLo reemplaza por vacío sin lanzar errorNinguno
keeps resolving known tokens next to unknown onesToken desconocido junto a uno conocidoEl conocido se resuelve (wolf77)Ninguno

templates without tokens​

TestEscenarioResultado esperadoMocks
returns plain text untouchedTexto sin tokensSe devuelve igualNinguno
handles empty templatePlantilla vacíaDevuelve cadena vacíaNinguno

HTML stripping​

TestEscenarioResultado esperadoMocks
removes tags and keeps textPlantilla con etiquetas HTMLElimina etiquetas y conserva el texto (Hola wolf77)Ninguno
converts <br> and block closings to line breaksPlantilla con <br> y cierres de bloqueLos convierte en saltos de línea (Línea 1\nLínea 2\nLínea 3)Ninguno
decodes basic HTML entitiesEntidades HTML básicasSe decodifican (Soporte & Ventas)Ninguno

outbound-dispatcher.registry.spec.ts​

Suite raíz: OutboundDispatcherRegistry. Resolución de dispatchers por canal con supports() y fallback.

TestEscenarioResultado esperadoMocks
resolves the only implementation registered for a channelUn solo dispatcher para el canalresolve devuelve ese dispatcherDispatcher falso
returns undefined for a channel nobody registered (web delivers over the socket itself)Canal sin dispatcherresolve devuelve undefinedNinguno
keeps several implementations of the SAME channel and picks by supports()Varias implementaciones del mismo canalElige la que responde true en supports()Dispatchers falsos
prefers a matching supports() over the fallback, whichever registered firstFallback y dispatcher específico en cualquier orden de registroGana el que coincide por supports(), no el fallbackDispatchers falsos
returns undefined when every sibling declines and there is no fallbackTodos declinan y no hay fallbackDevuelve undefinedDispatchers falsos
refuses a second fallback for one channel instead of silently shadowing oneSe registra un segundo fallback del mismo canalLanza error en lugar de ocultar el primeroDispatchers falsos
keeps channels independentDispatchers de canales distintosCada canal resuelve el suyo sin interferenciasDispatchers falsos

outbound-router.service.spec.ts​

Suite raíz: OutboundRouterService. Enruta mensajes y acuses de lectura al canal dueño de la conexión.

TestEscenarioResultado esperadoMocks
routes to the dispatcher registered for the connection channelLa conexión es de un canal con dispatcherLlama a dispatch(ticket, message)connectionRepository.findById, dispatcher
resolves the connection once and leaves other channels untouchedHay dispatchers de WhatsApp e InstagramfindById se llama una vez, se despacha solo al canal dueñoconnectionRepository.findById, dispatchers
no-ops for a web connection: the socket emit is its deliveryConexión de canal webNo se despacha nadaconnectionRepository.findById
no-ops without a connectionId, without hitting the databaseTicket sin connectionIdNo hace nada ni consulta la baseconnectionRepository.findById
swallows a dispatcher failure instead of propagating itEl dispatcher lanza socket closedroute resuelve a undefined sin propagar el errordispatcher con mockRejectedValue

routeRead​

TestEscenarioResultado esperadoMocks
hands the read receipt to the channel that owns the ticketCanal con markReadLlama a markRead(ticket, ['a','b'])connectionRepository.findById, dispatcher
does nothing for a channel that cannot acknowledge readsDispatcher sin markReadResuelve a undefined sin errordispatcher
never touches the connection when there is nothing to acknowledgeLista de ids vacíaNo consulta la conexiónconnectionRepository.findById
swallows a channel failuremarkRead rechaza con socket closedResuelve a undefined sin propagardispatcher con mockRejectedValue

parent-message.helper.spec.ts​

Suite raíz: resolveParentMessage. Resuelve la vista previa del mensaje padre (respuesta citada).

TestEscenarioResultado esperadoMocks
returns no parent without querying when no parentId is givenNo se envía parentIdDevuelve sin padre y no consulta el repositoriomessageRepository.findById
returns the parent preview when it belongs to the same contact within the tenantEl padre existe, mismo tenant y mismo contactoDevuelve el parentId y la vista previa del padrefindById, ticketRepository.findByIdAndTenant
drops the parent when the message does not existfindById devuelve nullDescarta el padre (sin padre)findById
drops the parent when its ticket is outside the tenantEl ticket del padre no es del tenantDescarta el padrefindByIdAndTenant devuelve null
drops the parent when it belongs to another contactEl ticket del padre es de otro contactoDescarta el padrefindByIdAndTenant

inbound-lock.service.spec.ts​

Dos suites raíz: InboundLockService (unitaria con Redis simulado en memoria) y First-message race on ticket creation (integration) (con base de datos real en memoria).

InboundLockService​

TestEscenarioResultado esperadoMocks
runs the same contact one at a timeDos ejecuciones concurrentes para el mismo contactoLa segunda empieza solo cuando termina la primera (first:start, first:end, second:start)Redis falso (set, eval)
does not block different contacts or tenantsContactos o tenants distintosAmbos se ejecutan sin esperarseRedis falso
returns the callback value and releases the keyCallback exitosoDevuelve su valor y deja el almacén sin clavesRedis falso
releases the lock even when the callback throwsEl callback lanza errorSe propaga el error y la clave se liberaRedis falso
never releases a lock that another holder took after expiryEl lock expiró y otro titular lo tomóNo libera el lock ajenoRedis falso (eval)
processes without lock once the TTL passes while still heldSe vence el TTL esperando el lockProcesa sin lock cuando pasa el TTLjest.useFakeTimers, Redis falso
processes without lock right away when Redis is downRedis no disponibleProcesa de inmediato sin lock (sin demora apreciable) y sin llamar a evalRedis falso con fallo
closes its Redis connection on shutdownSe apaga el móduloLlama a quit del cliente RedisRedis falso (quit)

First-message race on ticket creation (integration)​

TestEscenarioResultado esperadoMocks
reproduces the bug: two simultaneous first messages create two ticketsDos primeros mensajes simultáneos sin lockSe crean 2 tickets para el mismo contactoMongo en memoria
with the inbound lock, the same two messages share one ticketLos mismos dos mensajes con el lockSe crea 1 solo ticket; ambos resultados comparten id y exactamente uno tiene created = trueMongo en memoria, lock con Redis falso