Skip to main content

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.ts
  • helpers/meta-event.normalizer.spec.ts
  • controllers/meta-webhook.controller.spec.ts
  • services/meta-webhook-processor.service.spec.ts
  • services/meta-webhook-queue.service.spec.ts
  • helpers/meta-signature.helper.spec.ts
  • helpers/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)​

TestEscenarioResultado esperadoMocks
ingests a text DM: contact from the profile, ticket, message with its midLlega un DM de texto de un contacto nuevoSe 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 vezGraph getUserProfile, ticket service, socket broadcast
runs the whole message inside the contact lockSe procesa un mensajewithContactLock se invoca con tenant, ig:IGSID y una funciónlock
stores the avatar in our storage and never exposes the Meta CDN url as profilePicPerfil con foto en el CDN de MetaLa imagen se sube a avatars/tenant/contactId.jpg; profilePic es la URL propia y profilePicSourceUrl guarda la de Metastorage uploadPublic, fetch global
asks Graph for the profile only the first timeDos mensajes consecutivos del mismo contactoGraph se consulta una vez; hay 1 contacto, 1 ticket y 2 mensajesGraph
ignores a redelivered midEl mismo evento se entrega dos vecesSolo se guarda un mensaje (idempotencia por mid)Graph
links a reply to the quoted messageSegundo mensaje con replyToMid del primeroEl parentId de la respuesta apunta al mensaje originalGraph
does nothing for an account without connectionEvento de una cuenta sin conexiónNo se crean contactos ni mensajesGraph
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 100Contacto anónimo (Instagram ······123456), el mensaje se guarda y authErrorAt sigue en nullGraph rechazado
error 190: marks the connection disconnected but the message still gets inGraph responde error 190 (token expirado)authErrorAt es una fecha, la conexión queda DISCONNECTED y el mensaje igual se guardaGraph rechazado
falls back to the @username when the profile has no namePerfil solo con usernameEl contacto se llama @juanapGraph

attachments​

TestEscenarioResultado esperadoMocks
downloads the attachment into our storage and saves a media messageEvento con una imagen y sin textoSe descarga la URL, se guarda con storeIncoming (imagen.jpg, image/jpeg), mensaje media con MediaId y un broadcast de archivodownloader, file service, socket
saves one message per attachment, keeping the original mid on the firstDos imágenes en un eventoMids mid.1 y mid.1#1; un solo ticket, un saludo y un broadcast de ticket creadodownloader, file service
resumes a half-stored batch without duplicating what already got inLa segunda descarga falla con HTTP 503 y luego se reintentaPrimer 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 attachmentTexto más imagenbody es imagen.jpg y caption es el textodownloader
uses the title of a shared post as captionAdjunto ig_post con título y sin textocaption es el título del postdownloader
saves a notice when the attachment is gone or too largeMetaMediaUnavailableError (HTTP 404)Mensaje chat con el aviso "No se pudo recuperar el adjunto (imagen)"; no se llama a storeIncomingdownloader rechazado
saves a notice for an attachment type it cannot downloadAdjunto de tipo something_newNo se descarga nada y el cuerpo contiene (adjunto)downloader
rethrows a transient download failure so BullMQ retries the jobFallo transitorio (TimeoutError)Se relanza el error y no se guarda ningún mensajedownloader rechazado

Mensajes no soportados (raíz)​

TestEscenarioResultado esperadoMocks
saves a notice for a message Meta flags as unsupportedEvento con isUnsupported: true y sin textoCuerpo "[Instagram] Mensaje no soportado. Ábrelo en la app de Instagram."Graph

messages sent outside the CRM (echoes)​

TestEscenarioResultado esperadoMocks
never stores what an agent writes from the Instagram appEvento echoNo se crea mensaje, contacto ni ticketGraph
ignores an echo carrying attachments tooEcho con adjuntoNo se descarga y no se guarda mensajedownloader

read receipts​

