Skip to main content

Servicios y helpers del core

Casos de prueba de src/app/core/services/** y src/app/core/helpers/**: 13 specs y 90 tests (it()), sin xit, fdescribe ni specs vacíos.

Los servicios HTTP se prueban con TestBed + HttpTestingController (HttpClientTestingModule o provideHttpClient() + provideHttpClientTesting()) y httpMock.verify() en afterEach. Los helpers y constantes se prueban como funciones/valores puros. Los títulos de it() se mantienen verbatim (algunos en inglés).

Helpers​

src/app/core/helpers/global/presence.helper.spec.ts​

  • SUT: isPresenceFresh y la constante PRESENCE_STALE_MS (presence.helper.ts).
  • Mocking: ninguno; función pura con objetos TicketContact armados en el test y lastSeen calculado con Date.now().

isPresenceFresh​

it()EscenarioResultado esperadoMocks/spies
is fresh at 0ms elapsed (just connected)Dado un contacto isOnline: true con lastSeen de hace 0 s, cuando se evalúa la frescura, entonces es fresco.trueNinguno
is fresh at 89s elapsed (within the 90s threshold)Dado isOnline: true y lastSeen de hace 89 s (dentro del umbral de 90 s).trueNinguno
is stale at 91s elapsed (past the 90s threshold)Dado isOnline: true y lastSeen de hace 91 s (pasado el umbral): un isOnline viejo se degrada a offline.falseNinguno
is stale exactly at the PRESENCE_STALE_MS boundary (strict less-than)Dado lastSeen exactamente PRESENCE_STALE_MS atrás (borde, comparación estricta).falseNinguno
is offline when lastSeen is missing, even if isOnline is trueDado isOnline: true sin lastSeen.falseNinguno
is offline when isOnline is explicitly false, regardless of a fresh lastSeenDado isOnline: false con lastSeen de hace 1 s.falseNinguno
is offline when isOnline is undefinedDado un contacto sin isOnline y con lastSeen reciente.falseNinguno
is offline when the contact itself is null/undefinedCuando se pasa null y luego undefined en lugar del contacto.Ambas llamadas devuelven falseNinguno
PRESENCE_STALE_MS is 90 seconds (>= 3x backend PRESENCE_HEARTBEAT_MS=30_000, per design D4)Cuando se lee la constante.Es 90_000Ninguno

Tests en este archivo: 9.

src/app/core/helpers/ui/nav.constant.spec.ts​

  • SUT: constante MENU_ITEMS (nav.constant.ts), el árbol de menú lateral.
  • Mocking: ninguno; se buscan ítems por id/title y se inspeccionan permission, children, path, type y hideForUserTypes.
it()EscenarioResultado esperadoMocks/spies
should NOT use static menu slugs as permission values on gated leaf itemsDado el ítem contacts, el permiso no debe ser el slug del menú.permission distinto de 'contacts' y igual a 'CONTACT_READ'Ninguno
should map each top-level leaf item to its real READ-tier acronymDados los ítems hoja connections, users, labels, quick-responses, departments y settings.Cada uno usa su acrónimo READ: CONNECTION_READ, USER_READ, LABEL_READ, QUICK_RESPONSE_READ, DEPARTMENT_READ, SETTINGS_READNinguno
should leave the dashboard item ungated (undefined permission)Dado el ítem dashboard.permission es undefinedNinguno
should gate the access-control container on ACCESS_CONTROL_READDado el contenedor access-control: el gate del padre no se deduce de los hijos; sin él quedaría un submenú vacío.permission es 'ACCESS_CONTROL_READ'Ninguno
should leave the chats container ungated (has children without gate)Dado el contenedor chats, que tiene hijos sin gate.permission es undefinedNinguno
should gate each access-control child on ACCESS_CONTROL_READDados los hijos ac-roles, ac-permissions, ac-role-permissions, ac-modules y ac-permissions-builder.Todos tienen permission 'ACCESS_CONTROL_READ'Ninguno
should gate the ticket-chat child on TICKET_READ and leave exempt chat children ungatedDados los hijos de chats: el de título tickets.chats.title, internal-chats y starred-messages.Ticket chat con 'TICKET_READ'; internal-chats y starred-messages sin permiso (undefined)Ninguno

MENU_ITEMS — settings entry​

it()EscenarioResultado esperadoMocks/spies
should include a settings nav item pointing to PAGE_URLS.SETTINGSDado el ítem settings.Definido, path 'settings' y type 'link'Ninguno
should gate the settings item by permission instead of user typeDado el ítem settings.permission 'SETTINGS_READ' y hideForUserTypes undefinedNinguno
should NOT declare hideForUserTypes on unrelated items (e.g. departments)Dado el ítem departments.hideForUserTypes es undefinedNinguno

Tests en este archivo: 10.

Servicios API​

src/app/core/services/api/connection.service.spec.ts​

  • SUT: ConnectionService (endpoints de sesión de WhatsApp).
  • Mocking: TestBed con HttpClientTestingModule; ToastrNotificationService reemplazado por jasmine.createSpyObj('ToastrNotificationService', ['showNotification']).

ConnectionService — whatsapp session endpoints​

it()EscenarioResultado esperadoMocks/spies
startWhatsappSession should POST /connections/whatsapp/:id/start and unwrap dataCuando se inicia la sesión de conn-1 y el servidor responde { message, data } con estado qr.POST a /connections/whatsapp/conn-1/start; el observable emite solo dataHttpTestingController, spy de ToastrNotificationService
logoutWhatsappSession should DELETE /connections/whatsapp/:id/logoutCuando se cierra la sesión y el servidor responde data: null.DELETE a /connections/whatsapp/conn-1/logout; emite undefinedHttpTestingController, spy de ToastrNotificationService
getWhatsappSessionStatus should GET /connections/whatsapp/:id/status and unwrap dataCuando se consulta el estado y el servidor responde connected con phoneJid.GET a /connections/whatsapp/conn-1/status; emite dataHttpTestingController, spy de ToastrNotificationService

Tests en este archivo: 3.

src/app/core/services/api/message.service.spec.ts​

  • SUT: MessageService.getContactNotes() (el resto de métodos del servicio no tiene cobertura, según el comentario del spec).
  • Mocking: TestBed con HttpClientTestingModule; matcher por función en expectOne para validar URL y params.

MessageService > getContactNotes​

it()EscenarioResultado esperadoMocks/spies
issues GET /messages/:contactId with messageType=note, page and limit paramsCuando se piden las notas de contact-1 con página 1 y límite 10.GET a /messages/contact-1 con messageType=note, page=1, limit=10HttpTestingController
defaults page to 1 and limit to 10 when not providedCuando se invoca solo con el contactId.Se envían page=1, limit=10 y messageType=noteHttpTestingController
reverses the server-returned ascending page into newest-first orderDado que el backend devuelve la página ascendente por createdAt (n-1, n-2).El resultado queda newest-first: ['n-2', 'n-1']HttpTestingController
returns an empty results array and preserves meta when data.results is undefinedDado que la respuesta trae meta pero no data.results.results es [] y meta se conservaHttpTestingController
does not throw and returns empty results/undefined meta when data itself is missingDado que la respuesta no trae data.No lanza error; results es [] y meta es undefinedHttpTestingController

Tests en este archivo: 5.

src/app/core/services/api/scheduled-message.service.spec.ts​

  • SUT: ScheduledMessageService.
  • Mocking: TestBed con provideHttpClient() + provideHttpClientTesting(); fixture ScheduledMessage de ejemplo.

ScheduledMessageService > create​

it()EscenarioResultado esperadoMocks/spies
POSTs create with the payloadCuando se crea un mensaje programado con ticketId, body, scheduledAt y messageType.POST a /scheduled-messages/create con el payload íntegro como bodyHttpTestingController

ScheduledMessageService > getMine​

it()EscenarioResultado esperadoMocks/spies
GETs mine with status/page/limit query paramsCuando se piden los propios con status: 'pending', página 1, límite 20.GET a /scheduled-messages/mine con params status, page=1 y limit=20HttpTestingController

ScheduledMessageService > getByTicket​

it()EscenarioResultado esperadoMocks/spies
GETs by-ticket/:ticketIdCuando se consultan los programados del ticket tk1.GET a /scheduled-messages/by-ticket/tk1HttpTestingController

ScheduledMessageService > update​

it()EscenarioResultado esperadoMocks/spies
PATCHes update/:id with body/scheduledAtCuando se actualiza sm1 solo con { body: 'edited' }.PATCH a /scheduled-messages/update/sm1 con body { body: 'edited' }HttpTestingController

ScheduledMessageService > cancel​

it()EscenarioResultado esperadoMocks/spies
PATCHes cancel/:id with an empty bodyCuando se cancela sm1.PATCH a /scheduled-messages/cancel/sm1 con body {}HttpTestingController

Tests en este archivo: 5.

src/app/core/services/api/settings.service.spec.ts​

  • SUT: SettingsService.
  • Mocking: TestBed con HttpClientTestingModule; fixture TenantSettings completo. La respuesta GET simula el reshape del ResponseInterceptor global (data.results).

SettingsService​

it()EscenarioResultado esperadoMocks/spies
getSettings should GET /settings/get-all and unwrap data.results[0]Cuando el servidor responde data: { results: [tenantSettings], meta: {} }.GET a /settings/get-all; emite el primer elemento de resultsHttpTestingController
updateGeneral should PATCH /settings/general with the payloadCuando se envía { language: 'en', timezone: 'UTC' }.PATCH a /settings/general con ese body; emite los settings devueltosHttpTestingController
updateSchedule should PATCH /settings/attention/schedule with the payloadCuando se envían los businessHours.PATCH a /settings/attention/schedule con el payloadHttpTestingController
updateAutoResolution should PATCH /settings/attention/auto-resolution with the payloadCuando se envía la configuración de autoResolution.PATCH a /settings/attention/auto-resolution con el payloadHttpTestingController
updateCsat should PATCH /settings/attention/csat with the payloadCuando se envía la configuración de csat.PATCH a /settings/attention/csat con el payloadHttpTestingController

Tests en este archivo: 5.

src/app/core/services/api/starred-message.service.spec.ts​

  • SUT: StarredMessageService (incluye el stream changed$).
  • Mocking: TestBed con HttpClientTestingModule; se suscribe a changed$ para observar las señales emitidas.

StarredMessageService​

it()EscenarioResultado esperadoMocks/spies
toggle should POST /starred-messages/toggle/:messageId with { messageType } and unwrap dataCuando se hace toggle de msg-1 tipo contact.POST a /starred-messages/toggle/msg-1 con body { messageType: 'contact' }; emite { starred: true }HttpTestingController
toggle should announce the change on changed$ once the server confirms itDada una suscripción a changed$, cuando el servidor confirma el toggle (las vistas de destacados no participan del toggle y necesitan la señal).changed$ emite { messageId: 'msg-1', messageType: 'contact', starred: true }HttpTestingController
toggle should carry starred:false through when the message was un-starredCuando el servidor responde starred: false para msg-2 tipo internal.El cambio emitido en changed$ trae starred en falseHttpTestingController
toggle should stay silent when the request fails, so nothing refreshes on a rejected changeCuando la petición falla con 500.changed$ no emite nada ([]); el error se absorbe en el testHttpTestingController
getIdsByScope should GET /starred-messages/ids with contactId param and unwrap data.idsCuando se pide con { contactId: 'contact-1' }.GET con param contactId; emite ['msg-1', 'msg-2']HttpTestingController
getIdsByScope should default to no scope params when called without argumentsCuando se invoca sin argumentos.No se envían params de query; emite []HttpTestingController
findByContact should GET /starred-messages/by-contact/:contactId with page/limit and unwrap dataCuando se pide contact-1 página 1 límite 20.GET a /starred-messages/by-contact/contact-1 con page=1 y limit=20; emite dataHttpTestingController
findByInternalConversation should GET /starred-messages/by-internal-conversation/:conversationIdCuando se pide la conversación conv-1.GET a la ruta de by-internal-conversation; emite dataHttpTestingController
findGlobal should GET /starred-messages with optional messageType filterCuando se pide página 1, límite 20, tipo internal.GET a /starred-messages con param messageType=internal; emite dataHttpTestingController
fetchMessageWindow should GET /messages/:id/window for contact messagesCuando se pide la ventana de msg-1 tipo contact.GET a /messages/msg-1/window; emite el resultadoHttpTestingController
fetchMessageWindow should include radius when providedCuando se pasa radius 30.La petición lleva radius=30; emite { results: [], anchorId: 'msg-1' }HttpTestingController
fetchMessageWindow should GET the nested internal-conversations route for internal messagesCuando se pide msg-2 tipo internal con conv-1.GET a /internal-conversations/conv-1/messages/msg-2/windowHttpTestingController
fetchMessageWindow should error out synchronously when messageType is internal without a conversationIdCaso de fallo: tipo internal sin conversationId.El observable emite error sin next; no se hace ninguna petición HTTP (expectNone)HttpTestingController, done

Tests en este archivo: 13.

src/app/core/services/api/system-module.service.spec.ts​

  • SUT: SystemModuleService.
  • Mocking: TestBed con HttpClientTestingModule; ToastrNotificationService como jasmine.createSpyObj.

SystemModuleService​

it()EscenarioResultado esperadoMocks/spies
should be createdDada la inyección con sus dependencias mockeadas.El servicio existe (toBeTruthy)HttpTestingController, spy de ToastrNotificationService

Tests en este archivo: 1.

src/app/core/services/api/ticket.service.spec.ts​

  • SUT: TicketService (pospuesto y bloqueo de tickets).
  • Mocking: TestBed con provideHttpClient() + provideHttpClientTesting().

TicketService > postponeTicket​

it()EscenarioResultado esperadoMocks/spies
PATCHes postpone/:id with postponedUntil in the bodyCuando se pospone t1 hasta 2026-07-06T18:30:00.000Z.PATCH a /tickets/postpone/t1 con body { postponedUntil }HttpTestingController

TicketService > cancelPostponeTicket​

it()EscenarioResultado esperadoMocks/spies
PATCHes cancel-postpone/:id with an empty bodyCuando se cancela el pospuesto de t1.PATCH a /tickets/cancel-postpone/t1 con body {}HttpTestingController

TicketService > lockTicket​

it()EscenarioResultado esperadoMocks/spies
PATCHes lock/:id with an empty bodyCuando se bloquea t1.PATCH a /tickets/lock/t1 con body {}HttpTestingController

TicketService > unlockTicket​

it()EscenarioResultado esperadoMocks/spies
PATCHes unlock/:id with an empty bodyCuando se desbloquea t1.PATCH a /tickets/unlock/t1 con body {}HttpTestingController

TicketService > forceUnlockTicket​

it()EscenarioResultado esperadoMocks/spies
PATCHes force-unlock/:id with an empty bodyCuando se fuerza el desbloqueo de t1.PATCH a /tickets/force-unlock/t1 con body {}HttpTestingController

TicketService > getLockedTickets​

it()EscenarioResultado esperadoMocks/spies
GETs get-all with lockedOnly=true merged into the filterCuando se pide con filtro { page: 1, limit: 10 }.GET a /tickets/get-all con lockedOnly=true, page=1 y limit=10HttpTestingController

Tests en este archivo: 6.

src/app/core/services/api/user.service.spec.ts​

  • SUT: UserService.updateMyAvatar.
  • Mocking: TestBed con provideHttpClient() + provideHttpClientTesting(); ToastrNotificationService como jasmine.createSpyObj.

UserService > updateMyAvatar​

it()EscenarioResultado esperadoMocks/spies
PATCHes me/avatar with a FormData body containing the file under "file"Cuando se sube un File photo.jpg (image/jpeg).PATCH a /users/me/avatar; el body es FormData y get('file') devuelve el mismo archivoHttpTestingController, spy de ToastrNotificationService

Tests en este archivo: 1.

Servicios UI​

src/app/core/services/ui/chat-socket.service.spec.ts​

  • SUT: ChatSocketService (listeners de socket.io).
  • Mocking: TestBed con ChatSocketService y Store reemplazado por jasmine.createSpyObj('Store', ['dispatch', 'select']) (select devuelve of('es')). El método privado createSocket se sustituye con spyOn<any>(service, 'createSocket') por un socket falso que registra cada on(event, handler) en un mapa handlers, para invocar los handlers como lo haría el servidor. Luego se llama service.connect('user-1'). Cada describe repite este setup.

ChatSocketService — ticket_deleted​

it()EscenarioResultado esperadoMocks/spies
dispatches ticketDeletedFromSocket and emits ticketDeleted$ when the server sends ticket_deletedCuando el handler de TICKET_DELETED recibe { ticketId: 't1' }.Se despacha TicketActions.ticketDeletedFromSocket y ticketDeleted$ emite { ticketId: 't1' }Socket falso, spy de Store
carries the ticketId through for a different id (triangulation, not a hardcoded echo)Cuando llega otro id (ticket-xyz-42).Acción y emisión llevan ese id, no un valor fijoSocket falso, spy de Store

ChatSocketService — contact_presence_changed​

it()EscenarioResultado esperadoMocks/spies
dispatches contactPresenceChangedFromSocket when the server sends contact_presence_changedCuando llega presencia online con lastSeen y disconnectReason: null.Se despacha contactPresenceChangedFromSocket con el mismo payloadSocket falso, spy de Store
carries a disconnect classification through on an offline transitionCuando llega isOnline: false con disconnectReason: 'clean'.La acción despachada conserva disconnectReason: 'clean'Socket falso, spy de Store

ChatSocketService — ticket_lock_changed​

it()EscenarioResultado esperadoMocks/spies
dispatches ticketLockChangedFromSocket when the server sends ticket_lock_changed (lock)Cuando llega un bloqueo con lockedBy y lockedByName.Se despacha ticketLockChangedFromSocket con esos datosSocket falso, spy de Store
dispatches ticketLockChangedFromSocket with a null lockedBy on unlockCaso de desbloqueo: lockedBy y lockedByName en null.Se despacha la acción con ambos en nullSocket falso, spy de Store

ChatSocketService — scheduled message events​

it()EscenarioResultado esperadoMocks/spies
dispatches sentFromSocket on scheduled_message_sentCuando llega SCHEDULED_MESSAGE_SENT con id, ticketId, messageId.Se despacha ScheduledMessageActions.sentFromSocketSocket falso, spy de Store
dispatches cancelledFromSocket on scheduled_message_cancelledCuando llega con reason: 'ticket_resolved'.Se despacha cancelledFromSocket con id y reasonSocket falso, spy de Store
dispatches failedFromSocket on scheduled_message_failedCuando llega con error: 'boom'.Se despacha failedFromSocket con id y errorSocket falso, spy de Store
dispatches createdFromSocket on scheduled_message_createdCuando llega { item }.Se despacha createdFromSocket({ item })Socket falso, spy de Store
dispatches updatedFromSocket on scheduled_message_updatedCuando llega { item }.Se despacha updatedFromSocket({ item })Socket falso, spy de Store

ChatSocketService — whatsapp_qr / whatsapp_session_status​

it()EscenarioResultado esperadoMocks/spies
emits whatsappQr$ with the server-rendered data URL when the server sends whatsapp_qrCuando llega WHATSAPP_QR con connectionId y qr (data URL).whatsappQr$ emite el payload y store.dispatch no se llamaSocket falso, spy de Store
emits whatsappSessionStatus$ when the server sends whatsapp_session_statusCuando llega WHATSAPP_SESSION_STATUS con estado connected y phoneJid.whatsappSessionStatus$ emite el payloadSocket falso, spy de Store

ChatSocketService — reconnection​

Usa jasmine.clock().install() en beforeEach y uninstall() en afterEach; el socket falso añade connect.

it()EscenarioResultado esperadoMocks/spies
reconnects itself when the server closes the connection (io server disconnect)Cuando llega DISCONNECT con 'io server disconnect'.connect no se llama de inmediato; tras 1000 ms síSocket falso, jasmine.clock()
reports "connecting" while the server-initiated retry is pendingCuando el servidor cierra la conexión y se observa connectionStatus$.El último estado emitido es 'connecting'Socket falso, spy de Store
does not retry a transport-level drop: socket.io-client already reconnects thoseCuando DISCONNECT llega con 'ping timeout' y pasan 10 s.connect nunca se llamaSocket falso, jasmine.clock()
gives up after the retry budget instead of hammering the server foreverCuando el servidor desconecta 5 veces seguidas (espera intento * 1000 ms) y luego una sexta vez, con 60 s de espera.connect se llamó 5 veces y sigue en 5 tras la sextaSocket falso, jasmine.clock()
resets the retry budget once a reconnection authenticatesTras agotar 5 reintentos, llega AUTHENTICATED y luego otra desconexión del servidor.El presupuesto se reinicia: connect suma 6 llamadasSocket falso, jasmine.clock()
emits reconnected$ only on re-authentications, never on the first oneCuando llega AUTHENTICATED dos veces.reconnected$ emite 0 veces tras la primera y 1 tras la segundaSocket falso
reuses the existing socket instead of building a second one with duplicate listenersDado un socket existente con connected = false, cuando se llama connect('user-1') otra vez.createSocket se llamó solo 1 vez y fakeSocket.connect se invocaSocket falso, spy de createSocket
cancels a pending server-disconnect retry when the caller disconnectsDada una desconexión del servidor con reintento pendiente, cuando se llama service.disconnect() y pasan 5 s.connect nunca se llamaSocket falso, jasmine.clock()
surfaces a failed handshake as "connecting" rather than staying silentCaso de fallo: CONNECT_ERROR con Error('xhr poll error').El último estado de connectionStatus$ es 'connecting'Socket falso

Tests en este archivo: 22.

src/app/core/services/ui/my-permissions.service.spec.ts​

  • SUT: MyPermissionsService (hasPermission, hasAnyPermission, caché con TTL).
  • Mocking: TestBed; GlobalService reemplazado por { profile: ReplaySubject<User> } y UserService por jasmine.createSpyObj('UserService', ['getMeUser']). Helpers locales emitProfile y meResponse.

MyPermissionsService​

it()EscenarioResultado esperadoMocks/spies
should resolve true when the acronym is present in the profile-provided setDado un perfil con CONTACT_READ y CONTACT_UPDATE, cuando se consulta CONTACT_READ.Emite trueReplaySubject de perfil, spy de UserService
should resolve false when the acronym is missing from the resolved setDado un perfil solo con CONTACT_READ, cuando se consulta CONTACT_DELETE.Emite falseReplaySubject de perfil, spy de UserService
should resolve false when the profile has no permissions yetDado un perfil con permissions undefined.Emite falseReplaySubject de perfil, spy de UserService
should NOT re-fetch getMe when the cache is fresh (within TTL)Dado un caché vigente.getMeUser no se llamaReplaySubject de perfil, spy de UserService
should re-fetch getMe and resolve against the refreshed set once the cache is staleDado que el reloj avanza 10 001 ms (pasado el TTL) y getMeUser devuelve CONTACT_READ y CONTACT_DELETE.getMeUser se llama 1 vez y CONTACT_DELETE resuelve truespyOn(Date, 'now'), spy de UserService
should resolve any-of membership via hasAnyPermissionDado un perfil con CONTACT_READ, cuando se consulta ['CONTACT_DELETE', 'CONTACT_READ'].Emite trueReplaySubject de perfil, spy de UserService

Tests en este archivo: 6.

src/app/core/services/ui/user-avatars.service.spec.ts​

  • SUT: UserAvatarsService.getAvatarUrl.
  • Mocking: sin TestBed; instancia directa con new UserAvatarsService() y usuarios simulados con as User.

UserAvatarsService > getAvatarUrl​

it()EscenarioResultado esperadoMocks/spies
returns the avatarUrl when the user has one setDado un usuario con avatarUrl.Devuelve esa URLNinguno
returns undefined when avatarUrl is absent (caller falls back to initials)Dado un usuario sin avatarUrl.undefined (el llamador usa iniciales)Ninguno
returns undefined when avatarUrl is an empty stringDado avatarUrl: ''.undefinedNinguno
returns undefined when user itself is null/undefinedCuando se pasa null y luego undefined.Ambas llamadas devuelven undefinedNinguno

Tests en este archivo: 4.

Total​

13 specs, 90 tests en core/services y core/helpers (helpers: 19; servicios API: 39; servicios UI: 32).