Skip to main content

Ticket — Casos de prueba del servicio

Documenta los tests unitarios del servicio de tickets, en src/app/modules/ticket/services/:

  • ticket.service.spec.ts
  • ticket.service.business-hours.spec.ts
  • ticket-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).

TestEscenarioResultado esperadoMocks
should call aggregate and return results with metaSe 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 foundEl 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 providedSe llama sin segundo argumento.aggregate se invoca exactamente una vez.aggregate
should place tenantId $match as the first pipeline stagePipeline generado sin filtros.La primera etapa tiene la propiedad $match.aggregate
should include tenantId as ObjectId in match stagePipeline generado sin filtros.$match.tenantId es un ObjectId igual al tenant recibido.aggregate
should add sendType to $match when status filter is providedFiltro status: 'open'.$match.sendType es 'open'.aggregate
should add UserId $in filter when assignedUserId is providedFiltro 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 providedassignedUserIds con dos ids.UserId.$in alterna ObjectId y string por cada id.aggregate
should give assignedUserIds precedence over unassigned and assignedUserId+includeUnassignedSe 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 providedFiltro contactId.ContactId.$in con ObjectId y string.aggregate
should add createdAt $gte when createdFrom is providedFiltro createdFrom.createdAt.$gte es Date.aggregate
should add createdAt $lte when createdTo is providedFiltro createdTo.createdAt.$lte es Date.aggregate
should add both $gte and $lte when both date filters are providedAmbos filtros de fecha.createdAt con $gte y $lte como Date.aggregate
should add lastMessageData.messageType $match when lastMessageType is providedlastMessageType: 'chat'.Existe etapa $match con lastMessageData.messageType = 'chat'.aggregate
should add lastMessageData.sendType $match when lastMessageSendType is providedlastMessageSendType: 'contact'.Etapa $match con lastMessageData.sendType = 'contact'.aggregate
should add lastMessageData.ack $match when lastMessageAck is providedlastMessageAck: 'read'.Etapa $match con lastMessageData.ack = 'read'.aggregate
should add contact.labels $match with ObjectId when labelId is providedlabelId.contact.labels es ObjectId del label.aggregate
should add contact.isBlocked $match when contactIsBlocked is providedcontactIsBlocked: true.Etapa con contact.isBlocked = true.aggregate
should add contact.isActive $match when contactIsActive is providedcontactIsActive: false (falsy, borde).Etapa con contact.isActive = false (no se omite).aggregate
should add $gt unreadData $match when hasUnread is truehasUnread: true.Existe etapa $match.$expr.$gt.aggregate
should add $eq 0 unreadData $match when hasUnread is falsehasUnread: false.Existe etapa $match.$expr.$eq.aggregate
should add lockedBy $ne null $match when lockedOnly is truelockedOnly: true (ticket-lock).$match.lockedBy = { $ne: null }.aggregate
should NOT add a lockedBy $match when lockedOnly is not providedSin filtros.$match.lockedBy es undefined.aggregate
should include the lockedByUser lookup stage in the pipelinePipeline por defecto.Hay $lookup con as: 'lockedByUser' y localField: 'lockedBy'.aggregate
should have only one $match stage when no filters are providedFiltros {}.Solo una etapa $match.aggregate
should always include a $sort stagePipeline por defecto.Existe $sort por lastMessageData.createdAt: -1.aggregate
should end with a $facet stage containing data and total branchesPipeline por defecto.La última etapa es $facet con ramas data y total.aggregate
should include $project inside $facet.dataPipeline por defecto.$facet.data contiene un $project.aggregate
should include contact.createdAt in the $project stage for the contact panel registration datePipeline por defecto.$project.contact.createdAt = '$contact.createdAt'.aggregate
should use default page=1 and limit=20 when not providedSin 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 10page: 2, limit: 10.$skip = 10, $limit = 10.aggregate
should calculate totalPages correctlyTotal 25, limit: 10.meta.totalPages = 3.exec con total 25
should return totalPages 0 when total is 0Facet vacío.meta.totalPages = 0.exec vacío
should reflect custom page and limit in metapage: 3, limit: 15, total 50.meta = page 3, limit 15, total 50, totalPages 4.exec total 50

getTicketsList / assignedUser/department/connection projection​

TestEscenarioResultado esperadoMocks
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 userPipeline 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 existingInspecció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 unassignedInspección del $project.then incluye _id, name, color; else = '$$REMOVE'.aggregate
should project connection with channelType/name, removed entirely when there is no connectionInspección del $project.then incluye _id, name, channelType; else = '$$REMOVE'.aggregate
should pass through a fully-populated assignedUser/department/connection from the aggregation resultEl 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/departmentIdTicket con assignedUser: null y sin department/connection.assignedUser es null; department y connection son undefined, sin error.exec con ticket sin asignar

getTicketById​

TestEscenarioResultado esperadoMocks
should match by _id and tenantId as the first pipeline stageSe 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 pipelinePipeline generado.Existen los tres $lookup (assignedUser, department, connection).aggregate
should return the first ticket in facetResult.data with assignedUser/department/connection populatedEl 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 assignedTicket 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​

