Casos de prueba — WhatsApp
Documentación de los casos de prueba unitarios y de integración del módulo src/app/modules/whatsapp (canal WhatsApp QR sobre Baileys). Fuentes:
auth/mongo-auth-state.spec.tscontrollers/whatsapp.controller.spec.tshelpers/baileys-logger.spec.ts,incoming-contact.helper.spec.ts,incoming-location.helper.spec.ts,incoming-media.helper.spec.ts,jid.helper.spec.ts,media.helper.spec.ts,quoted-message.helper.spec.tsservices/whatsapp-broadcast.wiring.spec.ts,whatsapp-connection.wiring.spec.ts,whatsapp-inbound.service.spec.ts,whatsapp-outbound.dispatcher.spec.ts,whatsapp-session.manager.spec.ts
Resumen: 14 archivos spec, 22 bloques describe (incluidos los anidados), 151 tests (it).
baileys es ESM-only y Jest (CJS) no puede cargarlo: todos los specs lo mockean con jest.mock('baileys', ...) antes de cualquier import. Por eso la columna Mocks no repite ese mock salvo cuando aporta (por ejemplo downloadMediaMessage).
mongo-auth-state.spec.ts
Test de integración con mongodb-memory-server (Mongo real en memoria) y la variable WHATSAPP_SESSION_ENCRYPTION_KEY definida.
makeMongoAuthState — integration (mongodb-memory-server)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
seeds creds via initAuthCreds() on first start | Primer arranque de una conexión sin sesión persistida | state.creds existe (con noiseKey, signedIdentityKey) y en Mongo se guarda el sobre cifrado iv/tag/ciphertext, sin texto plano de las llaves | baileys (initAuthCreds, BufferJSON), Mongo en memoria |
round-trips Buffers in creds through saveCreds() and a fresh load | Se llama saveCreds() y se recarga el estado con otra instancia | Las llaves siguen siendo Buffer y son idénticas a las originales | baileys, Mongo en memoria |
keys.set() upserts a value retrievable via keys.get() | Se guarda una llave session/peer-1 y se lee | keys.get devuelve un Buffer con los mismos bytes | Mongo en memoria |
keys.set() with a null value deletes the row | Se guarda una llave y luego se setea null | keys.get devuelve undefined y la fila ya no existe en la colección | Mongo en memoria |
isolates keys per connectionId | Dos conexiones guardan la misma clave shared con distinto valor | Cada conexión lee su propio valor | Mongo en memoria |
clear() removes the session doc and all its keys | Se llama clear() tras guardar una llave | Se elimina el documento de sesión y cuenta 0 llaves de la conexión | Mongo en memoria |
encryption at rest
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
throws a clear error when a stored key value is tampered with | Se altera un byte del ciphertext almacenado y se llama keys.get | Rechaza con error authentication tag verification failed | Mongo en memoria |
throws a clear error when stored creds are legacy plaintext-shaped (no iv/tag/ciphertext) | Documento heredado (pre-cifrado) escrito por el driver crudo con creds en texto plano | makeMongoAuthState rechaza con not a valid encrypted envelope | Mongo en memoria |
whatsapp.controller.spec.ts
WhatsappController
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
start(): 404s when the connection does not exist | findById devuelve null | NotFoundException | connectionRepository, translateService |
start(): 404s when the connection is not a whatsapp-qr channel | La conexión es de canal WEB | NotFoundException | connectionRepository |
start(): registers the requester BEFORE starting the session, then starts it | Conexión WhatsApp QR válida | El orden de llamadas es registerRequester y luego start; responde sessionState QR y el qr crudo | broadcastWiring, connectionRepository, sessionManager.start (spy) |
logout(): delegates to the session manager | Conexión válida | Llama sessionManager.logout(connectionId) | sessionManager.logout (spy) |
status(): falls back to the persisted whatsappConfig when no in-memory session entry exists | Sin entrada en memoria; whatsappConfig persistido en DISCONNECTED con phoneJid | Responde sessionState persistido, qr: null y phoneJid | connectionRepository |
status(): prefers the live in-memory session entry over the persisted fallback | Hay entrada en memoria CONNECTED y config persistida DISCONNECTED | Responde CONNECTED | sessionManager.getEntry (spy) |
baileys-logger.spec.ts
createBaileysLogger
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
forwards a plain message | warn('algo pasó') | Llama Logger.warn con el mismo mensaje | Logger.prototype.warn/error (spies) |
keeps the detail object alongside the message | warn({ trace: 'boom' }, 'failed to upload') | Se loguea failed to upload — {"trace":"boom"} | Logger spies |
unwraps an Error into its stack | error(err, 'upload failed') con un Error | El log contiene upload failed — Error: sin conexión | Logger spies |
survives a circular object instead of throwing | Se pasa un objeto con referencia circular | No lanza y se emite el warn | Logger spies |
drops info, debug and trace | Se llama info, debug y trace | No se emite nada (ni log ni warn) | Logger.log (spy) |
returns a child that keeps logging | child({ class: 'baileys' }).warn(...) | El hijo sigue logueando el mensaje | Logger spies |
degrades the expected 515 stream error to debug | error con fullErrorNode stream:error código 515 | No se emite error; se baja a debug | Logger.debug (spy) |
keeps a session conflict at error level | error con reasonNode conflict / replaced | Se mantiene en nivel error | Logger spies |
incoming-contact.helper.spec.ts
resolveIncomingContacts
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns undefined for a message without shared contacts | Mensaje conversation de texto | undefined | Ninguno |
reads the name and the WhatsApp id of a shared contact | contactMessage con vCard FN y TEL;waid=... | [{ name: 'WT pruebas', phone: '593963185056' }] | Ninguno |
falls back to the vCard full name and telephone when WhatsApp omits them | Sin displayName; vCard con FN;CHARSET y TEL plano | Usa nombre y teléfono de la vCard (573001234567) | Ninguno |
reads every contact of a contacts array message | contactsArrayMessage con dos contactos | Devuelve ambos contactos en orden | Ninguno |
skips contacts without a phone and returns undefined when none is usable | vCard sin TEL | undefined | Ninguno |
uses the phone as name when the contact has none | Sin nombre, solo waid | name igual al teléfono | Ninguno |
unwraps a contact shared in a chat with disappearing messages | Contacto dentro de ephemeralMessage | Desenvuelve y devuelve el contacto | Ninguno |
incoming-location.helper.spec.ts
resolveIncomingLocation
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns undefined for a message without a location | Mensaje de texto | undefined | Ninguno |
builds a maps url from the coordinates | locationMessage con latitud y longitud | URL https://maps.google.com/maps?q=<lat>%2C<lng>&z=17&hl | Ninguno |
treats a live location as a fixed point | liveLocationMessage | Misma URL de mapas con las coordenadas | Ninguno |
unwraps a location sent in a chat with disappearing messages | Ubicación dentro de ephemeralMessage | URL de mapas correcta | Ninguno |
returns undefined when the coordinates are missing | locationMessage solo con name | undefined | Ninguno |
keeps a zero coordinate | Latitud y longitud igual a 0 | URL con q=0%2C0 (el cero no se descarta) | Ninguno |
incoming-media.helper.spec.ts
resolveIncomingMedia
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns undefined for a plain text message | Mensaje conversation | undefined | Ninguno |
maps an image with its caption | imageMessage jpeg con caption | fileType IMAGE, mimetype, filename: 'imagen.jpg' y caption | Ninguno |
stores a sticker as an image | stickerMessage webp | fileType IMAGE y filename: 'sticker.webp' | Ninguno |
stores a round video (ptvMessage) as a regular video | ptvMessage mp4 | fileType VIDEO y filename: 'video.mp4' | Ninguno |
stores a regular video too | videoMessage con caption | fileType VIDEO y caption conservado | Ninguno |
unwraps an ephemeral message | Imagen dentro de ephemeralMessage | fileType IMAGE | Ninguno |
unwraps a view-once message | Video dentro de viewOnceMessageV2 | fileType VIDEO | Ninguno |
unwraps nested containers | ephemeralMessage que contiene viewOnceMessage con ptvMessage | fileType VIDEO (dos capas) | Ninguno |
names a voice note apart from a regular audio | audioMessage con ptt: true y otro sin ptt | nota-de-voz.ogg y audio.mp3 respectivamente | Ninguno |
keeps the original filename of a document | documentMessage pdf con fileName | fileType DOCUMENT, filename original y caption vacío | Ninguno |
accepts a document of an unknown type | Documento application/zip | Se acepta como DOCUMENT conservando el mimetype | Ninguno |
unwraps documentWithCaptionMessage | Documento envuelto con caption | Devuelve filename y caption del documento interno | Ninguno |
returns undefined for a reaction | reactionMessage | undefined | Ninguno |
jid.helper.spec.ts
isDirectContactJid
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
accepts a phone-number JID | 5511999999999@s.whatsapp.net | true | Ninguno |
accepts a linked-id JID | 123456789@lid | true | Ninguno |
rejects a group JID | ...@g.us | false | Ninguno |
rejects the status broadcast JID | status@broadcast | false | Ninguno |
rejects a broadcast-list JID | 1234567890@broadcast | false | Ninguno |
rejects a newsletter/channel JID | ...@newsletter | false | Ninguno |
rejects a bot JID | ...@bot | false | Ninguno |
rejects an unknown suffix | someone@community | false (lista blanca: un sufijo nuevo queda fuera sin cambiar código) | Ninguno |
rejects an absent JID | undefined, null y cadena vacía | false en los tres casos | Ninguno |
resolveContactJid
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
prefers the phone JID carried in remoteJidAlt over a lid remoteJid | remoteJid es @lid y remoteJidAlt es el JID de teléfono | Devuelve el JID de teléfono | Ninguno |
keeps remoteJid when it is already the phone JID | remoteJid ya es teléfono y el alt es @lid | Devuelve remoteJid | Ninguno |
falls back to remoteJid when there is no alt | Solo remoteJid | Devuelve remoteJid | Ninguno |
leaves a group JID untouched | remoteJid de grupo | Lo devuelve tal cual (lo descarta la lista blanca, no el resolvedor) | Ninguno |
returns the lid when no phone JID is available anywhere | Solo @lid, sin alternativa | Devuelve el @lid | Ninguno |
handles an empty key | Objeto vacío {} | undefined | Ninguno |
media.helper.spec.ts
buildWhatsappMediaContent
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
maps an image to Baileys image content | Tipo IMAGE | { image: { url }, mimetype } | Ninguno |
maps a video | Tipo VIDEO | { video: { url }, mimetype: 'video/mp4' } | Ninguno |
sends the precomputed thumbnail so the video shows a preview | Video con thumbnail | El contenido incluye jpegThumbnail (evita que Baileys invoque ffmpeg) | Ninguno |
omits the thumbnail key when the video has none | Video sin thumbnail | No existe la clave jpegThumbnail | Ninguno |
maps audio as a voice note | Tipo AUDIO | { audio: { url }, mimetype: 'audio/ogg; codecs=opus', ptt: true } | Ninguno |
sends the precomputed duration and waveform so Baileys never recomputes them | Audio con durationSeconds y waveform | Incluye seconds y waveform como Uint8Array | Ninguno |
omits both keys when the file has no audio metadata | Audio sin metadatos | No incluye seconds ni waveform | Ninguno |
maps a document with its filename | Tipo DOCUMENT pdf | { document: { url }, mimetype, fileName } | Ninguno |
falls back to document for an unrecognised type | Tipo desconocido (hologram) | Se envía como documento con mimetype y fileName | Ninguno |
attaches the caption to an image | Imagen con caption | Incluye caption | Ninguno |
attaches the caption to a document alongside its filename | Documento con caption | Incluye fileName y caption | Ninguno |
drops the caption on audio, which cannot carry one | Audio con caption | El caption se omite | Ninguno |
omits the caption key entirely when there is no text | Imagen sin caption | No existe la clave caption | Ninguno |
always hands the URL over, never a buffer | Video | El primer valor del contenido es { url } (Baileys hace el streaming) | Ninguno |
quoted-message.helper.spec.ts
resolveQuotedMessageId
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
returns the quoted message id of a text reply | extendedTextMessage con contextInfo.stanzaId | Devuelve wamid.PARENT | Ninguno |
returns the quoted message id of a media reply | imageMessage con contextInfo.stanzaId | Devuelve wamid.PHOTO_PARENT | Ninguno |
unwraps ephemeral containers before looking for the quote | Respuesta dentro de ephemeralMessage | Devuelve wamid.EPHEMERAL_PARENT | Ninguno |
returns undefined for a message that does not reply to anything | Texto simple y un mensaje solo reenviado (isForwarded) | undefined en ambos | Ninguno |
returns undefined when the message has no content | Mensaje sin message | undefined | Ninguno |
whatsapp-broadcast.wiring.spec.ts
WhatsappBroadcastWiringService
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
pushes a QR only to the room of the registered requester | Se registra el solicitante y el manager emite qr | broadcastWhatsappQr se llama con el id del solicitante y { connectionId, qr } | socketBroadcast, connectionRepository, WhatsappSessionManager real |
does not push a QR for a connection with no registered requester | Se emite qr sin solicitante registrado | No se hace broadcast | socketBroadcast |
broadcasts session status tenant-wide and persists it to the connection | El manager emite status CONNECTED con phoneJid | broadcastWhatsappSessionStatus al tenant y updateWhatsappConfig con sessionState y phoneJid | socketBroadcast, connectionRepository.updateWhatsappConfig |
forgets the requester once the session connects (no stale QR push on a later reconnect) | Se registra solicitante, llega status CONNECTED y luego otro qr | El qr posterior no se envía a nadie | socketBroadcast |
whatsapp-connection.wiring.spec.ts
WhatsappConnectionWiringService
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
starts a WhatsApp session when ConnectionService emits "created" | ConnectionService (EventEmitter) emite created | sessionManager.start(connectionId, tenantId) | sessionManager.start (spy), EventEmitter |
does not throw when the session fails to start (logged, not propagated) | start rechaza con boom | El emit no lanza (el error se registra, no se propaga) | sessionManager.start rechazado |
whatsapp-inbound.service.spec.ts
El spec mockea downloadMediaMessage de Baileys y usa un contactLock de paso directo (withContactLock). Los mensajes entrantes se simulan emitiendo inbound / ack en el WhatsappSessionManager.
WhatsappInboundService
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
resolves the contact by JID, resolves the ticket, and persists the inbound message | Mensaje de texto de un contacto nuevo | findOrCreate con externalId y username; resolveContactTicket con el texto; create con externalMessageId | contactRepository, ticketService, messageRepository, tenantSettingsService |
links a WhatsApp reply to the quoted message so the backoffice shows what the contact answered | Respuesta con stanzaId de un mensaje conocido | Se guarda con parentId y se hace broadcast con parent (cuerpo del mensaje citado) | messageRepository.findByExternalMessageId/findById, ticketRepository.findByIdAndTenant, socketBroadcast |
ingests a reply without parent when the quoted message is unknown to the CRM | Respuesta citando wamid.UNKNOWN | Se guarda sin parentId y no consulta findById | messageRepository |
dedupe (task 5.2): the same key.id delivered twice results in exactly one Message | Mismo key.id entregado dos veces | create se llama una sola vez | messageRepository.findByExternalMessageId |
skips messages with no jid/key.id and messages that are echoes of our own send (fromMe) | Mensaje sin remoteJid y otro fromMe: true | No se crea contacto ni mensaje | contactRepository, messageRepository |
ignores group, status and other non-1:1 JIDs without touching contacts or tickets | JIDs de grupo, status@broadcast y newsletter | No se toca contacto, ticket ni mensajes | contactRepository, ticketService, messageRepository |
identifies the contact by the phone JID from remoteJidAlt, not the lid | remoteJid @lid con remoteJidAlt de teléfono | findOrCreate con externalId de teléfono y phone: '5511999999999' | contactRepository |
still ingests a 1:1 message delivered in the same batch as a group one | Lote con un mensaje de grupo y uno 1:1 | Solo el 1:1 se procesa (1 contacto, 1 mensaje) | contactRepository, messageRepository |
skips messages carrying no text (reactions, deletions, protocol messages) | Reacción y conversation en blanco | No se crea contacto ni mensaje | contactRepository, messageRepository |
ack map (task 5.4): status 2/3/4 update Message.ack by externalMessageId as sent/delivered/read | Evento ack con estados 2, 3 y 4 | advanceAckByExternalMessageId con sent, delivered y read | messageRepository.advanceAckByExternalMessageId |
treats PLAYED (5) as read | Estado 5 | Se traduce a read | messageRepository |
broadcasts the ack change so the backoffice repaints the tick | advanceAck devuelve messageId/ticketId | broadcastMessageAckChanged(ticketId, tenantId, [messageId], 'delivered') | messageRepository, socketBroadcast |
emits nothing when the transition was refused | advanceAck devuelve null | No se emite broadcast | messageRepository, socketBroadcast |
ignores ack updates with an unmapped status code | Estado 1 sin mapeo | No se llama advanceAck | messageRepository |
WhatsappInboundService — avatar del contacto
El sock.profilePictureUrl y global.fetch están simulados; el storage guarda la copia propia.
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
downloads the picture and stores our own copy in the public prefix | Contacto sin foto sincronizada y URL válida | Sube a avatars/<tenant>/<contacto>.jpg y actualiza profilePic, profilePicSourceUrl, profilePicSyncedAt | sock.profilePictureUrl, fetch, storageService.uploadPublic, contactRepository.update |
marks the attempt even when the contact has no picture or restricted it | profilePictureUrl rechaza (forbidden) | No sube nada; actualiza solo profilePicSyncedAt | sock.profilePictureUrl rechazado, storageService |
does not re-download while the copy is fresh | profilePicSyncedAt reciente | No consulta la foto ni sube | sock.profilePictureUrl, storageService |
skips the download when the source URL has not changed | TTL vencido (30 días) pero misma URL de origen | No hace fetch ni sube | fetch, storageService |
re-downloads once the TTL passed and the picture changed | TTL vencido y URL de origen distinta | Se sube la nueva copia | sock.profilePictureUrl, storageService |
ingests the message even when the avatar sync blows up | Fallan profilePictureUrl y contactRepository.update | El mensaje se guarda igualmente | sock, contactRepository.update rechazado |
WhatsappInboundService — adjuntos entrantes
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
downloads an incoming image and persists it as a media message | imageMessage jpeg | storeIncoming con mimetype y type: image; mensaje messageType: 'media', body: 'imagen.jpg' | downloadMediaMessage, fileService.storeIncoming |
persists the caption apart from the filename | Imagen con caption | body es el nombre de archivo y caption va separado | downloadMediaMessage, fileService |
emits new-file-message with a freshly signed url | Imagen entrante | broadcastNewFileMessage con URL firmada nueva y no se usa broadcastNewMessage | storageService.getSignedUrl, socketBroadcast |
skips the message when the download fails | downloadMediaMessage rechaza (media expired) | No se crea el mensaje | downloadMediaMessage rechazado |
still ingests a plain text message as chat | Texto simple | messageType: 'chat' sin storeIncoming | fileService |
ingests a view-once message that arrives with no content at all | isViewOnce: true y message: {} | No descarga; guarda chat con aviso WHATSAPP_VIEW_ONCE_NOTICE y viewOnce: true | downloadMediaMessage, fileService |
does not download an attachment that comes flagged as view-once | Imagen marcada isViewOnce | No descarga y se guarda sin MediaId | downloadMediaMessage |
emits the view-once notice over new_message, not new-file-message | Aviso de "ver una vez" | broadcastNewMessage con viewOnce: true; no se emite new-file-message | socketBroadcast |
leaves a regular image without the view-once flag | Imagen normal | El mensaje no incluye viewOnce: true | messageRepository |
persists a location as its own message type, with the maps url | locationMessage con coordenadas | messageType: 'location', body con URL de mapas, sin descarga; broadcast con messageType: 'location' | messageRepository, socketBroadcast |
persists a shared contact card with its name and phone | contactMessage con vCard | messageType: 'contact', body con el nombre y sharedContacts con nombre y teléfono; también en el broadcast | messageRepository, socketBroadcast |
does not download a redelivered message | findByExternalMessageId devuelve un mensaje existente | No se descarga el adjunto | messageRepository, downloadMediaMessage |
whatsapp-outbound.dispatcher.spec.ts
WhatsappOutboundDispatcher
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
registers itself for the whatsapp-qr channel on module init | Se ejecuta onModuleInit | channelType es WHATSAPP_QR y el registro lo resuelve para ese canal | OutboundDispatcherRegistry real |
no-ops when the ticket has no connectionId | Ticket sin connectionId | No consulta contactos | contactRepository |
task 5.5: connection not ready (no active socket) — message stays persisted, dispatch does not throw and does not try to send | No hay socket activo | dispatch resuelve sin error y no consulta contacto ni asigna externalMessageId | contactRepository, messageRepository |
sends the WhatsApp markup of a text message untouched | Cuerpo *Richard Garcia*: hola | sendMessage(jid, { text }) con el texto sin alterar | sock.sendMessage, contactRepository.findByMongoId |
sends via the active socket and attaches the resulting externalMessageId | Socket activo y envío correcto | Envía el texto y guarda externalMessageId wamid.OUT1 | sock.sendMessage, messageRepository.setExternalMessageId |
markRead
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
acknowledges the contact messages by rebuilt key | Dos mensajes con ids externos del contacto | readMessages con claves reconstruidas (remoteJid, id, fromMe: false) | sock.readMessages, messageRepository.findExternalMessageIdsByIds, contactRepository |
does nothing when none of the messages came from WhatsApp | No hay ids externos | No llama readMessages ni consulta contacto | messageRepository, contactRepository |
does not throw when the connection is down | Sin socket | Resuelve undefined sin consultar mensajes | messageRepository |
quoted replies
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
quotes the contact message the agent replied to, so the phone shows the reply | Mensaje con parentId de un mensaje del contacto | sendMessage con quoted (fromMe: false, id externo y texto del padre) | sock.sendMessage, messageRepository.findById |
marks the quote as ours when the agent replies to a message sent from the CRM | El padre es sendType: 'user' | La clave citada lleva fromMe: true | messageRepository.findById |
quotes the caption when the replied message was an attachment | El padre tiene caption | El mensaje citado usa el caption como conversation | messageRepository.findById |
sends without quote when the replied message never reached WhatsApp | El padre no tiene externalMessageId | Envía solo (jid, { text }), sin tercer argumento | messageRepository.findById |
ignores a replied message that belongs to another tenant | El padre pertenece a otro tenant | Se envía sin cita | messageRepository.findById |
does not look up a parent for a message that is not a reply | Mensaje sin parentId | No se llama findById | messageRepository |
attachments
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
sends the attachment as media, resolving a fresh signed URL | Mensaje con MediaId de un PDF | getSignedUrl(storageKey); envía document con mimetype y fileName; guarda externalMessageId | fileRepository.findByIdAndTenant, storageService.getSignedUrl, sock.sendMessage |
carries the caption through to the channel | Imagen con caption | Envía image con mimetype y caption | fileRepository, storageService, sock |
sends nothing when the referenced file is gone | El archivo ya no existe (null) | No se envía nada (evita mostrar un nombre de archivo como texto) | fileRepository |
still sends a plain text message as text | Mensaje sin MediaId | No consulta archivos y envía { text } | fileRepository, sock |
sends the WhatsApp markup of the caption untouched | Caption con marcado *...* | El caption llega sin modificar | fileRepository, storageService, sock |
whatsapp-session.manager.spec.ts
Usa un socket falso basado en EventEmitter, timers falsos de Jest y socketFactory/authStateFactory/qrRenderer inyectados.
WhatsappSessionManager
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
qr -> connected: emits qr then transitions to connected on connection open | Se inicia la sesión, llega qr y luego connection: open | Estado INIT, luego QR (emite qr con data URL) y después CONNECTED (emite status) | socketFactory, authStateFactory, qrRenderer |
wires creds.update to saveCreds | El socket emite creds.update | Se invoca saveCreds | authStateFactory |
loggedOut (401) close clears auth state, removes the entry, and does not reconnect | Cierre con statusCode 401 | Se ejecuta clear, se elimina la entrada y no se reconecta (1 sola llamada a socketFactory) | socketFactory, timers falsos |
creates the socket with an explicit logger and history sync disabled | start() | La config tiene syncFullHistory: false, markOnlineOnConnect: false y un logger con warn | socketFactory |
a transient close (non-401) schedules a bounded reconnect that opens a new socket | Cierre con código 408 | Estado DISCONNECTED sin clear; tras BASE_DELAY_MS se crea un segundo socket | socketFactory, timers falsos |
start() is idempotent while the entry is non-terminal (no double socket) | start() dos veces seguidas | Devuelve la misma entrada y crea un solo socket y un solo auth state | socketFactory, authStateFactory |
caps reconnect attempts at WHATSAPP_RECONNECT.MAX_ATTEMPTS | Cierres repetidos (MAX_ATTEMPTS + 2) | Total de sockets limitado a 1 + MAX_ATTEMPTS | socketFactory, timers falsos |
logout() ends the socket, clears auth state, and marks the entry terminal | logout() | Llama sock.logout, clear y elimina la entrada | fakeSocket.logout, clear |
onModuleInit() — staggered rehydrate on boot
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
queries persisted sessions with non-null creds to decide what to rehydrate | onModuleInit | Consulta find({ creds: { $ne: null } }) | sessionModel.find |
staggers restarts by WHATSAPP_REHYDRATE_STAGGER_MS instead of rehydrating every candidate at once | Tres sesiones persistidas | Nada se ejecuta de forma síncrona; arrancan una a una separadas por WHATSAPP_REHYDRATE_STAGGER_MS | sessionModel.find, start (spy), timers falsos |
rehydrates a session with valid persisted auth straight to connected, without requesting a new QR | Una sesión con credenciales válidas | Pasa de INIT a CONNECTED al abrir, sin emitir qr | sessionModel.find, socketFactory |
a failure rehydrating one session does not stop the others from being attempted | La primera sesión falla con boom | Se intenta la segunda y se registra Rehydrate failed for connection conn-fail | start (spy), Logger.warn |