Testing

The layered architecture exists partly so this page is short. Because the SDK is reached only through the service layer, and credentials arrive per request rather than from global config, every layer is testable in isolation without real network calls or secrets. Four kinds of test cover the whole integration.

1 · Unit-test the service layer with a mocked SDK

The service layer is the one place SDK types appear. Test it by injecting a mock SDK client and asserting two things: the SDK was called with the right shape, and the response was mapped into your contract (snake → camel) correctly. No network, no key.

// services/sent.service.spec.ts
const mockClient = { messages: { send: vi.fn() } };
const service = new SentService(mockClient as any, mockLogger as any);

it('send maps the SDK response into the canonical shape', async () => {
  mockClient.messages.send.mockResolvedValue({
    data: {
      status: 'QUEUED',
      template_id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
      template_name: 'welcome',
      recipients: [{ message_id: 'msg_123', to: '+14155550123', channel: 'sms' }],
    },
  });

  const result = await service.send({
    to: ['+14155550123'],
    channel: ['sms'],
    template: { name: 'welcome' },
  });

  // SDK snake_case → contract camelCase; message_id → id.
  expect(result).toEqual({
    status: 'QUEUED',
    templateId: '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
    templateName: 'welcome',
    recipients: [{ id: 'msg_123', to: '+14155550123', channel: 'sms', body: undefined }],
  });
});
# tests/test_sent_service.py — pytest-asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock
from app.models.schemas import CanonicalSendRequest
from app.services.sent_service import SentService

@pytest.mark.asyncio
async def test_send_canonical_maps_response():
    data = MagicMock(
        status="QUEUED",
        template_id="7ba7b820-9dad-11d1-80b4-00c04fd430c8",
        template_name="welcome",
        recipients=[MagicMock(message_id="msg_123", to="+14155550123",
                              channel="sms", body=None)],
    )
    client = MagicMock()
    client.messages.send = AsyncMock(return_value=MagicMock(data=data))
    service = SentService(client)

    result = await service.send_canonical(CanonicalSendRequest(
        to=["+14155550123"], channel=["sms"], template={"name": "welcome"},
    ))

    # SDK snake_case → contract camelCase; message_id → id.
    assert result.status == "QUEUED"
    assert result.templateId == "7ba7b820-9dad-11d1-80b4-00c04fd430c8"
    assert result.recipients[0].id == "msg_123"

A test container (e.g. tests/setup.ts) that swaps the whole dependency container for mocks — a sentService/messagesService full of vi.fn() and a no-op logger — keeps this clean in TypeScript. Injecting dependencies rather than importing singletons is what makes this work; the same pattern applies with pytest fixtures or Spring's @MockBean.

2 · Integration-test your routes (supertest-style)

Mount the real app with a mocked service container and drive it over HTTP. This exercises routing, edge validation, body parsing, and error mapping together — everything except the actual Sent call.

// tests/messages.integration.spec.ts
import request from 'supertest';
import { createApp } from '../app';
import { setupTestContainer } from './setup';

beforeEach(() => {
  setupTestContainer({
    sentService: {
      send: vi.fn().mockResolvedValue({
        status: 'QUEUED',
        templateId: '550e8400-e29b-41d4-a716-446655440000',
        templateName: 'welcome',
        recipients: [{ id: 'msg_123', to: '+14155550123', channel: 'sms' }],
      }),
    } as any,
  });
  app = createApp();
});

it('POST /api/messages succeeds with a valid payload', async () => {
  const res = await request(app)
    .post('/api/messages')
    .set('Authorization', 'Bearer test-key')
    .send({
      to: ['+14155550123'],
      channel: ['sms'],
      template: { name: 'welcome', parameters: { name: 'Ada' } },
    });
  expect(res.status).toBe(200);
  expect(res.body.recipients[0].id).toBe('msg_123');
});