TestEscenarioResultado esperadoMocks
marks the read message and the earlier outbound ones, and tells the backofficeLectura sobre mid.out.2 con un saliente anteriorAmbos mensajes pasan a read y se emite broadcastMessageAckChanged con ambos idssocket broadcast
leaves messages sent after the read one as they wereLectura de mid.out.1 con un mensaje posteriorEl posterior sigue en sentsocket broadcast
does nothing, and emits nothing, for a repeated receiptEl mismo recibo llega dos vecesLa segunda vez no emite broadcastsocket broadcast
ignores a receipt for a message the CRM never storedMid desconocidoNo emite broadcastsocket broadcast
ignores a receipt for an account without connectionRecibo de otra cuentaNo emite broadcastsocket broadcast

deletions (still deferred)​

TestEscenarioResultado esperadoMocks
a deletion writes nothingEvento message_deletedNo se escribe ningún mensajeninguno

Facebook Messenger​

TestEscenarioResultado esperadoMocks
ingests a message into a ticket of the page connection, with an fb contactMensaje de un PSID en una páginaContacto fb:PSID (origen facebook), perfil pedido con el token de página y ticket de la conexión de la páginaGraph
saves a shared link as a text message with only its urlAdjunto fallback con URL y sin textoNo se descarga; mensaje chat con solo la URLdownloader
keeps the text of the message above the shared linkTexto más adjunto post con URLCuerpo texto + salto de línea + URLdownloader
names Messenger in the noticesMensaje no soportado y adjunto fallback sin URLAvisos "[Facebook] ... Ábrelo en Messenger." para ambosdownloader
a watermark read marks the outbound messages up to it and tells the backofficeLectura con watermark entre dos salientesSolo el anterior pasa a read y se emite el broadcast con su idsocket broadcast
ignores a watermark read of a contact the CRM does not knowLectura de un PSID desconocidoNo emite broadcastsocket broadcast
a delivery with mids marks exactly those messages as deliveredDelivery con m_out.2 y un mid desconocidoSolo m_out.2 pasa a delivered; un único broadcastsocket broadcast
a delivery without mids falls back to its watermarkDelivery sin mids pero con watermarkSe marca como delivered el mensaje anterior al watermarksocket broadcast
a delivery never downgrades a message already readDelivery sobre un mensaje ya readPermanece read y no emite broadcastsocket broadcast

meta-event.normalizer.spec.ts​

Tests unitarios puros de normalizeMetaWebhook, sin mocks.

messages​

TestEscenarioResultado esperadoMocks
maps a contact text messageMensaje de texto de un contactoEvento message completo (canal, cuenta, contacto, fecha, mid, texto, sin adjuntos, no echo, no unsupported)ninguno
inverts account and contact on an echosender es la cuenta e is_echo es trueaccountId es la cuenta, contactId el destinatario e isEcho trueninguno
keeps attachments with their url and drops untyped onesAdjuntos con url, sin url (story_mention) y sin tipoSe conservan los dos con tipo y se descarta el que no tiene tipo; texto vacíoninguno
carries the quoted message idreply_to.mid presentereplyToMid igual al mid citadoninguno
flags unsupported messages instead of dropping themis_unsupported trueEvento message con isUnsupported trueninguno
maps a deleted message to message_deletedis_deleted trueEvento message_deleted con el mid y sin propiedad textninguno

other event types​

TestEscenarioResultado esperadoMocks
turns a postback into a message with the button titleEvento postbackEvento message con el mid y el título del botón como textoninguno
maps a read receiptEvento read con midEvento read con mid y contactoninguno
ignores reactions and empty read or delivery receiptsReacción, read vacío y delivery con mids vacíosLista vacía en los tres casosninguno

payload shapes​

TestEscenarioResultado esperadoMocks
reads events from standbyEventos en standbySe produce 1 evento con el midninguno
flattens the dashboard test payload, with its timestamp in seconds as a stringPayload de prueba con changes, timestamp en segundos como string y un campo commentsSolo 1 evento (el de messages) con cuenta/contacto invertidos según el payload y fecha convertida desde segundosninguno
falls back to receivedAt when the timestamp is missing or garbageTimestamp ausente o not-a-dateAmbos usan receivedAtninguno
preserves order across entries and eventsDos entradas con 2 y 1 eventosMids en orden a, b, cninguno
drops malformed events without throwingEventos sin sender, sin mid, changes con valor null, {} y nullTodos devuelven lista vacía sin lanzarninguno
handles the same shape for facebook pagesMismo payload con canal Facebook y object pageEvento con channelType facebookninguno

