Casos de prueba: Meta saliente y OAuth
Casos de prueba de la autorización OAuth de Instagram, el cliente Graph, el despachador de mensajes salientes, la descarga de media, la renovación de tokens y el registro del módulo.
Specs fuente (bajo src/app/modules/meta):
controllers/meta-oauth.controller.spec.ts
services/meta-oauth.service.spec.ts
services/meta-graph.client.spec.ts
services/meta-outbound.dispatcher.spec.ts
services/meta-media.downloader.spec.ts
services/meta-token-refresh.service.spec.ts
jobs/meta-token-refresh.job.spec.ts
meta.module.spec.ts
Resumen: 8 archivos spec, 18 bloques describe y 68 casos it() (cada it.each cuenta como un caso).
Test HTTP con supertest; AuthGuard inyecta un usuario con tenant, PermissionsGuard siempre permite, y MetaOAuthService se reemplaza por un mock.
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns the Instagram authorization url for the logged user tenant | POST /authorize con un departmentIds válido | 200, data.url con la URL de autorización y buildAuthorizeUrl llamado con tenant, id de usuario y departmentIds | MetaOAuthService, guards |
rejects malformed department ids before reaching the service | POST /authorize con departmentIds: ['not-an-id'] | 400 y el servicio no se invoca | MetaOAuthService, guards |
redirects the browser to wherever the service decided | GET /callback con code y state | 302 con Location igual a lo que devuelve el servicio; handleCallback recibe code, state y error indefinido | MetaOAuthService |
passes the cancellation from Instagram to the service | GET /callback con error=access_denied | handleCallback recibe error: 'access_denied' sin code ni state | MetaOAuthService |
Test de integración con MongoDB en memoria; Graph, configuración y departamentos son mocks, y el state se firma con un JwtService real.
buildAuthorizeUrl
| Test | Escenario | Resultado esperado | Mocks |
|---|
points to Instagram with our app, redirect, scopes and a signed state | Generar URL con un departamento del tenant | Apunta a instagram.com/oauth/authorize con client_id, redirect_uri, response_type=code, scopes de Instagram y un state JWT firmado (propósito meta-oauth, tenant, usuario, departamentos) | config, departments |
rejects departments that do not belong to the tenant | departmentIds de un departamento ajeno | BadRequestException | departments |
rejects reauthorizing a connection that is not an Instagram one of the tenant | connectionId de una conexión web o de otro tenant | NotFoundException en ambos casos | config, departments |
answers 404 when Meta is not configured for this deployment | Falta meta.instagramAppId | AppException con código META_NOT_CONFIGURED y estado 404; handleCallback también lanza NotFoundException | config |
handleCallback
| Test | Escenario | Resultado esperado | Mocks |
|---|
creates the connection, subscribes the webhook and sends the user back | Callback válido con code y state firmado | Redirige con meta=connected y connectionId; conexión @mi_marca CONNECTED con departamentos, token de larga duración cifrado y expiración de unos 60 días; exchangeCode y subscribeToWebhooks llamados con los argumentos esperados | Graph, config, departments |
sends the user back with "denied" when they cancel on Instagram | error: access_denied | Redirige con meta=error&reason=denied sin intercambiar el code | Graph |
rejects %s (it.each) | state ausente, basura, firmado con otro secret, expirado o de otro propósito | Redirige con reason=invalid_state y no se intercambia el code | Graph |
stores nothing when the code exchange fails | exchangeCode rechaza con MetaGraphError | Redirige con reason=exchange_failed y no se crea ninguna conexión | Graph rechazado |
stores nothing when the webhook subscription fails | subscribeToWebhooks rechaza | Redirige con reason=subscription_failed y no se crea ninguna conexión | Graph rechazado |
refuses an account already connected in another tenant | La cuenta ya está en otro tenant | Redirige con reason=account_in_use y el tenant actual queda sin conexiones | Graph |
updates the existing connection of this tenant instead of creating another | La cuenta ya existe en este tenant | Reutiliza la conexión (misma id, una sola) y actualiza el token | Graph |
reauthorizes a disconnected connection and brings it back online | state con connectionId de una conexión con error de auth | Redirige con meta=connected; la conexión vuelve a CONNECTED y authErrorAt queda en null | Graph |
refuses to reauthorize a connection with a different Instagram account | La conexión tiene otra cuenta distinta | Redirige con reason=account_mismatch | Graph |
Tests unitarios con fetch global espiado (jest.spyOn).
| Test | Escenario | Resultado esperado | Mocks |
|---|
posts the form Instagram expects | Intercambiar el code | POST a api.instagram.com/oauth/access_token con formulario client_id, client_secret, grant_type, redirect_uri, code | fetch |
reads a %s response (it.each: flat, wrapped in data[]) | Respuesta plana o envuelta en data[] | Devuelve accessToken: 'short' y userId: '555' | fetch |
turns the OAuth error shape into a MetaGraphError | Respuesta 400 con error_type, code y error_message | MetaGraphError con código 400 y mensaje que incluye "already used" | fetch |
| Test | Escenario | Resultado esperado | Mocks |
|---|
exchanges for the long-lived token on graph.instagram.com without version | Canje a token de larga duración | Devuelve accessToken y expiresInSeconds; URL graph.instagram.com/access_token con grant_type=ig_exchange_token y client_secret | fetch |
reads the professional account id (user_id, not id) from a %s /me (it.each: flat, wrapped in data[]) | /me con respuesta plana o en data[] | Se usa user_id y username; ruta /v25.0/me con fields=user_id,username,name | fetch |
refreshes a long-lived token on graph.instagram.com without version | Renovar un token | Devuelve el token renovado y su vigencia; URL refresh_access_token con grant_type=ig_refresh_token y el token actual | fetch |
subscribeToWebhooks
| Test | Escenario | Resultado esperado | Mocks |
|---|
subscribes the account to messages and seen events | Suscribir una cuenta | POST a /v25.0/{cuenta}/subscribed_apps con subscribed_fields=messages,messaging_seen | fetch |
fails when Meta does not confirm the subscription | Respuesta success: false | Rechaza con MetaGraphError | fetch |
| Test | Escenario | Resultado esperado | Mocks |
|---|
asks Instagram for name, username and picture | Perfil de un contacto de Instagram | Host graph.instagram.com, fields=name,username,profile_pic; devuelve name, username y profilePic | fetch |
asks Messenger for first and last name and joins them | Perfil de un PSID de Facebook | Host graph.facebook.com, ruta /v25.0/psid; nombre unido "Juana Pérez" y profilePic | fetch |
leaves the name out when Messenger returns neither part | Respuesta sin nombre ni apellido | Devuelve solo profilePic | fetch |
| Test | Escenario | Resultado esperado | Mocks |
|---|
signs every Messenger call with the page token and the app secret | sendMessage por facebook con app secret configurado | Resuelve messageId; ruta /v25.0/page-1/messages con appsecret_proof igual al HMAC SHA-256 del token | fetch, config |
never signs Instagram calls, which belong to another app | sendMessage por instagram | La URL no lleva appsecret_proof | fetch, config |
sends no proof when the app secret is not configured | Facebook sin app secret | La URL no lleva appsecret_proof | fetch, config |
Tests unitarios del despachador con repositorios, storage, sockets, Graph y registro mockeados. El helper de token se reemplaza por uno fijo.
| Test | Escenario | Resultado esperado | Mocks |
|---|
registers one dispatcher per Meta channel, both backed by this one | onModuleInit y despacho por el segundo registro | Se registran Instagram y Facebook sin supports; el de Facebook envía a la página con destinatario PSID | registry, graph, connections |
sending
| Test | Escenario | Resultado esperado | Mocks |
|---|
sends the text to the contact igsid and stores the returned message id | Mensaje de texto a un contacto de Instagram | sendMessage con destinatario IGSID, texto y messaging_type: RESPONSE; guarda el mid devuelto | graph, messages |
sends an attachment as a url Meta can fetch | Mensaje con MediaId | Adjunto del tipo del archivo con URL firmada del storage | files, storage, graph |
never sends the filename as plain text when the file is gone | El archivo ya no existe | No se llama a sendMessage y no se marca fallido | files, graph |
adds the HUMAN_AGENT tag only when it is enabled | La configuración habilita la etiqueta | Payload con messaging_type: MESSAGE_TAG y tag: HUMAN_AGENT | config, graph |
sends a Messenger reply from the page to the contact psid | Ticket de Facebook | Envío desde la página al PSID y guarda el mid | graph, messages |
does nothing when the ticket has no Meta connection | No se encuentra la conexión | No se envía nada | connections |
rejections
| Test | Escenario | Resultado esperado | Mocks |
|---|
error %i marks the message failed as %s (it.each: 10, 551, 36000, 99999) | Graph rechaza con ese código | markFailed con OUT_OF_WINDOW, RECIPIENT_UNAVAILABLE, UNSUPPORTED_MEDIA o UNKNOWN y detalle code - mensaje | graph rechazado, messages |
tells the backoffice right away, with the reason | Error 10 | Se emite broadcastMessageAckChanged con estado failed y motivo OUT_OF_WINDOW | socket broadcast |
error 190 also disconnects the connection | Error 190 | markMetaAuthError sobre la conexión y mensaje fallido con motivo AUTH_ERROR | connections, messages |
emits nothing when the message was already delivered or read | markFailed devuelve null | No se emite broadcast | messages, socket broadcast |
transient failures
| Test | Escenario | Resultado esperado | Mocks |
|---|
retries and succeeds without marking anything failed | Primer intento con error transitorio y segundo exitoso | 2 llamadas, no se marca fallido y se guarda el mid del segundo | graph, fake timers |
gives up after the retries and marks the message failed | Error 429 persistente | Se agotan los reintentos (DELAYS_MS.length + 1 llamadas) y se marca fallido como RATE_LIMITED | graph, fake timers |
does not retry a permanent rejection | Error 10 (permanente) | Una sola llamada | graph, fake timers |
markRead
| Test | Escenario | Resultado esperado | Mocks |
|---|
tells Instagram the agent saw the conversation | markRead con mensajes en Instagram | sendSenderAction con mark_seen | graph |
tells Messenger the agent saw the conversation, from the page | markRead en Facebook | sendSenderAction desde la página al PSID con mark_seen | graph |
does nothing without messages or without a Meta connection | Lista vacía y conexión inexistente | No se llama a sendSenderAction | graph, connections |
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns the bytes with the type taken from the response | Respuesta 200 image/jpeg; charset=binary | Devuelve buffer, mimetype image/jpeg y fileType image | fetch |
rejects by content-length before reading the body | content-length mayor al límite de imagen | MetaMediaUnavailableError | fetch |
rejects while streaming when there is no content-length | Stream sin content-length que excede el límite | MetaMediaUnavailableError | fetch |
treats HTTP %i as a permanent failure (it.each 403, 404, 410) | Respuesta con ese estado | MetaMediaUnavailableError | fetch |
treats HTTP %i as transient so the job retries (it.each 429, 500, 503) | Respuesta con ese estado | Error genérico que no es MetaMediaUnavailableError | fetch |
treats a network failure or timeout as transient | fetch rechaza con TimeoutError | Error que no es MetaMediaUnavailableError y su mensaje contiene TimeoutError | fetch |
Test de integración con MongoDB en memoria; Graph mockeado y fecha fija inyectada.
| Test | Escenario | Resultado esperado | Mocks |
|---|
refreshes a token that expires within 10 days and stores it encrypted | Token que vence en 5 días | Se renueva, se guarda cifrado con nueva expiración y la conexión sigue CONNECTED | Graph |
leaves alone a token that still has more than 10 days | Vence en 30 días | No se llama a Graph | Graph |
skips connections already flagged and tokens that never expire | Conexión con error de auth y token sin expiración | No se llama a Graph | Graph |
ignores connections of other channels | Conexión de Facebook por vencer | No se llama a Graph | Graph |
disconnects the connection when Meta says the token was revoked | Graph responde error 190 | Conexión DISCONNECTED y authErrorAt con fecha | Graph rechazado |
keeps the old token on any other failure and retries on the next run | Fallo transitorio y luego éxito | El token viejo se conserva y sigue CONNECTED; en la siguiente corrida se renueva | Graph (rechaza una vez) |
one failing connection does not stop the others | Una conexión falla y otra no | La que falla conserva el token y la sana se renueva | Graph |
disconnects a token that expired without a successful refresh | Token ya vencido (-1 día) | No se llama a Graph, conexión DISCONNECTED y authErrorAt con fecha | Graph |
| Test | Escenario | Resultado esperado | Mocks |
|---|
runs the refresh | handleInterval | refreshExpiringTokens se llama una vez | refresh service |
never lets an error escape the interval | El servicio rechaza con db down | handleInterval resuelve sin error | refresh service rechazado |
is scheduled every hour | Metadata SCHEDULE_INTERVAL_OPTIONS del método | timeout igual a 3 600 000 ms | ninguno |
| Test | Escenario | Resultado esperado | Mocks |
|---|
registers the queue worker and its processing service | Metadata de providers del módulo | Incluye MetaWebhookProcessor y MetaWebhookProcessorService | ninguno |
registers the outbound dispatcher so tickets on this channel can reply | Metadata de providers | Incluye MetaOutboundDispatcher | ninguno |
binds the inbound handler the worker depends on to MetaInboundService | Metadata de providers | Existe { provide: MetaInboundHandler, useExisting: MetaInboundService } | ninguno |