Core Building Blocks / Subscription

Testing

Test handlers in isolation with mocks or validate bridge routing with runtime harnesses.

Subscriptions have one testing level: handler isolation with createSubscriptionContextMock. Unlike commands and queue workers, there is no full runtime harness for subscriptions — test handler logic directly using the context mock.

LevelAPIValidates
Handler testcreateSubscriptionContextMock(...)Outcome logic, side effects, guard logic, return outcomes

Handler test

Use the context mock to test your subscription handler in isolation:

import { describe, test, expect, vi } from 'vitest'
import { createSubscriptionContextMock, safeBind } from '@purista/core'
import { orderNotificationSubscription } from './orderNotifications.js'
import { notificationService } from '../notificationService.js'

describe('orderNotification subscription', () => {
  test('returns ack after sending notification', async () => {
    const { context } = createSubscriptionContextMock(orderNotificationSubscription, {
      payload: { orderId: 'ord-1', customerId: 'cust-1', items: [] },
    })

    const sendPush = vi.fn()
    const handler = safeBind(orderNotificationSubscription.getSubscriptionFunction(), notificationService)
    const result = await handler(context, { orderId: 'ord-1', customerId: 'cust-1', items: [] })

    expect(result.status).toBe('ack')
  })

  test('sends notification with correct customer', async () => {
    const { context, stubs } = createSubscriptionContextMock(orderNotificationSubscription, {
      payload: { orderId: 'ord-1', customerId: 'cust-1', items: [{ productId: 'p1', qty: 2 }] },
    })

    stubs.service.CustomerService['1'].getCustomer.mockResolvedValue({ id: 'cust-1', name: 'Alice' })

    const handler = safeBind(orderNotificationSubscription.getSubscriptionFunction(), notificationService)
    await handler(context, { orderId: 'ord-1', customerId: 'cust-1', items: [{ productId: 'p1', qty: 2 }] })

    // Verify the stub was called
    expect(stubs.service.CustomerService['1'].getCustomer).toHaveBeenCalledWith({ customerId: 'cust-1' })
  })

  test('returns retry on transient failure', async () => {
    const { context } = createSubscriptionContextMock(orderNotificationSubscription, {
      payload: { orderId: 'ord-1', customerId: 'cust-1', items: [] },
    })

    const handler = safeBind(orderNotificationSubscription.getSubscriptionFunction(), notificationService)

    // Simulate a transient failure in the handler
    // In a real test, you'd mock the notification service to throw
    const result = await handler(context, { orderId: 'ord-1', customerId: 'cust-1', items: [] })

    // If your handler catches errors and returns retry:
    // expect(result.status).toBe('retry')
  })
})

Testing explicit outcomes

Test each outcome path your handler supports:

import { SubscriptionConsumerControlError } from '@purista/core'

describe('outcome handling', () => {
  test('ack on success', async () => {
    const { context } = createSubscriptionContextMock(orderNotificationSubscription)
    const handler = safeBind(orderNotificationSubscription.getSubscriptionFunction(), notificationService)
    const result = await handler(context, { orderId: 'ord-1', customerId: 'cust-1', items: [] })
    expect(result.status).toBe('ack')
  })

  test('retry on temporary failure', async () => {
    const { context } = createSubscriptionContextMock(orderNotificationSubscription)

    const handler = vi.fn().mockImplementation(async () => {
      throw new SubscriptionConsumerControlError('DB down', { status: 'retry' })
    })

    await expect(handler(context, {})).rejects.toThrow('DB down')
  })

  test('deadLetter on permanent failure', async () => {
    const { context } = createSubscriptionContextMock(orderNotificationSubscription)

    const handler = vi.fn().mockImplementation(async () => {
      throw new SubscriptionConsumerControlError('Invalid order', { status: 'deadLetter' })
    })

    await expect(handler(context, {})).rejects.toThrow('Invalid order')
  })
})

Testing before guards

The subscription builder does not expose a getBeforeGuardHook method. Test guard logic either by running the full subscription function (guards execute automatically) or by extracting the guard from the builder’s hooks and calling it directly:

describe('before guards', () => {
  test('requireAuth rejects unauthenticated events via full handler', async () => {
    const { context } = createSubscriptionContextMock(orderNotificationSubscription, {
      payload: { orderId: 'ord-1', customerId: 'cust-1', items: [] },
    })

    // Remove principalId to simulate unauthenticated event
    context.message.principalId = undefined

    const handler = safeBind(orderNotificationSubscription.getSubscriptionFunction(), notificationService)

    // Guards execute as part of getSubscriptionFunction() — the handler throws on guard failure
    await expect(
      handler(context, { orderId: 'ord-1', customerId: 'cust-1', items: [] }, {})
    ).rejects.toThrow('Authentication required')
  })
})

What about end-to-end testing?

For integration-level testing where you want to verify that events published to a real broker trigger your subscription handler, use an integration test that starts the full service with its event bridge. There is no standalone subscription runtime harness — createSubscriptionContextMock covers the handler contract, and your integration test suite covers broker routing.

Which level should you use?

ScenarioRecommended level
Outcome logicHandler test
Side effectsHandler test
Guard behaviorHandler test
Filter matchingRuntime test
Bridge adviceRuntime test
Event routingRuntime test
Full pipelineRuntime test