attachments of shared posts (webhooks since November 2025)​

TestEscenarioResultado esperadoMocks
keeps title and media id of an ig_postAdjunto ig_post con media id, título y URLSe conservan mediaId, title y urlninguno
drops the legacy share that duplicates an ig_post of the same mediashare e ig_post con el mismo payloadQueda solo el ig_postninguno
keeps a share when there is no ig_post for itSolo un shareSe conserva el shareninguno

Raíz​

TestEscenarioResultado esperadoMocks
drops the self-sent messages Meta uses to test the webhookMensaje con is_self trueLista vacíaninguno

read receipts as Instagram really sends them (verified against Meta, 2026-09-26)​

TestEscenarioResultado esperadoMocks
keeps a read with no sender nor recipient, taking the account from the entryRecibo real sin sender/recipientEvento read con la cuenta del entry, contactId vacío y el midninguno
still drops it when the entry has no account id eitherEntry sin idLista vacíaninguno
does not relax the rule for messages: a message without sender is still droppedMensaje sin senderLista vacíaninguno

Messenger events (facebook page)​

TestEscenarioResultado esperadoMocks
maps a read with only a watermarkread solo con watermarkEvento read con watermark como fecha y sin midninguno
maps a delivery with its mids and watermarkdelivery con mids y watermarkEvento delivery con ambosninguno
maps a delivery that only carries a watermarkdelivery solo con watermarkmids es [] y se conserva el watermarkninguno
keeps the url of a stickerMensaje con adjunto stickerAdjunto sticker con su URLninguno
reads url and title of a fallback from the attachment itselfAdjunto fallback con url y título fuera del payload (payload null)Se leen url y title del adjuntoninguno

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​

TestEscenarioResultado esperadoMocks
echoes the challenge as raw text, not wrappedGET con modo subscribe y token correcto200, text/plain y el challenge 1158201444queue
answers 403 to a wrong verify tokenToken incorrecto403queue
answers 403 when the mode is not subscribeModo unsubscribe403queue

MetaWebhookController (HTTP) / POST events​

TestEscenarioResultado esperadoMocks
enqueues a correctly signed event and answers an empty 200Evento Instagram firmado con el secret de Instagram200 con cuerpo vacío; enqueue se llama una vez con canal, body parseado y rawBody idénticoqueue
rejects an Instagram event signed with the Meta app secret: Instagram signs with its ownFirma con el secret general de Meta401 y no se encolaqueue
verifies the exact bytes received, not the re-serialized bodyCuerpo con espacios extra, firmado sobre esos bytes200queue
answers 401 and enqueues nothing on a bad signatureFirma de un atacante401 y no se encolaqueue
answers 401 when the signature header is missingSin cabecera de firma401 y no se encolaqueue
drops a signed event for another object with a 200 so Meta does not retryObjeto page en la ruta instagram, bien firmado200 y no se encolaqueue
answers 503 when the queue is down so Meta retriesenqueue rechaza (Redis timeout)503queue rechazada
answers 404 for a channel that is not a Meta oneCanal web404queue

POST events / facebook pages​

TestEscenarioResultado esperadoMocks
enqueues a page event signed with the Meta app secretEvento de página firmado con el secret de Meta200 y se encola con canal facebookqueue
rejects a page event signed with the Instagram secretEvento de página firmado con el secret de Instagram401 y no se encolaqueue
drops an instagram object arriving on the facebook routeObjeto instagram en la ruta facebook200 y no se encolaqueue

MetaWebhookController without Meta configured​

TestEscenarioResultado esperadoMocks
answers 404 to both routesConfiguración meta vacía; GET y POST a instagramAmbas rutas responden 404queue

meta-webhook-processor.service.spec.ts​

MetaWebhookProcessorService​

