Casos de prueba: Meta entrante
Casos de prueba del flujo entrante de Meta (Instagram y Facebook Messenger): recepción del webhook, validación de firma, cola, normalización de eventos e ingesta de mensajes.
Specs fuente (bajo src/app/modules/meta):
services/meta-inbound.service.spec.tshelpers/meta-event.normalizer.spec.tscontrollers/meta-webhook.controller.spec.tsservices/meta-webhook-processor.service.spec.tsservices/meta-webhook-queue.service.spec.tshelpers/meta-signature.helper.spec.tshelpers/meta-attachment.helper.spec.ts
Resumen: 7 archivos spec, 26 bloques describe y 104 casos it() (cada it.each cuenta como un caso).
meta-inbound.service.spec.ts
Test de integración con MongoDB en memoria (mongodb-memory-server). Repositorios reales de conexión, contacto, mensaje y ticket; mocks de Graph, ticket service, sockets, storage, file service, downloader y lock.
MetaInboundService (integration)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
ingests a text DM: contact from the profile, ticket, message with its mid | Llega un DM de texto de un contacto nuevo | Se crea el contacto con el nombre del perfil (originChannelType instagram), el mensaje con su mid (sendType contact, ack sent), el ticket ligado a la conexión, y se envían saludo y broadcasts de ticket y mensaje una vez | Graph getUserProfile, ticket service, socket broadcast |
runs the whole message inside the contact lock | Se procesa un mensaje | withContactLock se invoca con tenant, ig:IGSID y una función | lock |
stores the avatar in our storage and never exposes the Meta CDN url as profilePic | Perfil con foto en el CDN de Meta | La imagen se sube a avatars/tenant/contactId.jpg; profilePic es la URL propia y profilePicSourceUrl guarda la de Meta | storage uploadPublic, fetch global |
asks Graph for the profile only the first time | Dos mensajes consecutivos del mismo contacto | Graph se consulta una vez; hay 1 contacto, 1 ticket y 2 mensajes | Graph |
ignores a redelivered mid | El mismo evento se entrega dos veces | Solo se guarda un mensaje (idempotencia por mid) | Graph |
links a reply to the quoted message | Segundo mensaje con replyToMid del primero | El parentId de la respuesta apunta al mensaje original | Graph |
does nothing for an account without connection | Evento de una cuenta sin conexión | No se crean contactos ni mensajes | Graph |
profile error %i: anonymous contact, the message still gets in (it.each 230, 9010, 100) | Graph rechaza el perfil con MetaGraphError de código 230, 9010 o 100 | Contacto anónimo (Instagram ······123456), el mensaje se guarda y authErrorAt sigue en null | Graph rechazado |
error 190: marks the connection disconnected but the message still gets in | Graph responde error 190 (token expirado) | authErrorAt es una fecha, la conexión queda DISCONNECTED y el mensaje igual se guarda | Graph rechazado |
falls back to the @username when the profile has no name | Perfil solo con username | El contacto se llama @juanap | Graph |
attachments
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
downloads the attachment into our storage and saves a media message | Evento con una imagen y sin texto | Se descarga la URL, se guarda con storeIncoming (imagen.jpg, image/jpeg), mensaje media con MediaId y un broadcast de archivo | downloader, file service, socket |
saves one message per attachment, keeping the original mid on the first | Dos imágenes en un evento | Mids mid.1 y mid.1#1; un solo ticket, un saludo y un broadcast de ticket creado | downloader, file service |
resumes a half-stored batch without duplicating what already got in | La segunda descarga falla con HTTP 503 y luego se reintenta | Primer intento lanza HTTP 503 con 1 mensaje guardado; el reintento completa sin duplicar (mid.1, mid.1#1) | downloader (falla una vez) |
uses the message text as caption of the first attachment | Texto más imagen | body es imagen.jpg y caption es el texto | downloader |
uses the title of a shared post as caption | Adjunto ig_post con título y sin texto | caption es el título del post | downloader |
saves a notice when the attachment is gone or too large | MetaMediaUnavailableError (HTTP 404) | Mensaje chat con el aviso "No se pudo recuperar el adjunto (imagen)"; no se llama a storeIncoming | downloader rechazado |
saves a notice for an attachment type it cannot download | Adjunto de tipo something_new | No se descarga nada y el cuerpo contiene (adjunto) | downloader |
rethrows a transient download failure so BullMQ retries the job | Fallo transitorio (TimeoutError) | Se relanza el error y no se guarda ningún mensaje | downloader rechazado |
Mensajes no soportados (raíz)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
saves a notice for a message Meta flags as unsupported | Evento con isUnsupported: true y sin texto | Cuerpo "[Instagram] Mensaje no soportado. Ábrelo en la app de Instagram." | Graph |
messages sent outside the CRM (echoes)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
never stores what an agent writes from the Instagram app | Evento echo | No se crea mensaje, contacto ni ticket | Graph |
ignores an echo carrying attachments too | Echo con adjunto | No se descarga y no se guarda mensaje | downloader |
read receipts
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
marks the read message and the earlier outbound ones, and tells the backoffice | Lectura sobre mid.out.2 con un saliente anterior | Ambos mensajes pasan a read y se emite broadcastMessageAckChanged con ambos ids | socket broadcast |
leaves messages sent after the read one as they were | Lectura de mid.out.1 con un mensaje posterior | El posterior sigue en sent | socket broadcast |
does nothing, and emits nothing, for a repeated receipt | El mismo recibo llega dos veces | La segunda vez no emite broadcast | socket broadcast |
ignores a receipt for a message the CRM never stored | Mid desconocido | No emite broadcast | socket broadcast |
ignores a receipt for an account without connection | Recibo de otra cuenta | No emite broadcast | socket broadcast |
deletions (still deferred)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
a deletion writes nothing | Evento message_deleted | No se escribe ningún mensaje | ninguno |
Facebook Messenger
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
ingests a message into a ticket of the page connection, with an fb contact | Mensaje de un PSID en una página | Contacto fb:PSID (origen facebook), perfil pedido con el token de página y ticket de la conexión de la página | Graph |
saves a shared link as a text message with only its url | Adjunto fallback con URL y sin texto | No se descarga; mensaje chat con solo la URL | downloader |
keeps the text of the message above the shared link | Texto más adjunto post con URL | Cuerpo texto + salto de línea + URL | downloader |
names Messenger in the notices | Mensaje no soportado y adjunto fallback sin URL | Avisos "[Facebook] ... Ábrelo en Messenger." para ambos | downloader |
a watermark read marks the outbound messages up to it and tells the backoffice | Lectura con watermark entre dos salientes | Solo el anterior pasa a read y se emite el broadcast con su id | socket broadcast |
ignores a watermark read of a contact the CRM does not know | Lectura de un PSID desconocido | No emite broadcast | socket broadcast |
a delivery with mids marks exactly those messages as delivered | Delivery con m_out.2 y un mid desconocido | Solo m_out.2 pasa a delivered; un único broadcast | socket broadcast |
a delivery without mids falls back to its watermark | Delivery sin mids pero con watermark | Se marca como delivered el mensaje anterior al watermark | socket broadcast |
a delivery never downgrades a message already read | Delivery sobre un mensaje ya read | Permanece read y no emite broadcast | socket broadcast |
meta-event.normalizer.spec.ts
Tests unitarios puros de normalizeMetaWebhook, sin mocks.
messages
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
maps a contact text message | Mensaje de texto de un contacto | Evento message completo (canal, cuenta, contacto, fecha, mid, texto, sin adjuntos, no echo, no unsupported) | ninguno |
inverts account and contact on an echo | sender es la cuenta e is_echo es true | accountId es la cuenta, contactId el destinatario e isEcho true | ninguno |
keeps attachments with their url and drops untyped ones | Adjuntos con url, sin url (story_mention) y sin tipo | Se conservan los dos con tipo y se descarta el que no tiene tipo; texto vacío | ninguno |
carries the quoted message id | reply_to.mid presente | replyToMid igual al mid citado | ninguno |
flags unsupported messages instead of dropping them | is_unsupported true | Evento message con isUnsupported true | ninguno |
maps a deleted message to message_deleted | is_deleted true | Evento message_deleted con el mid y sin propiedad text | ninguno |
other event types
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
turns a postback into a message with the button title | Evento postback | Evento message con el mid y el título del botón como texto | ninguno |
maps a read receipt | Evento read con mid | Evento read con mid y contacto | ninguno |
ignores reactions and empty read or delivery receipts | Reacción, read vacío y delivery con mids vacíos | Lista vacía en los tres casos | ninguno |
payload shapes
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
reads events from standby | Eventos en standby | Se produce 1 evento con el mid | ninguno |
flattens the dashboard test payload, with its timestamp in seconds as a string | Payload de prueba con changes, timestamp en segundos como string y un campo comments | Solo 1 evento (el de messages) con cuenta/contacto invertidos según el payload y fecha convertida desde segundos | ninguno |
falls back to receivedAt when the timestamp is missing or garbage | Timestamp ausente o not-a-date | Ambos usan receivedAt | ninguno |
preserves order across entries and events | Dos entradas con 2 y 1 eventos | Mids en orden a, b, c | ninguno |
drops malformed events without throwing | Eventos sin sender, sin mid, changes con valor null, {} y null | Todos devuelven lista vacía sin lanzar | ninguno |
handles the same shape for facebook pages | Mismo payload con canal Facebook y object page | Evento con channelType facebook | ninguno |
attachments of shared posts (webhooks since November 2025)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
keeps title and media id of an ig_post | Adjunto ig_post con media id, título y URL | Se conservan mediaId, title y url | ninguno |
drops the legacy share that duplicates an ig_post of the same media | share e ig_post con el mismo payload | Queda solo el ig_post | ninguno |
keeps a share when there is no ig_post for it | Solo un share | Se conserva el share | ninguno |
Raíz
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
drops the self-sent messages Meta uses to test the webhook | Mensaje con is_self true | Lista vacía | ninguno |
read receipts as Instagram really sends them (verified against Meta, 2026-09-26)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
keeps a read with no sender nor recipient, taking the account from the entry | Recibo real sin sender/recipient | Evento read con la cuenta del entry, contactId vacío y el mid | ninguno |
still drops it when the entry has no account id either | Entry sin id | Lista vacía | ninguno |
does not relax the rule for messages: a message without sender is still dropped | Mensaje sin sender | Lista vacía | ninguno |
Messenger events (facebook page)
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
maps a read with only a watermark | read solo con watermark | Evento read con watermark como fecha y sin mid | ninguno |
maps a delivery with its mids and watermark | delivery con mids y watermark | Evento delivery con ambos | ninguno |
maps a delivery that only carries a watermark | delivery solo con watermark | mids es [] y se conserva el watermark | ninguno |
keeps the url of a sticker | Mensaje con adjunto sticker | Adjunto sticker con su URL | ninguno |
reads url and title of a fallback from the attachment itself | Adjunto fallback con url y título fuera del payload (payload null) | Se leen url y title del adjunto | ninguno |
meta-webhook.controller.spec.ts
Test HTTP con supertest sobre una aplicación Nest real (prefijo, versionado URI, filtro de excepciones e interceptor de respuesta). La cola se reemplaza por un mock.
MetaWebhookController (HTTP) / GET verification
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
echoes the challenge as raw text, not wrapped | GET con modo subscribe y token correcto | 200, text/plain y el challenge 1158201444 | queue |
answers 403 to a wrong verify token | Token incorrecto | 403 | queue |
answers 403 when the mode is not subscribe | Modo unsubscribe | 403 | queue |
MetaWebhookController (HTTP) / POST events
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
enqueues a correctly signed event and answers an empty 200 | Evento Instagram firmado con el secret de Instagram | 200 con cuerpo vacío; enqueue se llama una vez con canal, body parseado y rawBody idéntico | queue |
rejects an Instagram event signed with the Meta app secret: Instagram signs with its own | Firma con el secret general de Meta | 401 y no se encola | queue |
verifies the exact bytes received, not the re-serialized body | Cuerpo con espacios extra, firmado sobre esos bytes | 200 | queue |
answers 401 and enqueues nothing on a bad signature | Firma de un atacante | 401 y no se encola | queue |
answers 401 when the signature header is missing | Sin cabecera de firma | 401 y no se encola | queue |
drops a signed event for another object with a 200 so Meta does not retry | Objeto page en la ruta instagram, bien firmado | 200 y no se encola | queue |
answers 503 when the queue is down so Meta retries | enqueue rechaza (Redis timeout) | 503 | queue rechazada |
answers 404 for a channel that is not a Meta one | Canal web | 404 | queue |
POST events / facebook pages
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
enqueues a page event signed with the Meta app secret | Evento de página firmado con el secret de Meta | 200 y se encola con canal facebook | queue |
rejects a page event signed with the Instagram secret | Evento de página firmado con el secret de Instagram | 401 y no se encola | queue |
drops an instagram object arriving on the facebook route | Objeto instagram en la ruta facebook | 200 y no se encola | queue |
MetaWebhookController without Meta configured
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
answers 404 to both routes | Configuración meta vacía; GET y POST a instagram | Ambas rutas responden 404 | queue |
meta-webhook-processor.service.spec.ts
MetaWebhookProcessorService
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
dispatches each event to its handler, in order | Job con mensaje, read, borrado, postback y delivery | Los handlers se llaman en el orden: message:m1, read:m0, deleted:m2, message:m3, delivery:m4,m5 | MetaInboundHandler |
runs handlers one at a time, never in parallel | Tres mensajes con handler asíncrono | La concurrencia máxima es 1 | MetaInboundHandler |
stops at the first failure and rethrows so BullMQ retries the job | El handler falla en m2 | Se relanza db down y m3 no se procesa | MetaInboundHandler |
completes without calling anything when the payload has no events | Job sin eventos | No se llama a ningún handler | MetaInboundHandler |
meta-webhook-queue.service.spec.ts
MetaWebhookQueueService
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
uses a job id derived from the raw body so Meta retries collapse | Encolar un payload normal | Job PROCESS con jobId instagram- + hash del body, delay 0 y removeOnComplete con la ventana de deduplicación | cola BullMQ |
delays batches that carry an echo | Payload con un echo | delay igual a ECHO_DELAY_MS | cola BullMQ |
rejects when Redis does not answer in time | add nunca resuelve y avanza el timer fake | La promesa rechaza con timed out | cola BullMQ, fake timers |
meta-signature.helper.spec.ts
isValidMetaSignature
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
accepts a signature made with the configured secret | Firma con el secret configurado | true | ninguno |
accepts a signature made with any of the candidate secrets | Firma con el segundo de dos secrets candidatos | true | ninguno |
rejects a body altered after signing | Cuerpo modificado tras firmar | false | ninguno |
rejects a signature made with an unknown secret | Firma con secret desconocido | false | ninguno |
rejects a header without the sha256= prefix | Firma sin el prefijo sha256= | false | ninguno |
rejects a header of a different length without throwing | Cabecera sha256=abc | false sin lanzar | ninguno |
rejects a missing header, a missing body or no secrets | Sin cabecera, sin cuerpo o sin secrets | false en los tres casos | ninguno |
isValidVerifyToken
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
matches only the exact token | Token exacto, token truncado y undefined | true solo para el exacto | ninguno |
hashWebhookBody
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
is deterministic for identical bodies and differs otherwise | Dos buffers iguales y uno distinto | Hash igual para iguales y distinto para el otro | ninguno |
hasEchoEvent
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
detects an echo in messaging or standby | Echo en messaging o en standby | true | ninguno |
is false for regular messages and malformed payloads | Mensaje normal y {} | false | ninguno |
meta-attachment.helper.spec.ts
meta-attachment.helper
| Test | Escenario | Resultado esperado | Mocks |
|---|---|---|---|
maps %s to %s (it.each) | MIME image/jpeg, image/webp; charset=binary, video/mp4, VIDEO/QUICKTIME, audio/aac, application/pdf, application/octet-stream | image, image, video, video, audio, document, document | ninguno |
uses the same size limits as a backoffice upload | maxSizeFor por tipo | Igual a FILE_SIZE.IMAGE, VIDEO, AUDIO y DOCUMENT | ninguno |
builds a filename from the stored type and the real mimetype | Combinaciones tipo/MIME | imagen.png, video.mov, video.mp4 (MIME desconocido) y documento sin extensión | ninguno |
downloads only known types that carry a url | Tipos image, story_mention, ig_reel con url; image sin url; fallback con url | true para los conocidos con url; false sin url o para fallback | ninguno |
names the attachment in the notice, with a generic label for unknown types | Aviso para image y para something_new en Instagram | Texto exacto con "(imagen)" y "(adjunto)" para el desconocido | ninguno |
names the Facebook channel and Messenger in its notices | Aviso de sticker y mensaje no soportado en Facebook | Textos "[Facebook] ... Ábrelo en Messenger." | ninguno |
treats fallback and post attachments with a url as links | fallback/post con y sin url, image con url, sticker con url | Enlace solo para fallback/post con url; sticker con url es descargable | ninguno |
keeps the original mid for the first part and suffixes the rest | externalIdForPart con índices 0, 1 y 2 | mid.1, mid.1#1, mid.1#2 | ninguno |