npm.io
0.2.0 • Published yesterdayCLI

@crvouga/mockingbird-service-fcm

Licence
MIT
Version
0.2.0
Deps
2
Size
884 kB
Vulns
35
Weekly
0
Stars
3

@crvouga/mockingbird-service-fcm

Familiar calls. Faithful echoes. Part of Mockingbird.

Stateful mock of the Firebase Cloud Messaging HTTP v1 send API (POST /v1/projects/{project_id}/messages:send), the call firebase-admin makes. Projects, access tokens, and registration tokens are fixtures. There is no FCM sandbox. This package is status: "wip".

  • Operation coverage: SUPPORT.md
  • The contract (openapi.yaml) is hand-authored from the FCM REST reference and firebase-admin 12.7.0 / 13.5.0.

Install

npm install -D @crvouga/mockingbird-service-fcm

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with npx mockingbird-fcm serve, createServer from ./server (Node), or createRuntime with any Fetch server.

Usage

firebase-admin hardcodes https://fcm.googleapis.com. Point it at the mock with createAdminTransport (an https.Agent that dials the mock) and call enableLegacyHttpTransport() so multicast sends honor that agent. send is already HTTP/1.1. The fixture project is demo-project, the fixture device token is fixture-device-token, and any bearer is accepted until you turn on strict.

import { createServer } from "@crvouga/mockingbird-service-fcm/server"
import { createAdminTransport } from "@crvouga/mockingbird-service-fcm/admin"

const fcm = await createServer()
const transport = await createAdminTransport({ origin: fcm.url, token: "fixture-token" })
// initializeApp({ projectId: "demo-project", credential: transport.credential, httpAgent: transport.agent })
// getMessaging(app).enableLegacyHttpTransport()
await transport.close()
await fcm.close()

Without the SDK, POST the wire body and read the outbox:

import { createRuntime, FCM_FIXTURE_PROJECT, FCM_FIXTURE_TOKEN } from "@crvouga/mockingbird-service-fcm"

const fcm = createRuntime()
const response = await fcm.fetch(
  new Request(`http://fcm.test/v1/projects/${FCM_FIXTURE_PROJECT}/messages:send`, {
    method: "POST",
    headers: { authorization: "Bearer fixture-token", "content-type": "application/json" },
    body: JSON.stringify({
      message: { token: FCM_FIXTURE_TOKEN, notification: { title: "Hello", body: "World" } },
    }),
  }),
)
const body = (await response.json()) as { name: string }
const outbox = await (await fcm.fetch(new Request("http://fcm.test/__admin/outbox"))).json()
void body
void outbox
Routes
Route Behaviour
POST /v1/projects/{project_id}/messages:send Bearer required. Body { message, validate_only? }. One registration token (topic and condition are rejected). Success is { name: "projects/{project_id}/messages/{id}" }. Errors are a Google RPC envelope. validate_only: true validates and returns a name without storing anything.
GET /health Liveness, plus the x-mockingbird header on every response.
Admin
Route Behaviour
GET /__admin/outbox Accepted messages, oldest first. Filters: token, platform, project, since (epoch ms), collapseKey, state. Each row has the normalized fields and the original wire message.
POST /__admin/tokens Register { token, platform?, project?, state?, appId? }. tokens: [...] registers a batch.
POST /__admin/tokens/:token/expire Mark expired. Optional { replacement }.
POST /__admin/tokens/:token/state { state: active | expired | unregistered | sender_mismatch }.
GET /__admin/inbox/:token Device inbox, in delivery order.
POST /__admin/inbox/:token { action: "ack" } drops the oldest item. { action: "clear" } empties it.
POST /__admin/deliver Deliver accepted messages. Expired ones stay in the outbox as expired.
POST /__admin/messages/:id/deliver Deliver one accepted message.
POST /__admin/messages/:id/drop Mark dropped and do not inbox it.
POST /__admin/messages/:id/duplicate Copy the inbox entry again.
GET /__admin/settings, PUT /__admin/settings strict, automaticDelivery (default true), collapse (default false), credentials (bearer → { project, expired? }), clockOffsetMs.
POST /__admin/time { advanceMs } moves this namespace's offset only.
POST /__admin/scripts { token?, errorCode, count, httpStatus? }. The next count sends to that token (or every token when token is omitted) return that Google RPC error and store nothing. Then the token's own state applies.
GET /__admin/scripts, DELETE /__admin/scripts List or clear scripts. ?token= clears one.