TestEscenarioResultado esperadoMocks
should return messages for a given ticketIdConsulta 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 sendersSe inspecciona el pipeline.Tras el $lookup usersData hay una etapa { $unset: 'usersData.password' }.aggregate
should use default page=1 and limit=10 when not providedSin tenant ni paginación.meta page 1 limit 10 y $slice ['$sortedMessages', 0, 10].aggregate
should calculate skip correctly for page 2 with limit 10page 2, limit 10.$slice con skip 10 y limit 10.aggregate
should calculate skip correctly for page 3 with limit 20page 3, limit 20.$slice con skip 40 y limit 20.aggregate
should match by ticketId and tenantId as first pipeline stagePipeline generado.Primera etapa $match con _id del ticket y tenantId definido.aggregate
should return empty array when no messages are foundResultado con messages: [].messages es [].exec
should handle multiple messages in correct orderDos mensajes.Se devuelven 2 mensajes en orden ('First', 'Second').exec
should include sender information in the responseMensaje con sender.sender definido y username correcto.exec
should include all message fields in responseUn 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 storageKeyMensaje 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 mediaMensaje sin media.getSignedUrl no se invoca.StorageService
should include contact lookups in the pipelinePipeline generado.Existe $lookup desde contacts.aggregate
should include user lookups in the pipelinePipeline generado.Existe $lookup desde users.aggregate
should sort messages chronologically in ascending orderPipeline generado.$sortArray.sortBy = { createdAt: 1 }.aggregate
should return correct meta with total pages25 mensajes totales, limit 10.meta total 25, page 1, limit 10, totalPages 3.exec
should calculate totalPages as 0 when total is 0totalMessages: 0.totalPages 0.exec
should return page 2 messages when requestedPá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 5Página 3, limit 5, total 50.page 3, limit 5, totalPages 10, mensaje 'Message 11'.exec
should maintain message chronological order across pagesPágina 1, limit 3, total 6.3 mensajes en orden Msg1-Msg3 y totalPages 2.exec
should skip messages correctly when going to next pageDos llamadas consecutivas (página 1 y 2).El $slice pasa de skip 0 a skip 10.aggregate
should return partial results on last pagePágina 3, limit 10, total 22.2 mensajes, page 3, totalPages 3.exec

postponeTicket​

Mocks: TicketRepository.findByIdAndTenant, postpone; Message model create (mensaje de sistema).

TestEscenarioResultado esperadoMocks
throws ForbiddenException when requesting user is neither owner nor adminUn agente que no es dueño pospone.Rechaza con ticket.forbidden; no llama a postpone.findByIdAndTenant
throws BadRequestException when postponedUntil is not a future dateFecha 5 minutos en el pasado.Rechaza con ticket.invalidPostponeDate; no llama a postpone.findByIdAndTenant
allows the admin to postpone even when not the ownerRol 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 postponesEl dueño pospone con fecha futura.sendType = postponed, postponedUntil persistido y se crea un mensaje de sistema.postpone, Message.create

cancelPostpone​

TestEscenarioResultado esperadoMocks
throws BadRequestException when the ticket is not postponedTicket en estado open.Rechaza con ticket.notPostponed; no llama a cancelPostpone.findByIdAndTenant
throws ForbiddenException when requesting user is neither owner nor adminAgente no dueño.Rechaza con ticket.forbidden; no cancela.findByIdAndTenant
sets status back to open and clears the date on successDueño cancela el aplazamiento.sendType open, postponedUntil undefined; llama cancelPostpone(ticket._id).cancelPostpone

markUnread​

TestEscenarioResultado esperadoMocks
throws NotFoundException when the ticket does not exist for the tenantfindByIdAndTenant devuelve null.Rechaza con ticket.notFound; no llama a setAgentUnread.findByIdAndTenant
sets agentUnread to true regardless of ticket ownershipMarca como no leído sin comprobar propietario.agentUnread true; setAgentUnread(ticket._id, true).setAgentUnread
sets agentUnread back to falseTicket con agentUnread: true marcado en false.agentUnread false; setAgentUnread(ticket._id, false).setAgentUnread

deleteTicket​

Mocks: findByIdAndTenant, deleteById, Message.deleteMany, SocketBroadcastService (broadcastTicketClosed, evictTicketRoom), ScheduledMessageCancellationService.cancelForTicket.

TestEscenarioResultado esperadoMocks
throws NotFoundException when the ticket does not exist for the tenantTicket inexistente.Rechaza con ticket.notFound; no hay broadcast, evict, deleteMany ni deleteById.todos
executes notify -> evict -> cascade delete -> root delete in that exact orderSe 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 roomEliminación exitosa.evictTicketRoom(ticketId).evictTicketRoom
cascades message deletion scoped to both ticketId and tenantIdEliminación exitosa.Message.deleteMany({ ticketId, tenantId }).Message.deleteMany
deletes the ticket root by id after the cascade completes and returns itEliminación exitosa.deleteById(ticket._id) y devuelve el ticket.deleteById
swallows an eviction failure (e.g. contact offline) and still completes the deletionevictTicketRoom 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 rootdeleteMany 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 deleteSe registra el orden.cancelForTicket(ticketId, tenantId, 'ticket_deleted') antes del cascade.cancelForTicket
is non-fatal: a scheduled-message cancellation failure does not block the deletecancelForTicket rechaza (redis down).La eliminación se completa y se llama a deleteById.cancelForTicket rechaza