TestEscenarioResultado esperadoMocks
dispatches each event to its handler, in orderJob con mensaje, read, borrado, postback y deliveryLos handlers se llaman en el orden: message:m1, read:m0, deleted:m2, message:m3, delivery:m4,m5MetaInboundHandler
runs handlers one at a time, never in parallelTres mensajes con handler asíncronoLa concurrencia máxima es 1MetaInboundHandler
stops at the first failure and rethrows so BullMQ retries the jobEl handler falla en m2Se relanza db down y m3 no se procesaMetaInboundHandler
completes without calling anything when the payload has no eventsJob sin eventosNo se llama a ningún handlerMetaInboundHandler

meta-webhook-queue.service.spec.ts​

MetaWebhookQueueService​

TestEscenarioResultado esperadoMocks
uses a job id derived from the raw body so Meta retries collapseEncolar un payload normalJob PROCESS con jobId instagram- + hash del body, delay 0 y removeOnComplete con la ventana de deduplicacióncola BullMQ
delays batches that carry an echoPayload con un echodelay igual a ECHO_DELAY_MScola BullMQ
rejects when Redis does not answer in timeadd nunca resuelve y avanza el timer fakeLa promesa rechaza con timed outcola BullMQ, fake timers

meta-signature.helper.spec.ts​

isValidMetaSignature​

TestEscenarioResultado esperadoMocks
accepts a signature made with the configured secretFirma con el secret configuradotrueninguno
accepts a signature made with any of the candidate secretsFirma con el segundo de dos secrets candidatostrueninguno
rejects a body altered after signingCuerpo modificado tras firmarfalseninguno
rejects a signature made with an unknown secretFirma con secret desconocidofalseninguno
rejects a header without the sha256= prefixFirma sin el prefijo sha256=falseninguno
rejects a header of a different length without throwingCabecera sha256=abcfalse sin lanzarninguno
rejects a missing header, a missing body or no secretsSin cabecera, sin cuerpo o sin secretsfalse en los tres casosninguno

isValidVerifyToken​

TestEscenarioResultado esperadoMocks
matches only the exact tokenToken exacto, token truncado y undefinedtrue solo para el exactoninguno

hashWebhookBody​

TestEscenarioResultado esperadoMocks
is deterministic for identical bodies and differs otherwiseDos buffers iguales y uno distintoHash igual para iguales y distinto para el otroninguno

hasEchoEvent​

TestEscenarioResultado esperadoMocks
detects an echo in messaging or standbyEcho en messaging o en standbytrueninguno
is false for regular messages and malformed payloadsMensaje normal y {}falseninguno

meta-attachment.helper.spec.ts​

meta-attachment.helper​

TestEscenarioResultado esperadoMocks
maps %s to %s (it.each)MIME image/jpeg, image/webp; charset=binary, video/mp4, VIDEO/QUICKTIME, audio/aac, application/pdf, application/octet-streamimage, image, video, video, audio, document, documentninguno
uses the same size limits as a backoffice uploadmaxSizeFor por tipoIgual a FILE_SIZE.IMAGE, VIDEO, AUDIO y DOCUMENTninguno
builds a filename from the stored type and the real mimetypeCombinaciones tipo/MIMEimagen.png, video.mov, video.mp4 (MIME desconocido) y documento sin extensiónninguno
downloads only known types that carry a urlTipos image, story_mention, ig_reel con url; image sin url; fallback con urltrue para los conocidos con url; false sin url o para fallbackninguno
names the attachment in the notice, with a generic label for unknown typesAviso para image y para something_new en InstagramTexto exacto con "(imagen)" y "(adjunto)" para el desconocidoninguno
names the Facebook channel and Messenger in its noticesAviso de sticker y mensaje no soportado en FacebookTextos "[Facebook] ... Ábrelo en Messenger."ninguno
treats fallback and post attachments with a url as linksfallback/post con y sin url, image con url, sticker con urlEnlace solo para fallback/post con url; sticker con url es descargableninguno
keeps the original mid for the first part and suffixes the restexternalIdForPart con índices 0, 1 y 2mid.1, mid.1#1, mid.1#2ninguno