Logical message time is clockOffsetMs plus the process-wide runtime clock. POST /__admin/clock still moves every namespace. Records, faults, outboxes, inboxes, and message ids are per namespace.

Presets

POST /__admin/faults {"preset": "<name>"}. Each one targets SendMessage:

Preset Effect
invalid_auth 401 UNAUTHENTICATED, nothing stored
permission_denied 403 PERMISSION_DENIED, nothing stored
invalid_argument 400 INVALID_ARGUMENT, nothing stored
unregistered 404 NOT_FOUND + FcmError UNREGISTERED, nothing stored
sender_mismatch 403 + FcmError SENDER_ID_MISMATCH, nothing stored
quota_exceeded 429 RESOURCE_EXHAUSTED, Retry-After, google.rpc.RetryInfo, FcmError QUOTA_EXCEEDED
unavailable 503 + FcmError UNAVAILABLE
internal 500 + FcmError INTERNAL
slow waits 15s (the Firebase Admin send timeout)
drop connection drop before accept; outbox stays empty
accepted_then_network_drop outbox record is stored, then the connection drops
Namespaces

x-mockingbird-namespace, a /ns/<name>/ prefix, or PUT /__admin/credentials (bearer → namespace). A bearer listed in settings.credentials authorizes only that project. strict: true rejects bearers that are not listed. Webhooks: none. The device inbox is a test control, not a callback.

SDK error codes

firebase-admin maps error.details[].errorCode when @type is type.googleapis.com/google.firebase.fcm.v1.FcmError, otherwise error.status. Two mappings differ from a naive reading of the error-code names, and the mock follows firebase-admin 12.7.0 and 13.5.0:

  • A JSON 401 with status UNAUTHENTICATED and no FcmError details becomes messaging/third-party-auth-error. messaging/authentication-error is what the SDK uses for a non-JSON 401.
  • FcmError DEADLINE_EXCEEDED is not in the messaging map, so the SDK reports messaging/unknown-error. UNAVAILABLE is messaging/server-unavailable.

INVALID_ARGUMENT, SENDER_ID_MISMATCH, QUOTA_EXCEEDED, UNAVAILABLE, and INTERNAL map to messaging/invalid-argument, messaging/mismatched-credential, messaging/message-rate-exceeded, messaging/server-unavailable, and messaging/internal-error. Status PERMISSION_DENIED (a bearer for another project) is also messaging/mismatched-credential.

Deliberately not modelled
  • Firebase Auth, Firestore, Realtime Database, Hosting, Remote Config, Analytics, and client installation APIs.
  • Topic and condition sends, and topic subscription management.
  • APNs and Android transport, real devices, app callbacks, notification rendering, badges, and sounds.
  • Production quotas and Google's unpublished retry schedule. The Admin SDK's own retry (503, ECONNRESET, ETIMEDOUT) is left to the SDK.

API

Export Kind Description
FcmAPI class In-process mock: fetch(request), reset(), logicalNow(), outbox(query), inbox(token), registerToken, setTokenState, ackInbox, deliverPending, dropMessage, duplicateMessage, putScript. Options: sqlite, now, namespace, settings.
createRuntime function Mock plus /health, /__admin/*, namespaces, clock, presets, and the journal. Options: sqlite, clock, seed, adminKey, onLog, settings.
FCM_PRESETS object Named fault presets.
FCM_NAMESPACE string Service name, "fcm".
FCM_FIXTURE_PROJECT string Seeded project id, "demo-project".
FCM_FIXTURE_TOKEN string Seeded Android registration token, "fixture-device-token".
FCM_FIXTURE_BEARER string Bearer the playground sends, "fixture-token". Accepted whenever strict is off.
fcmCredential function (accessToken?) → { getAccessToken } for initializeApp({ credential }).
document, operationIds, supportedOperationIds values The OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server) Node Serve over node:http. CLI flag --strict. Port 8826.
createAdminTransport (./admin) Node { origin, token? } → { credential, agent, port, close }. agent is an https.Agent aimed at the mock.

Part of mockingbird.

Keywords