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.tsservices/inbound-message.pipeline.spec.tsservices/message-template.service.spec.tsservices/outbound-dispatcher.registry.spec.tsservices/outbound-router.service.spec.tshelpers/parent-message.helper.spec.tsinbound-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
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should create and return a message | Se llama a create con un DTO válido | Delega en repository.create(dto) y devuelve el mensaje creado | mockRepository.create |
findAll
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should return an array of messages | El repositorio tiene un mensaje | Devuelve el arreglo con el mensaje | mockRepository.findAll |
should return an empty array when no messages exist | El repositorio devuelve [] | Devuelve arreglo vacío | mockRepository.findAll |
findAllByTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should call repository with ticketId, page and limit | Se consulta por ticket con página y límite | Llama a findAllByTicket con ticketId, page y limit | mockRepository.findAllByTicket |
should return results and meta | El repositorio devuelve resultados y total | Retorna results y meta (page, limit, total, totalPages) | mockRepository.findAllByTicket |
should calculate totalPages correctly across multiple pages | Total mayor que el límite | meta.totalPages es 3 | mockRepository.findAllByTicket |
should return totalPages 0 when total is 0 | Total igual a 0 | meta.totalPages es 0 | mockRepository.findAllByTicket |
findOne
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should return a message when found | findById devuelve un mensaje | Devuelve el mensaje y se consulta por el id | mockRepository.findById |
should throw NotFoundException when message does not exist | findById devuelve null | Lanza NotFoundException (mensaje traducido) | mockRepository.findById, traducción |
update
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should update and return the message | update devuelve el mensaje actualizado | Llama a update(id, dto) y devuelve el resultado | mockRepository.update |
should throw NotFoundException when message does not exist | update devuelve null | Lanza NotFoundException | mockRepository.update |
remove
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should remove and return the message when tenant matches | El mensaje existe y pertenece al tenant | Consulta, elimina y devuelve el mensaje | findById, remove |
should throw NotFoundException when message does not exist | findById devuelve null | Lanza NotFoundException | findById |
should throw ForbiddenException when tenant does not match | El mensaje pertenece a otro tenant | Lanza ForbiddenException y no llama a remove | findById |
should cascade-delete every starred row for the removed message (contact scope) | Eliminación válida de un mensaje de contacto | Borra todos los favoritos del mensaje (removeAllByMessage) con el id como ObjectId | findById, remove, repositorio de favoritos |
should NOT cascade-delete when the message does not exist | El mensaje no existe | No se invoca el borrado en cascada de favoritos | findById, repositorio de favoritos |
should NOT cascade-delete when the tenant does not match (forbidden) | Tenant distinto | No se invoca el borrado en cascada | findById, repositorio de favoritos |
getMessageWindow
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException when the anchor message does not exist | El mensaje ancla no existe | Lanza NotFoundException; no se llama a findWindow | findById |
throws NotFoundException when the anchor message belongs to a different tenant | El ancla es de otro tenant | Lanza NotFoundException (no revela existencia); no se llama a findWindow | findById |
delegates to the repository with the anchor ticketId, createdAt and radius | Ancla válida con radio | Llama a findWindow con ticketId, createdAt y radio; devuelve { results, anchorId } | findById, findWindow |
markAsRead
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should return the message the repository advanced | El repositorio avanza el ack a leído | Devuelve el mensaje actualizado | markAsRead |
should return the existing message when it was already read, without throwing | markAsRead devuelve null pero el mensaje existe | Devuelve el mensaje existente sin lanzar | markAsRead, findById |
should throw NotFoundException when message does not exist | markAsRead y findById devuelven null | Lanza NotFoundException | markAsRead, findById |
markTicketMessagesAsRead
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should advance the contact-authored messages of the ticket to read | Se marcan como leídos los mensajes de un ticket | Llama a advanceTicketAck y devuelve los ids avanzados | advanceTicketAck |
findAllByContact
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should delegate to repository with contactId, tenantId and filter | Consulta por contacto con filtro | Delegación al repositorio con los tres argumentos | findAllByContact |
should return results and meta | El repositorio devuelve resultados y total | Retorna results y meta | findAllByContact |
should calculate totalPages correctly across multiple pages | Total mayor que el límite | meta.totalPages es 3 | findAllByContact |
should return totalPages 0 when total is 0 | Total 0 | meta.totalPages es 0 | findAllByContact |
should fall back to DEFAULT_PAGE and DEFAULT_LIMIT when filter omits them | El filtro no trae page ni limit | meta.page es 1 y meta.limit es 10 | findAllByContact |
getTicketsByContact
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should delegate to TicketService with contactId injected into filter | Se piden tickets de un contacto | getTicketsList recibe el filtro con contactId inyectado | TicketService.getTicketsList |
should forward page, limit, createdFrom, createdTo and status to TicketService | Filtro con paginación, rango de fechas y estado | Todos los campos se reenvían a TicketService | TicketService.getTicketsList |
should return the result from TicketService | TicketService devuelve un resultado | Se devuelve tal cual | TicketService.getTicketsList |
findAllByContactTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should throw NotFoundException when ticket does not belong to the contact | findByContactAndTicket devuelve null | Lanza NotFoundException | TicketService.findByContactAndTicket |
should not query repository when ticket ownership check fails | La verificación de propiedad falla | No se consulta findAllByContactTicket | TicketService, repositorio |
should verify ticket ownership with correct args | Ticket del contacto | Se verifica propiedad con contactId, ticketId y tenantId correctos | TicketService.findByContactAndTicket |
should delegate to repository with ticketId, tenantId and filter | Propiedad verificada | Delegación con ticketId, tenantId y filtro | findAllByContactTicket |
should return the repository media (already signed) untouched, without re-fetching or overwriting it | El repositorio ya devuelve media con URL firmada | El mensaje se devuelve idéntico (misma referencia) y la URL firmada no se altera | TicketService, repositorio |
should return results and meta | Resultados y total | Retorna results y meta | findAllByContactTicket |
should calculate totalPages correctly | Total mayor que el límite | meta.totalPages es 3 | findAllByContactTicket |
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.
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
persists the message and emits new_message -> ticket_created -> ticket_updated in order on a new ticket | Ticket recién creado | Persiste el mensaje, emite los eventos en el orden indicado y envía el saludo de conexión | repositorio de mensajes, TicketService, puerto |
does not emit ticket_created on an existing (not created) ticket | Ticket ya existente | No emite ticket_created ni envía saludo de conexión | repositorio de mensajes, TicketService, puerto |
persists shared contacts as a contact message and emits them to the backoffice | Mensaje con contactos compartidos | Se persiste como mensaje de contacto con los sharedContacts y se emiten new_message y ticket_updated | repositorio de mensajes, puerto |
tells the ticket list which message is now the last one and its ack | Mensaje entrante guardado | ticket_updated informa el último mensaje y su ack | updateLastMessage, puerto |
keeps a plain chat message without shared contacts or a typed bubble | Mensaje de chat simple | Se guarda sin contactos compartidos ni burbuja tipada | repositorio de mensajes, puerto |
marks the ticket read for agent/user sends but not for contact sends | Envío de agente/usuario y luego envío de contacto | Marca el ticket como leído solo para agente/usuario; no para contacto | puerto (markTicketRead) |
CSAT short-circuit: a pending-survey rating reply is consumed and skips the assignment engine | El contacto responde una encuesta CSAT pendiente | Se registra la calificación, se envía el mensaje de sistema y no se ejecuta el motor de asignación | recordCsatRating, SystemMessageService, assignOnContactMessage |
runs the assignment engine for an unassigned contact ticket that is not a CSAT reply | Ticket sin asignar y mensaje que no es CSAT | Se ejecuta assignOnContactMessage | TicketService.assignOnContactMessage |
runs the assignment engine for an already-assigned contact ticket so a disconnected agent can be redistributed | Ticket ya asignado, mensaje de contacto | Tambié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 sends | Mensaje enviado por agente | No se ejecuta el motor de asignación | TicketService.assignOnContactMessage |
calls the outbound dispatcher for agent sends but not for contact sends | Envío de agente y envío de contacto | outboundRouter.route se llama solo para el agente | OutboundRouterService.route |
stays inert when no outbound router is wired (plain web-chat usage) | No hay router saliente configurado | El ingreso se resuelve sin error | repositorio 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
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
resolves {{username}} from contact.username | Plantilla Hola con el token de username | Resuelve a Hola wolf77 | Ninguno |
resolves {{fullName}} from firstname + lastName | Token de nombre completo | Concatena nombre y apellido | Ninguno |
resolves {{phone}} as string | Token de teléfono numérico | Devuelve el teléfono como texto | Ninguno |
resolves {{identifier}} from ticket.identifier | Token de identificador | Toma el valor de ticket.identifier | Ninguno |
resolves {{email}} from contact.email | Token de correo | Toma el valor de contact.email | Ninguno |
resolves multiple tokens in one template | Varios tokens en una plantilla | Resultado wolf77 - Juan Pérez - juan@example.com | Ninguno |
tolerates whitespace inside braces | Token con espacios internos | Se resuelve igual que sin espacios | Ninguno |
Saludo {{greetings}} según la hora del servidor
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns "Buenos días" before 12h | Hora del servidor antes de las 12 | Devuelve "Buenos días" | jest.useFakeTimers |
returns "Buenas tardes" between 12h and 18h | Hora entre las 12 y las 18 | Devuelve "Buenas tardes" | jest.useFakeTimers |
returns "Buenas noches" from 19h | Hora desde las 19 | Devuelve "Buenas noches" | jest.useFakeTimers |
fallbacks
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
{{fullName}} falls back to username when names are empty | Nombre y apellido vacíos | Usa el username (wolf77) | Ninguno |
{{fullName}} falls back to username when names are undefined | Nombre y apellido indefinidos | Usa el username | Ninguno |
{{fullName}} uses the available part when only one name exists | Solo existe un nombre | Devuelve esa parte (Juan) | Ninguno |
{{phone}} resolves to empty string when missing | Contacto sin teléfono | Resuelve a cadena vacía (Tel:) | Ninguno |
{{email}} resolves to empty string when missing | Contacto sin correo | Resuelve a cadena vacía (Mail:) | Ninguno |
{{username}} resolves to empty string when missing | Contacto sin username | Resuelve a cadena vacía (User:) | Ninguno |
unknown tokens
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
replaces unknown tokens with empty string without throwing | Plantilla con un token inexistente | Lo reemplaza por vacío sin lanzar error | Ninguno |
keeps resolving known tokens next to unknown ones | Token desconocido junto a uno conocido | El conocido se resuelve (wolf77) | Ninguno |
templates without tokens
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns plain text untouched | Texto sin tokens | Se devuelve igual | Ninguno |
handles empty template | Plantilla vacía | Devuelve cadena vacía | Ninguno |
HTML stripping
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
removes tags and keeps text | Plantilla con etiquetas HTML | Elimina etiquetas y conserva el texto (Hola wolf77) | Ninguno |
converts <br> and block closings to line breaks | Plantilla con <br> y cierres de bloque | Los convierte en saltos de línea (Línea 1\nLínea 2\nLínea 3) | Ninguno |
decodes basic HTML entities | Entidades HTML básicas | Se decodifican (Soporte & Ventas) | Ninguno |
outbound-dispatcher.registry.spec.ts
Suite raíz: OutboundDispatcherRegistry. Resolución de dispatchers por canal con supports() y fallback.
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
resolves the only implementation registered for a channel | Un solo dispatcher para el canal | resolve devuelve ese dispatcher | Dispatcher falso |
returns undefined for a channel nobody registered (web delivers over the socket itself) | Canal sin dispatcher | resolve devuelve undefined | Ninguno |
keeps several implementations of the SAME channel and picks by supports() | Varias implementaciones del mismo canal | Elige la que responde true en supports() | Dispatchers falsos |
prefers a matching supports() over the fallback, whichever registered first | Fallback y dispatcher específico en cualquier orden de registro | Gana el que coincide por supports(), no el fallback | Dispatchers falsos |
returns undefined when every sibling declines and there is no fallback | Todos declinan y no hay fallback | Devuelve undefined | Dispatchers falsos |
refuses a second fallback for one channel instead of silently shadowing one | Se registra un segundo fallback del mismo canal | Lanza error en lugar de ocultar el primero | Dispatchers falsos |
keeps channels independent | Dispatchers de canales distintos | Cada canal resuelve el suyo sin interferencias | Dispatchers falsos |
outbound-router.service.spec.ts
Suite raíz: OutboundRouterService. Enruta mensajes y acuses de lectura al canal dueño de la conexión.
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
routes to the dispatcher registered for the connection channel | La conexión es de un canal con dispatcher | Llama a dispatch(ticket, message) | connectionRepository.findById, dispatcher |
resolves the connection once and leaves other channels untouched | Hay dispatchers de WhatsApp e Instagram | findById se llama una vez, se despacha solo al canal dueño | connectionRepository.findById, dispatchers |
no-ops for a web connection: the socket emit is its delivery | Conexión de canal web | No se despacha nada | connectionRepository.findById |
no-ops without a connectionId, without hitting the database | Ticket sin connectionId | No hace nada ni consulta la base | connectionRepository.findById |
swallows a dispatcher failure instead of propagating it | El dispatcher lanza socket closed | route resuelve a undefined sin propagar el error | dispatcher con mockRejectedValue |
routeRead
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
hands the read receipt to the channel that owns the ticket | Canal con markRead | Llama a markRead(ticket, ['a','b']) | connectionRepository.findById, dispatcher |
does nothing for a channel that cannot acknowledge reads | Dispatcher sin markRead | Resuelve a undefined sin error | dispatcher |
never touches the connection when there is nothing to acknowledge | Lista de ids vacía | No consulta la conexión | connectionRepository.findById |
swallows a channel failure | markRead rechaza con socket closed | Resuelve a undefined sin propagar | dispatcher con mockRejectedValue |
parent-message.helper.spec.ts
Suite raíz: resolveParentMessage. Resuelve la vista previa del mensaje padre (respuesta citada).
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns no parent without querying when no parentId is given | No se envía parentId | Devuelve sin padre y no consulta el repositorio | messageRepository.findById |
returns the parent preview when it belongs to the same contact within the tenant | El padre existe, mismo tenant y mismo contacto | Devuelve el parentId y la vista previa del padre | findById, ticketRepository.findByIdAndTenant |
drops the parent when the message does not exist | findById devuelve null | Descarta el padre (sin padre) | findById |
drops the parent when its ticket is outside the tenant | El ticket del padre no es del tenant | Descarta el padre | findByIdAndTenant devuelve null |
drops the parent when it belongs to another contact | El ticket del padre es de otro contacto | Descarta el padre | findByIdAndTenant |
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
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
runs the same contact one at a time | Dos ejecuciones concurrentes para el mismo contacto | La segunda empieza solo cuando termina la primera (first:start, first:end, second:start) | Redis falso (set, eval) |
does not block different contacts or tenants | Contactos o tenants distintos | Ambos se ejecutan sin esperarse | Redis falso |
returns the callback value and releases the key | Callback exitoso | Devuelve su valor y deja el almacén sin claves | Redis falso |
releases the lock even when the callback throws | El callback lanza error | Se propaga el error y la clave se libera | Redis falso |
never releases a lock that another holder took after expiry | El lock expiró y otro titular lo tomó | No libera el lock ajeno | Redis falso (eval) |
processes without lock once the TTL passes while still held | Se vence el TTL esperando el lock | Procesa sin lock cuando pasa el TTL | jest.useFakeTimers, Redis falso |
processes without lock right away when Redis is down | Redis no disponible | Procesa de inmediato sin lock (sin demora apreciable) y sin llamar a eval | Redis falso con fallo |
closes its Redis connection on shutdown | Se apaga el módulo | Llama a quit del cliente Redis | Redis falso (quit) |
First-message race on ticket creation (integration)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
reproduces the bug: two simultaneous first messages create two tickets | Dos primeros mensajes simultáneos sin lock | Se crean 2 tickets para el mismo contacto | Mongo en memoria |
with the inbound lock, the same two messages share one ticket | Los mismos dos mensajes con el lock | Se crea 1 solo ticket; ambos resultados comparten id y exactamente uno tiene created = true | Mongo en memoria, lock con Redis falso |