Ticket — Casos de prueba del servicio
Documenta los tests unitarios del servicio de tickets, en src/app/modules/ticket/services/:
ticket.service.spec.tsticket.service.business-hours.spec.tsticket-resolution-effects.service.spec.ts
Resumen: 3 specs, 22 suites (describe) y 142 tests (112 + 11 + 19). Todas las dependencias están mockeadas; no se conecta a MongoDB.
ticket.service.spec.ts
Tests unitarios de TicketService con todas las dependencias mockeadas (TicketRepository, Ticket model con aggregate().exec(), Message model, SocketBroadcastService, StorageService, TranslateService, ScheduledMessageCancellationService, etc.). Las constantes MOCK_TENANT_ID, MOCK_USER_ID, MOCK_CONTACT_ID y MOCK_LABEL_ID son ObjectIds fijos.
getTicketsList
Mocks comunes: mockModel.aggregate devuelve { exec } con un resultado $facet (mockFacetResult con 1 ticket, o mockFacetEmpty).
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should call aggregate and return results with meta | Se llama getTicketsList(tenant) sin filtros. | Invoca aggregate; results es el ticket y meta = { total: 1, page: 1, limit: 10, totalPages: 1 }. | aggregate.exec con facet de 1 ticket |
should return empty results and zero meta when no tickets found | El facet devuelve data y total vacíos. | results vacío y meta con total: 0 y totalPages: 0. | exec con mockFacetEmpty |
should use default empty filters when none are provided | Se llama sin segundo argumento. | aggregate se invoca exactamente una vez. | aggregate |
should place tenantId $match as the first pipeline stage | Pipeline generado sin filtros. | La primera etapa tiene la propiedad $match. | aggregate |
should include tenantId as ObjectId in match stage | Pipeline generado sin filtros. | $match.tenantId es un ObjectId igual al tenant recibido. | aggregate |
should add sendType to $match when status filter is provided | Filtro status: 'open'. | $match.sendType es 'open'. | aggregate |
should add UserId $in filter when assignedUserId is provided | Filtro assignedUserId. | $match.UserId.$in contiene el ObjectId y el string del usuario. | aggregate |
approval: should set UserId to null when unassigned is provided (legacy, no assignedUserIds) | Filtro unassigned: true sin assignedUserIds (precedencia D7). | $match.UserId es null. | aggregate |
approval: should $or UserId/null when assignedUserId + includeUnassigned are provided (legacy, no assignedUserIds) | assignedUserId + includeUnassigned: true. | $match.$or = [{ UserId: { $in: [ObjectId, string] } }, { UserId: null }]. | aggregate |
should add UserId $in with mixed ObjectId/string per id when assignedUserIds is provided | assignedUserIds con dos ids. | UserId.$in alterna ObjectId y string por cada id. | aggregate |
should give assignedUserIds precedence over unassigned and assignedUserId+includeUnassigned | Se envían assignedUserIds, unassigned, assignedUserId e includeUnassigned a la vez. | No hay $or; UserId.$in usa solo assignedUserIds. | aggregate |
should NOT add a UserId $match when assignedUserIds is absent/empty (legacy behavior unaffected) | Filtros {}. | $match.UserId es undefined. | aggregate |
should add ContactId $in filter when contactId is provided | Filtro contactId. | ContactId.$in con ObjectId y string. | aggregate |
should add createdAt $gte when createdFrom is provided | Filtro createdFrom. | createdAt.$gte es Date. | aggregate |
should add createdAt $lte when createdTo is provided | Filtro createdTo. | createdAt.$lte es Date. | aggregate |
should add both $gte and $lte when both date filters are provided | Ambos filtros de fecha. | createdAt con $gte y $lte como Date. | aggregate |
should add lastMessageData.messageType $match when lastMessageType is provided | lastMessageType: 'chat'. | Existe etapa $match con lastMessageData.messageType = 'chat'. | aggregate |
should add lastMessageData.sendType $match when lastMessageSendType is provided | lastMessageSendType: 'contact'. | Etapa $match con lastMessageData.sendType = 'contact'. | aggregate |
should add lastMessageData.ack $match when lastMessageAck is provided | lastMessageAck: 'read'. | Etapa $match con lastMessageData.ack = 'read'. | aggregate |
should add contact.labels $match with ObjectId when labelId is provided | labelId. | contact.labels es ObjectId del label. | aggregate |
should add contact.isBlocked $match when contactIsBlocked is provided | contactIsBlocked: true. | Etapa con contact.isBlocked = true. | aggregate |
should add contact.isActive $match when contactIsActive is provided | contactIsActive: false (falsy, borde). | Etapa con contact.isActive = false (no se omite). | aggregate |
should add $gt unreadData $match when hasUnread is true | hasUnread: true. | Existe etapa $match.$expr.$gt. | aggregate |
should add $eq 0 unreadData $match when hasUnread is false | hasUnread: false. | Existe etapa $match.$expr.$eq. | aggregate |
should add lockedBy $ne null $match when lockedOnly is true | lockedOnly: true (ticket-lock). | $match.lockedBy = { $ne: null }. | aggregate |
should NOT add a lockedBy $match when lockedOnly is not provided | Sin filtros. | $match.lockedBy es undefined. | aggregate |
should include the lockedByUser lookup stage in the pipeline | Pipeline por defecto. | Hay $lookup con as: 'lockedByUser' y localField: 'lockedBy'. | aggregate |
should have only one $match stage when no filters are provided | Filtros {}. | Solo una etapa $match. | aggregate |
should always include a $sort stage | Pipeline por defecto. | Existe $sort por lastMessageData.createdAt: -1. | aggregate |
should end with a $facet stage containing data and total branches | Pipeline por defecto. | La última etapa es $facet con ramas data y total. | aggregate |
should include $project inside $facet.data | Pipeline por defecto. | $facet.data contiene un $project. | aggregate |
should include contact.createdAt in the $project stage for the contact panel registration date | Pipeline por defecto. | $project.contact.createdAt = '$contact.createdAt'. | aggregate |
should use default page=1 and limit=20 when not provided | Sin paginación. | $skip = 0 y $limit = 10 (el título dice 20 pero el default verificado es 10). | aggregate |
should apply correct $skip and $limit for page 2 with limit 10 | page: 2, limit: 10. | $skip = 10, $limit = 10. | aggregate |
should calculate totalPages correctly | Total 25, limit: 10. | meta.totalPages = 3. | exec con total 25 |
should return totalPages 0 when total is 0 | Facet vacío. | meta.totalPages = 0. | exec vacío |
should reflect custom page and limit in meta | page: 3, limit: 15, total 50. | meta = page 3, limit 15, total 50, totalPages 4. | exec total 50 |
getTicketsList / assignedUser/department/connection projection
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should include the assignedUser $lookup stage (users, UserId -> _id) | Pipeline por defecto. | $lookup as: 'assignedUser', from: 'users', localField: 'UserId', foreignField: '_id'. | aggregate |
should $unwind assignedUser preserving tickets without an assigned user | Pipeline por defecto. | $unwind de $assignedUser con preserveNullAndEmptyArrays: true. | aggregate |
should include the department $lookup stage (departments, departmentId -> _id) | Pipeline por defecto. | $lookup a departments por departmentId → _id. | aggregate |
should include the connection $lookup stage (connections, connectionId -> _id) | Pipeline por defecto. | $lookup a connections por connectionId → _id. | aggregate |
should project assignedUser with the expected fields, conditioned on assignedUser._id existing | Inspección del $project en $facet.data. | $cond con if $ifNull de assignedUser._id; then con identifier, username, email, nombres, userType, isActive, avatarUrl; else null. | aggregate |
should project department with name/color (and the rest of its fields), removed entirely when unassigned | Inspección del $project. | then incluye _id, name, color; else = '$$REMOVE'. | aggregate |
should project connection with channelType/name, removed entirely when there is no connection | Inspección del $project. | then incluye _id, name, channelType; else = '$$REMOVE'. | aggregate |
should pass through a fully-populated assignedUser/department/connection from the aggregation result | El facet devuelve un ticket con los tres objetos poblados. | El resultado conserva assignedUser, department y connection tal cual. | exec con ticket poblado |
should pass through null/absent assignedUser and department without breaking when the ticket has no UserId/departmentId | Ticket con assignedUser: null y sin department/connection. | assignedUser es null; department y connection son undefined, sin error. | exec con ticket sin asignar |
getTicketById
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should match by _id and tenantId as the first pipeline stage | Se consulta un ticket por id y tenant. | Primera etapa $match con _id y tenantId correctos. | aggregate.exec |
should include the assignedUser, department and connection lookup stages in the pipeline | Pipeline generado. | Existen los tres $lookup (assignedUser, department, connection). | aggregate |
should return the first ticket in facetResult.data with assignedUser/department/connection populated | El facet devuelve un ticket poblado. | Devuelve ese ticket con username, nombre de departamento y channelType. | exec con ticket poblado |
should return null assignedUser/department/connection consistently when the ticket has none assigned | Ticket sin asignación ni departamento/conexión. | assignedUser null; department y connection undefined. | exec |
should return null when no ticket matches the id/tenant (empty facet data) | data vacío. | Devuelve null. | exec vacío |
should scope facetStage to a single result (page=1, limit=1) | Pipeline generado. | $skip 0 y $limit 1 en $facet.data. | aggregate |
getTicketMessages
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
should return messages for a given ticketId | Consulta de mensajes de un ticket. | messages igual al mock; meta.page 1 y meta.limit 10. | aggregate.exec |
strips the password hash from embedded user senders | Se inspecciona el pipeline. | Tras el $lookup usersData hay una etapa { $unset: 'usersData.password' }. | aggregate |
should use default page=1 and limit=10 when not provided | Sin tenant ni paginación. | meta page 1 limit 10 y $slice ['$sortedMessages', 0, 10]. | aggregate |
should calculate skip correctly for page 2 with limit 10 | page 2, limit 10. | $slice con skip 10 y limit 10. | aggregate |
should calculate skip correctly for page 3 with limit 20 | page 3, limit 20. | $slice con skip 40 y limit 20. | aggregate |
should match by ticketId and tenantId as first pipeline stage | Pipeline generado. | Primera etapa $match con _id del ticket y tenantId definido. | aggregate |
should return empty array when no messages are found | Resultado con messages: []. | messages es []. | exec |
should handle multiple messages in correct order | Dos mensajes. | Se devuelven 2 mensajes en orden ('First', 'Second'). | exec |
should include sender information in the response | Mensaje con sender. | sender definido y username correcto. | exec |
should include all message fields in response | Un mensaje completo. | Tiene _id, body, messageType, sendType, ack, deliveredAt, createdAt, updatedAt, parentId, MediaId, readAt. | exec |
should resolve a fresh signed url from storageKey for messages with media, and strip storageKey | Mensaje con media.storageKey. | Llama a getSignedUrl(storageKey); media.url es la URL firmada y storageKey queda undefined. | StorageService.getSignedUrl, exec |
should not call getSignedUrl for messages without media | Mensaje sin media. | getSignedUrl no se invoca. | StorageService |
should include contact lookups in the pipeline | Pipeline generado. | Existe $lookup desde contacts. | aggregate |
should include user lookups in the pipeline | Pipeline generado. | Existe $lookup desde users. | aggregate |
should sort messages chronologically in ascending order | Pipeline generado. | $sortArray.sortBy = { createdAt: 1 }. | aggregate |
should return correct meta with total pages | 25 mensajes totales, limit 10. | meta total 25, page 1, limit 10, totalPages 3. | exec |
should calculate totalPages as 0 when total is 0 | totalMessages: 0. | totalPages 0. | exec |
should return page 2 messages when requested | Página 2 con total 22. | meta.page 2 y primer mensaje 'Message 3'. | exec |
should return correct messages when navigating to page 3 with limit 5 | Página 3, limit 5, total 50. | page 3, limit 5, totalPages 10, mensaje 'Message 11'. | exec |
should maintain message chronological order across pages | Página 1, limit 3, total 6. | 3 mensajes en orden Msg1-Msg3 y totalPages 2. | exec |
should skip messages correctly when going to next page | Dos llamadas consecutivas (página 1 y 2). | El $slice pasa de skip 0 a skip 10. | aggregate |
should return partial results on last page | Página 3, limit 10, total 22. | 2 mensajes, page 3, totalPages 3. | exec |
postponeTicket
Mocks: TicketRepository.findByIdAndTenant, postpone; Message model create (mensaje de sistema).
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws ForbiddenException when requesting user is neither owner nor admin | Un agente que no es dueño pospone. | Rechaza con ticket.forbidden; no llama a postpone. | findByIdAndTenant |
throws BadRequestException when postponedUntil is not a future date | Fecha 5 minutos en el pasado. | Rechaza con ticket.invalidPostponeDate; no llama a postpone. | findByIdAndTenant |
allows the admin to postpone even when not the owner | Rol admin distinto del dueño con fecha futura. | Devuelve el ticket actualizado y llama postpone(ticket._id, future). | findByIdAndTenant, postpone |
sets status to postponed and persists the date when the owner postpones | El dueño pospone con fecha futura. | sendType = postponed, postponedUntil persistido y se crea un mensaje de sistema. | postpone, Message.create |
cancelPostpone
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws BadRequestException when the ticket is not postponed | Ticket en estado open. | Rechaza con ticket.notPostponed; no llama a cancelPostpone. | findByIdAndTenant |
throws ForbiddenException when requesting user is neither owner nor admin | Agente no dueño. | Rechaza con ticket.forbidden; no cancela. | findByIdAndTenant |
sets status back to open and clears the date on success | Dueño cancela el aplazamiento. | sendType open, postponedUntil undefined; llama cancelPostpone(ticket._id). | cancelPostpone |
markUnread
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException when the ticket does not exist for the tenant | findByIdAndTenant devuelve null. | Rechaza con ticket.notFound; no llama a setAgentUnread. | findByIdAndTenant |
sets agentUnread to true regardless of ticket ownership | Marca como no leído sin comprobar propietario. | agentUnread true; setAgentUnread(ticket._id, true). | setAgentUnread |
sets agentUnread back to false | Ticket con agentUnread: true marcado en false. | agentUnread false; setAgentUnread(ticket._id, false). | setAgentUnread |
deleteTicket
Mocks: findByIdAndTenant, deleteById, Message.deleteMany, SocketBroadcastService (broadcastTicketClosed, evictTicketRoom), ScheduledMessageCancellationService.cancelForTicket.
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException when the ticket does not exist for the tenant | Ticket inexistente. | Rechaza con ticket.notFound; no hay broadcast, evict, deleteMany ni deleteById. | todos |
executes notify -> evict -> cascade delete -> root delete in that exact order | Se registra el orden de las llamadas. | Orden notify, evict, cascade, root. | todos con mockImplementation |
notifies the contact room with ticketId, contactId, and reason "deleted" | Eliminación exitosa. | broadcastTicketClosed(ticketId, contactId, 'deleted'). | broadcastTicketClosed |
evicts the ticket room | Eliminación exitosa. | evictTicketRoom(ticketId). | evictTicketRoom |
cascades message deletion scoped to both ticketId and tenantId | Eliminación exitosa. | Message.deleteMany({ ticketId, tenantId }). | Message.deleteMany |
deletes the ticket root by id after the cascade completes and returns it | Eliminación exitosa. | deleteById(ticket._id) y devuelve el ticket. | deleteById |
swallows an eviction failure (e.g. contact offline) and still completes the deletion | evictTicketRoom lanza error. | La eliminación se completa igualmente; se llama a deleteMany y deleteById. | evictTicketRoom que lanza |
propagates a cascade delete failure and never deletes the ticket root | deleteMany rechaza con mongo down. | Propaga el error y no llama a deleteById. | Message.deleteMany rechaza |
cancels the ticket's pending scheduled messages with reason ticket_deleted, before the message cascade delete | Se registra el orden. | cancelForTicket(ticketId, tenantId, 'ticket_deleted') antes del cascade. | cancelForTicket |
is non-fatal: a scheduled-message cancellation failure does not block the delete | cancelForTicket rechaza (redis down). | La eliminación se completa y se llama a deleteById. | cancelForTicket rechaza |
resolveTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
cancels the ticket's pending scheduled messages with reason ticket_resolved, after markResolved and before the system message | Se registra el orden de llamadas al resolver. | cancelForTicket(ticketId, tenantId, 'ticket_resolved'); orden markResolved, cancelScheduled, systemMessage. | markResolved, cancelForTicket, Message.create |
is non-fatal: a scheduled-message cancellation failure does not block ticket resolution | cancelForTicket rechaza. | resolveTicket resuelve con el ticket. | cancelForTicket rechaza |
lockTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException when the ticket does not exist for the tenant | Ticket inexistente. | Rechaza con ticket.notFound; no llama a lockTicket. | findByIdAndTenant |
throws ForbiddenException when the requester is not the ticket owner | Otro usuario intenta bloquear. | Rechaza con ticket.notLockOwner. | findByIdAndTenant |
throws BadRequestException when the ticket is not open/inProgress | Ticket resolved. | Rechaza con ticket.notActive. | findByIdAndTenant |
locks an open ticket owned by the requester | Ticket open del solicitante. | lockTicket(ticket._id, ownerId) y devuelve el ticket bloqueado. | lockTicket |
locks an inProgress ticket owned by the requester | Ticket inProgress. | Resuelve con un resultado definido. | lockTicket |
throws ConflictException when the CAS write loses the race (already locked) | lockTicket devuelve null. | Rechaza con ticket.alreadyAssigned. | lockTicket null |
unlockTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException when the ticket does not exist for the tenant | Ticket inexistente. | Rechaza con ticket.notFound; no desbloquea. | findByIdAndTenant |
throws ForbiddenException when the requester does not hold the lock | Otro usuario intenta desbloquear. | Rechaza con ticket.notLockOwner. | findByIdAndTenant |
unlocks a ticket locked by the requester | El dueño del bloqueo desbloquea. | unlockTicket(ticket._id, ownerId) y devuelve el ticket con lockedBy: null. | unlockTicket |
throws ConflictException when the CAS write loses the race | unlockTicket devuelve null. | Rechaza con ticket.alreadyAssigned. | unlockTicket null |
forceUnlockTicket
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws NotFoundException when the ticket does not exist for the tenant | Ticket inexistente. | Rechaza con ticket.notFound. | findByIdAndTenant |
unlocks a ticket locked by anyone (no ownership check — tenant scope only) | Ticket bloqueado por otro usuario. | unlockTicket(ticket._id, null) y devuelve el desbloqueado. | unlockTicket |
throws ConflictException when the CAS write loses the race | unlockTicket devuelve null. | Rechaza con ticket.alreadyAssigned. | unlockTicket null |
ticket-lock guards
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns null immediately when the ticket is locked, without resolving departments (redistributeTicket) | Ticket con lockedBy informado. | Devuelve null de inmediato. | ninguno adicional |
throws a typed TICKET_LOCKED conflict (409) when the ticket is locked, before releasing to queue (unassignTicket) | Ticket bloqueado; un agente intenta desasignar. | Lanza excepción 409 con data.code = 'TICKET_LOCKED'; no llama a releaseToQueue. | findByIdAndTenant |
throws a typed TICKET_LOCKED conflict (409) when the ticket is locked, before transferring (transferTicket) | Ticket bloqueado; un agente intenta transferir. | Lanza 409 TICKET_LOCKED; no llama a transferUpdate. | findByIdAndTenant |
ticket.service.business-hours.spec.ts
Suite TicketService — autoAssignTicket business hours wiring. Verifica el cableado de horario laboral en autoAssignTicket: cuándo se consulta TenantSettings, cuándo se asigna y cuándo se envía (una sola vez) el mensaje de ausencia.
Mocks comunes: TicketRepository (countActiveByUser, assignUser, markOutOfHoursNotified), ContactRepository, UserRepository, DepartmentRepository (departamento Soporte con auto-asignación), DepartmentUserRepository.findAutoAssignUserIds (agentes A y B), TenantSettingsService.getOrCreate, BusinessHoursEvaluatorService.evaluate, Message model y SocketBroadcastService.
TicketService — autoAssignTicket business hours wiring
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
already-assigned ticket: returns null immediately WITHOUT consulting tenant settings at all | El ticket ya tiene UserId. | Devuelve null y no llama a getOrCreate. | TenantSettingsService |
REGRESSION: businessHours.enabled=false (default, no TenantSettings previously created) — assigns exactly as before | Horario deshabilitado; agente A con 2 tickets activos y B con 0. | Asigna al agente B (menos carga) con assignUser(ticket._id, B, null, undefined, undefined); no evalúa horario ni crea mensaje de agente. | countActiveByUser, assignUser, findByMongoId |
REGRESSION: no agents connected + businessHours.enabled=false — returns null, no absence message (unchanged pre-existing behavior) | Horario deshabilitado y lista de agentes conectados vacía. | Devuelve null; no asigna ni crea mensajes. | getOrCreate |
ticket without department: never auto-assigned, even within hours and with agents online | Ticket con departmentId: null y agentes en línea. | Devuelve null; no asigna ni crea mensajes. | getOrCreate |
businessHours.enabled=true AND within hours: assigns normally, no absence message | Horario habilitado y isWithinHours: true. | Asigna al agente A y no envía el mensaje Cerrado. | evaluate, assignUser |
businessHours.enabled=true AND outside hours: does NOT auto-assign, sends the configured absence message | Horario habilitado y fuera de horario con un agente conectado. | Devuelve null; no asigna; crea mensaje con el texto de ausencia, hace broadcast y llama markOutOfHoursNotified. | evaluate, Message.create, broadcastSystemMessage |
businessHours.enabled=true AND outside hours AND zero agents connected: still sends the absence message | Fuera de horario sin agentes. | Devuelve null y envía igualmente el mensaje de ausencia. | evaluate, Message.create |
outside hours on a ticket without department: still sends the absence message | Fuera de horario y ticket sin departamento. | Devuelve null, envía el mensaje y marca markOutOfHoursNotified. | evaluate, Message.create |
outside hours on an already-notified ticket: no second absence message, still no assignment | outOfHoursNotifiedAt ya informado. | No asigna, no crea mensaje, no hace broadcast ni vuelve a marcar. | markOutOfHoursNotified |
outside hours when a concurrent message already claimed the notice: no second absence message | markOutOfHoursNotified devuelve null (otra petición ganó la carrera). | Devuelve null; se intentó marcar pero no se crea mensaje ni broadcast. | markOutOfHoursNotified null |
businessHours.enabled=false: never seals outOfHoursNotifiedAt | Horario deshabilitado y asignación normal. | No se evalúa el horario ni se llama a markOutOfHoursNotified. | evaluate, markOutOfHoursNotified |
ticket-resolution-effects.service.spec.ts
Suite TicketResolutionEffectsService. Prueba apply(ticket, opciones): cambio a RESOLVED, mensaje de sistema, despedida de la conexión y disparo de CSAT.
Mocks comunes: TicketRepository (updateStatus, triggerCsat), Message model, SocketBroadcastService (broadcastSystemMessage, broadcastTicketStatusChanged), TenantSettingsService.getOrCreate (CSAT deshabilitado por defecto), ContactRepository, MessageTemplateService.interpolate, ScheduledMessageCancellationService, ConnectionRepository, OutboundRouterService.route.
TicketResolutionEffectsService
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
sets the ticket status to RESOLVED via the repository | apply con source: 'manual'. | updateStatus(ticket._id, RESOLVED). | updateStatus |
creates and broadcasts a system message naming the actor for a manual resolution | Resolución manual con actorName: 'Byron'. | Crea mensaje Ticket resuelto por Byron con isInternal: true y lo emite con broadcastSystemMessage(..., true). | Message.create, broadcastSystemMessage |
says the resolution was automatic when the source is the inactivity job | source: 'inactivity' sin actor. | El cuerpo es Ticket resuelto automáticamente por System. | Message.create |
defaults the actor name to "System" when none is provided | Resolución manual sin actorName. | El cuerpo es Ticket resuelto por System. | Message.create |
broadcasts ticket_status_changed to the tenant so open lists drop the ticket | Resolución por inactividad. | broadcastTicketStatusChanged(tenantId, ticketId, 'resolved'). | broadcastTicketStatusChanged |
returns the updated ticket from the repository | Resolución manual. | Devuelve el ticket actualizado por el repositorio. | updateStatus |
CA6 — cancel pending scheduled messages
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
cancels the ticket's pending scheduled messages with reason ticket_resolved, after updateStatus and before the system message | Se registra el orden de las llamadas. | cancelForTicket(ticketId, tenantId, 'ticket_resolved'); orden updateStatus, cancelScheduled, systemMessage. | updateStatus, cancelForTicket, Message.create |
is non-fatal: a scheduled-message cancellation failure does not block resolution | cancelForTicket rechaza (redis down). | apply resuelve con el ticket actualizado. | cancelForTicket rechaza |
farewell message
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
does not say goodbye when useConnectionFarewell=false | useConnectionFarewell: false. | No consulta la conexión; solo se crea el mensaje de resolución; no se enruta saliente. | ConnectionRepository, OutboundRouterService |
sends the connection farewell as a system message when useConnectionFarewell=true | Ticket con connectionId cuya conexión tiene farewellMessage. | Consulta la conexión, interpola la plantilla con el contacto, crea el último mensaje Connection farewell, lo emite y lo enruta una vez. | findById, interpolate, Message.create, route |
does not say goodbye when the connection has only blank text | farewellMessage solo con espacios. | Solo se crea un mensaje (el de resolución). | findById |
does not say goodbye when the ticket has no connection | Ticket sin connectionId. | Solo se crea un mensaje. | Message.create |
CSAT trigger
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
does nothing to csat when tenant csat.enabled=false (manual resolution) | CSAT deshabilitado, resolución manual. | No se llama a triggerCsat y solo se crea un mensaje. | getOrCreate, triggerCsat |
triggers csat.status=pending on manual resolution when csat.enabled=true, regardless of sendOnAutoResolve | CSAT habilitado con sendOnAutoResolve: false, resolución manual. | triggerCsat con triggeredBy: 'manual' y snapshots de agente y departamento. | triggerCsat |
sets expiresAt to 48h after requestedAt | CSAT habilitado. | expiresAt - requestedAt = 48 horas en ms. | triggerCsat |
snapshots null agent/department when the ticket has none | Ticket sin agente ni departamento. | agentIdSnapshot y departmentIdSnapshot son null. | triggerCsat |
sends csat.requestMessage as plain text via interpolate + Message.create + broadcast | CSAT habilitado con requestMessage personalizado. | Interpola con contacto y ticket, crea el mensaje, lo emite y lo enruta una vez. | interpolate, Message.create, broadcastSystemMessage, route |
does NOT trigger csat on inactivity resolution when sendOnAutoResolve=false, even if csat.enabled=true | Inactividad con sendOnAutoResolve: false. | No se llama a triggerCsat. | triggerCsat |
triggers csat on inactivity resolution when both csat.enabled=true and sendOnAutoResolve=true | Inactividad con ambos flags en true. | triggerCsat con triggeredBy: 'inactivity'. | triggerCsat |