resolveTicket​

TestEscenarioResultado esperadoMocks
cancels the ticket's pending scheduled messages with reason ticket_resolved, after markResolved and before the system messageSe 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 resolutioncancelForTicket rechaza.resolveTicket resuelve con el ticket.cancelForTicket rechaza

lockTicket​

TestEscenarioResultado esperadoMocks
throws NotFoundException when the ticket does not exist for the tenantTicket inexistente.Rechaza con ticket.notFound; no llama a lockTicket.findByIdAndTenant
throws ForbiddenException when the requester is not the ticket ownerOtro usuario intenta bloquear.Rechaza con ticket.notLockOwner.findByIdAndTenant
throws BadRequestException when the ticket is not open/inProgressTicket resolved.Rechaza con ticket.notActive.findByIdAndTenant
locks an open ticket owned by the requesterTicket open del solicitante.lockTicket(ticket._id, ownerId) y devuelve el ticket bloqueado.lockTicket
locks an inProgress ticket owned by the requesterTicket 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​

TestEscenarioResultado esperadoMocks
throws NotFoundException when the ticket does not exist for the tenantTicket inexistente.Rechaza con ticket.notFound; no desbloquea.findByIdAndTenant
throws ForbiddenException when the requester does not hold the lockOtro usuario intenta desbloquear.Rechaza con ticket.notLockOwner.findByIdAndTenant
unlocks a ticket locked by the requesterEl 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 raceunlockTicket devuelve null.Rechaza con ticket.alreadyAssigned.unlockTicket null

forceUnlockTicket​

TestEscenarioResultado esperadoMocks
throws NotFoundException when the ticket does not exist for the tenantTicket 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 raceunlockTicket devuelve null.Rechaza con ticket.alreadyAssigned.unlockTicket null

ticket-lock guards​

TestEscenarioResultado esperadoMocks
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​

TestEscenarioResultado esperadoMocks
already-assigned ticket: returns null immediately WITHOUT consulting tenant settings at allEl ticket ya tiene UserId.Devuelve null y no llama a getOrCreate.TenantSettingsService
REGRESSION: businessHours.enabled=false (default, no TenantSettings previously created) — assigns exactly as beforeHorario 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 onlineTicket 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 messageHorario 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 messageHorario 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 messageFuera 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 messageFuera 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 assignmentoutOfHoursNotifiedAt 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 messagemarkOutOfHoursNotified 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 outOfHoursNotifiedAtHorario 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​

TestEscenarioResultado esperadoMocks
sets the ticket status to RESOLVED via the repositoryapply con source: 'manual'.updateStatus(ticket._id, RESOLVED).updateStatus
creates and broadcasts a system message naming the actor for a manual resolutionResolució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 jobsource: 'inactivity' sin actor.El cuerpo es Ticket resuelto automáticamente por System.Message.create
defaults the actor name to "System" when none is providedResolució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 ticketResolución por inactividad.broadcastTicketStatusChanged(tenantId, ticketId, 'resolved').broadcastTicketStatusChanged
returns the updated ticket from the repositoryResolución manual.Devuelve el ticket actualizado por el repositorio.updateStatus

CA6 — cancel pending scheduled messages​

TestEscenarioResultado esperadoMocks
cancels the ticket's pending scheduled messages with reason ticket_resolved, after updateStatus and before the system messageSe 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 resolutioncancelForTicket rechaza (redis down).apply resuelve con el ticket actualizado.cancelForTicket rechaza

farewell message​

TestEscenarioResultado esperadoMocks
does not say goodbye when useConnectionFarewell=falseuseConnectionFarewell: 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=trueTicket 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 textfarewellMessage solo con espacios.Solo se crea un mensaje (el de resolución).findById
does not say goodbye when the ticket has no connectionTicket sin connectionId.Solo se crea un mensaje.Message.create

CSAT trigger​

TestEscenarioResultado esperadoMocks
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 sendOnAutoResolveCSAT habilitado con sendOnAutoResolve: false, resolución manual.triggerCsat con triggeredBy: 'manual' y snapshots de agente y departamento.triggerCsat
sets expiresAt to 48h after requestedAtCSAT habilitado.expiresAt - requestedAt = 48 horas en ms.triggerCsat
snapshots null agent/department when the ticket has noneTicket sin agente ni departamento.agentIdSnapshot y departmentIdSnapshot son null.triggerCsat
sends csat.requestMessage as plain text via interpolate + Message.create + broadcastCSAT 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=trueInactividad con sendOnAutoResolve: false.No se llama a triggerCsat.triggerCsat
triggers csat on inactivity resolution when both csat.enabled=true and sendOnAutoResolve=trueInactividad con ambos flags en true.triggerCsat con triggeredBy: 'inactivity'.triggerCsat