it('POST /api/messages returns 400 for an invalid body', async () => {
  const res = await request(app)
    .post('/api/messages')
    .set('Authorization', 'Bearer test-key')
    .send({ template: { name: 'welcome' } }); // missing `to`
  expect(res.status).toBe(400);
  expect(res.body.error.code).toBe('ValidationError');
});
# tests/test_messages.py — FastAPI TestClient (see conftest.py fixtures)
from app.models.schemas import CanonicalSendResponse, CanonicalRecipient

async def fake_send_canonical(self, request):
    # Return the real response model — the route iterates result.recipients as objects.
    return CanonicalSendResponse(
        status="QUEUED",
        templateId="550e8400-e29b-41d4-a716-446655440000",
        templateName="welcome",
        recipients=[CanonicalRecipient(id="msg_123", to="+14155550123", channel="sms")],
    )

def test_send_succeeds(client, monkeypatch):
    # send_canonical is async — replace it with an async stub.
    monkeypatch.setattr(
        "app.services.sent_service.SentService.send_canonical", fake_send_canonical
    )
    res = client.post(
        "/api/messages",
        headers={"Authorization": "Bearer test-key"},
        json={
            "to": ["+14155550123"],
            "channel": ["sms"],
            "template": {"name": "welcome", "parameters": {"name": "Ada"}},
        },
    )
    assert res.status_code == 200
    assert res.json()["recipients"][0]["id"] == "msg_123"

def test_health(client):
    res = client.get("/health")
    assert res.json() == {"status": "ok", "backend": "python", "framework": "fastapi"}

Also assert the unhappy paths: a request with no Authorization header should 401, a malformed body should 400, and an unknown route should 404. These are cheap to write and catch the mistakes that hurt most in production.

3 · Safe end-to-end sends with sandbox: true

When you want a real round-trip against Sent without delivering to a real handset, pass sandbox: true on the send. The request goes through the full stack and returns a real response shape, but no message is actually delivered — ideal for CI smoke tests and staging.

await sentService.send({
  to: ['+1234567890'],
  channel: ['sms'],
  template: { name: 'welcome', parameters: { name: 'Test' } },
  sandbox: true, // full round-trip, nothing delivered
});

sandbox is a per-send flag, not an environment mode — the same running service handles sandbox and live sends side by side. Make sure production traffic does not carry sandbox: true, and that any test harness that sets it can't leak into prod. Confirm it's off before go-live — see Going to production.

4 · Test the webhook verifier against a known vector

The verifier is the most security-critical code you own, and it's pure — inputs in, boolean out — so pin it with a fixed test vector: a known secret, id, timestamp, and body with a pre-computed expected signature, hardcoded once and never regenerated by your own code. This catches the case a same-code "compute it twice" check can't: if your implementation has a systemic misunderstanding of the scheme (wrong join order, wrong encoding), computing it twice in your own codebase reproduces the same mistake both times and still "passes." A hardcoded expected value doesn't have that blind spot — it only passes if your output matches the scheme itself.

// Fixed inputs and a hardcoded expected output — computed once, out of band,
// and never regenerated by computeSignature itself.
const SECRET = 'whsec_abcdef1234567890';
const ID   = '1f2e3d4c-5b6a-7980-1234-567890abcdef';
const TS   = '1750000000';
const BODY = '{"field":"message","event":"message.delivered"}';
const EXPECTED = 'v1,toBvqGNFsjQ5xSZO93Z3qiyVckIbArJtTaEPaUYZJGw=';

it('matches the known test vector byte-for-byte', async () => {
  expect(await computeSignature(ID, TS, BODY, SECRET)).toBe(EXPECTED);
});

Apply the same idea to your receiver's verify function, and cover the failure modes explicitly:

  • Strips the whsec_ prefix and decodes the key as base64 (not utf-8).
  • A tampered body produces a different signature (assert rejection).
  • A stale timestamp (> 300s) is rejected — replay protection.
  • Rotation: a space-separated X-Webhook-Signature is accepted if any part matches a current secret.
  • Verification runs on the raw body, before JSON parsing.

Because the vector is fixed, this test is also your cross-language conformance check: hard-code the same SECRET/ID/TS/BODY in your Python, Go, or Java verifier tests and they must all produce v1,<same base64>. See Signature verification.

Next steps

On this page