# 15 — Unit Tests

`flowrunner init` copied a unit-test harness into this project. This document is what it is and how to write
a suite against it.

The scope is deliberately narrow: **HTTP is mocked at the socket and nothing leaves the process.** There is
no live-API test kind here — validating a service against the real vendor means deploying it and running a
flow. What a unit suite is for is everything you can check without that: the request the service actually
sends, what it does with the answer, and what it does when the answer is a failure.

## What is in the project

```
jest.config.js               the runner: one suite per service
jest.setup.js                silences console output during a run
sandbox/
  index.js                   the five exports below
  service.js                 runServiceMethod, getServiceConfigItems, getServiceDefinition
  nock-mock.js               createNockMock — wire-level intercept + a history of every call
  files-mock.js              createFilesSandbox — a real Files API over local storage
services/{id}/tests/{id}.test.js    one suite per service
```

```bash
npm test                              # every suite
npx jest services/{id}/tests          # one service
npx jest services/{id}/tests -t 'getItems'
npm run test:coverage
```

**The harness is yours.** It was copied in, not linked, and nothing overwrites it — a service wrapping an
awkward SDK that needs a shim in `nock-mock.js` is a reason to edit `nock-mock.js`. (`flowrunner init` is
safe to re-run and adds only what is missing, so it will not undo an edit.)

## The five exports

There is no sandbox object to build, no `Flowrunner` global to install or clean up, and no service instance:
`runServiceMethod` owns all of that, per call.

| Export | For |
|---|---|
| `runServiceMethod(data)` | run one method the way the platform invokes it |
| `createNockMock()` | intercept and record HTTP at the socket |
| `createFilesSandbox()` | a real Files API, injected through `createFiles` |
| `getServiceConfigItems(id, entryPoint)` | the published config descriptions |
| `getServiceDefinition(id, entryPoint)` | the built model — what the platform can invoke |

### `runServiceMethod`

```js
const result = await runServiceMethod({
  serviceId,
  serviceEntryPointPath,
  configs: { apiKey: 'test-api-key' },          // your configItems values, in ONE bag
  methodName: 'getItems',
  methodData: { limit: 10 },                    // keyed by PARAM NAME
  // A dictionary's one declared param is `payload`: methodData: { payload: { search, cursor, criteria } }
  // — see the dictionary recipe in 18-test-recipes.md.
  executionContext: { accessToken: 'test-oauth-access-token' },  // OAuth services only
})
```

It resolves `methodName` in the built definition and assembles the handler's input from `methodData` **by
declared param name**, exactly as the platform does. Order in `methodData` never matters — which is what
makes a suite able to catch a param whose declared name disagrees with what the handler reads: the value
arrives `undefined` rather than arriving in the wrong slot.

A name that is not in the definition throws, naming the ones that are. That is a feature: a method the
platform cannot invoke is one no flow can reach.

The rest of `executionContext` is filled with stable `test-*` values you can assert against — `appId`,
`apiKey`, `serverURL`, `flowId`, `executionId`, `runtimeKey`.

### `getServiceDefinition` — what to cover

`methodsMap` is every method the platform can invoke, and each entry's `registerAs` says what kind it is:

| `registerAs` | Registered by | The suite's group |
|---|---|---|
| `ACTION` | `ext.addAction(...)` | `Actions` |
| `DICTIONARY` | `ext.dictionary(...)` | `Dictionaries` |
| `POLLING_TRIGGER` | `ext.addPollingTrigger(...)` | `Polling Triggers` |
| `REALTIME_TRIGGER` | `ext.addRealtimeTrigger(...)` | `RealTime Triggers` |
| `SAMPLE_RESULT_LOADER` | a `result` declared as a function | `DynamicResults` |
| `PARAM_SCHEMA_DEFINITION` | `dynamicParams` | `DynamicParams` |
| `SYSTEM` | the runtime — trigger and OAuth seams | **no group of its own** — see below |

**A SYSTEM method never gets its own `describe`.** `handleTriggerResolveEvents`,
`handleTriggerSelectMatched`, `handleTriggerUpsertWebhook`, `handleTriggerDeleteWebhook` and
`handleTriggerPollingForEvent` are the *transport* the platform uses to reach a trigger — they are not
features, and a group named after one describes nothing a flow author would recognize. Exercise each of them
**inside the `describe` of the specific trigger it is driving** — `onMessage`, `onChannelPost`,
`onChatJoinRequest` — so every test reads as a statement about that trigger. OAuth SYSTEM methods are the
exception and keep the `OAuth` group, having no trigger to belong to.

Enumerate it rather than reading the source by eye:

```js
// scratch.js, in the project root — run it, read it, delete it
const path = require('path')
const { getServiceDefinition } = require('./sandbox')

const definition = getServiceDefinition('todos', path.resolve('services/todos/src/index.js'))

for (const [name, method] of Object.entries(definition.methodsMap)) {
  console.log(method.registerAs.padEnd(24), name)
}
```

## The HTTP mock

`createNockMock()` intercepts at the **socket**, so whatever the service used to send — `Flowrunner.Request`
([04-http-requests.md](04-http-requests.md)), the file client, a bundled npm SDK — is recorded
identically, and a test never has to know which.

```js
let httpMock
beforeAll(() => { httpMock = createNockMock() })
afterEach(() => { httpMock.reset() })    // drops recorded calls AND handlers
afterAll(() => { httpMock.dispose() })   // un-patches `http` — see below
```

```js
httpMock.onGet(url).reply({ items: [] })          // 200 + body
httpMock.onPost(url).reply(body, 201, headers)    // explicit status / response headers
httpMock.onPut · onPatch · onDelete · onHead · onOptions
httpMock.on('propfind', url).reply(xmlString)     // custom verbs
httpMock.onGet(url).replyWith(call => ({ … }))    // dynamic: receives the history record
httpMock.onPost(url).replyWithError({ message: 'Nope' }, 422)   // an error RESPONSE
httpMock.onGet(url).replyWithNetworkError('ECONNRESET')          // a TRANSPORT failure
```

Handlers match in registration order, first wins. A URL registered without a query string matches whatever
query the service added; one registered with a query string matches only that exact query.

> **`dispose()` is not optional.** nock patches the shared `http` module when it activates, and jest gives
> every test file its own module registry — its own copy of nock — but one `http`. A suite that skips
> `dispose()` leaves its activation stacked on `http` for the rest of the worker, and the layers corrupt
> each other: request bodies that vanish from `history`, wrappers left pointing into a torn-down realm
> throwing `Cannot convert undefined or null to object` with an empty stack. The damage lands on whichever
> suite runs *next*, which is why the symptom is "passes alone, fails in a full run".

**Test both failure kinds.** An error response and a dropped connection reach the service differently — the
first as a `ResponseError` with `status` and `body`, the second with neither — and services routinely handle
one and not the other. An SDK that wraps its own transport may never see the network-error sentinel; when
that happens, assert only that the method rejects and say in a comment why the message is not pinned.

### The history record

```js
expect(httpMock.history).toEqual([{
  method: 'post',
  url: `${ BASE }/sms/send`,
  canonicalUrl: `${ BASE }/sms/send`,   // scheme + host + path, no query
  fullUrl: `${ BASE }/sms/send`,        // with query string
  headers: { 'Authorization': `Bearer ${ API_KEY }`, 'Content-Type': 'application/json' },
  query: {},
  body: { messages: [{ to: '+1', body: 'Hi' }] },
  formData: undefined,
  contentType: 'application/json',
  contentLength: 38,
  timeout: undefined,
}])
```

Compare it whole. A field-by-field assertion checks what you already suspected; the whole-object compare is
what catches the field you did not know the service was sending.

Three things the wire flattens, and they are all assertion traps:

- **`query` values are re-typed by shape**, because a query string carries no types. `?limit=100&flag=true`
  reads back as `{ limit: 100, flag: true }` — and an id sent as the string `'123'` reads back as `123`.
  Assert the coerced value.
- **Header names come back in conventional casing.** The client lowercases them on the way out, so the mock
  re-derives them: `X-Token` round-trips, `X-API-KEY` comes back as `X-Api-Key`.
- **`contentLength` is not guessable.** Write the assertion, run it, take the real number from the diff. It
  varies by method and body shape — a bodyless POST is often `0`, a bodyless GET often `undefined`.

The record has no `encoding`, no `unwrapBody` and no `toBuffer`: all three are client-side response flags
with no wire footprint, so a socket-level mock cannot see them. To pin a binary path, reply with known bytes
and assert those exact bytes arrive — with `.toBuffer()` the assertion is on the resolved value, which is a
`Buffer` byte for byte, including when the reply happens to be valid JSON
([04-http-requests.md](04-http-requests.md)).

### Multipart uploads

`formData` and `body` are mutually exclusive: when the request is `multipart/form-data` the mock parses the
parts into `formData` and leaves **`body: undefined`**.

```js
expect(httpMock.history).toEqual([{
  method  : 'post',
  url     : `${ BASE }/sendPhoto`,
  // …
  body    : undefined,
  formData: { _fields: [
    { name: 'chat_id', value: '-1001234567890',                 filename: undefined,  contentType: undefined },
    { name: 'photo',   value: Buffer.from([0x89, 0x50, 0x4e, 0x47]), filename: 'shot.png', contentType: 'image/png' },
  ] },
  contentType  : expect.stringMatching(/^multipart\/form-data; boundary=/),
  contentLength: 444,
  timeout      : undefined,
}])
```

- A **text part** arrives as a string, with `filename` and `contentType` `undefined`. Numbers the service
  appended arrive as strings — the wire has no types.
- A **binary part** arrives as a **Buffer**, byte-exact, carrying the `filename` and `contentType` the service
  passed in the part's options ([04-http-requests.md](04-http-requests.md)).
- A part the service passed as a **`Blob`** is no different on the record — the bytes arrive as a `Buffer`,
  with `filename` taken from `blob.name` (`'blob'` when it has none) and `contentType` from `blob.type`
  (`undefined` when it has none).

**Assert the bytes literally** — `value: Buffer.from([0x89, 0x50, 0x4e, 0x47])`, not a length and not
`expect.any(Buffer)`. Reply to the file fetch with a short recognizable sequence so the comparison stays
readable. The per-part `contentType` is a deliberate choice by the service and nothing else on the record
exposes it, so pin it too.

The `boundary` is random per request, so match `contentType` with `expect.stringMatching(...)`. Its *length*
is fixed, so `contentLength` is still a stable number — take it from the diff as usual.

> **An empty `_fields` means the service sent nothing.** This is the assertion that catches a request built
> with `.form(data)` and then `.send()`-ed, which goes out with no body at all
> ([04-http-requests.md](04-http-requests.md)). Because that failure is silent at the transport level
> and only shows up as a confusing 400 from the vendor, a multipart action is not covered until a test pins
> the parts.
>
> If a part you expect is missing, check the harness before rewriting the service: `nock` hands the reply
> callback a **string**, and a body that is not valid UTF-8 — any real binary upload — arrives **hex-encoded**.
> `sandbox/nock-mock.js` hex-decodes it (`multipartText`) before parsing; a harness without that step reports
> `{ _fields: [] }` for a perfectly correct upload.

## A suite

```js
'use strict'

const { createNockMock, getServiceConfigItems, runServiceMethod } = require('../../../sandbox')

const API_KEY = 'test-api-key'
const BASE = 'https://api.example.com/v1'
const HEADERS = { 'Authorization': `Bearer ${ API_KEY }`, 'Content-Type': 'application/json' }

describe('ServiceName Service', () => {
  const serviceId = 'service-name'
  const serviceEntryPointPath = require.resolve('../src/index.js')

  let httpMock

  const configs = { apiKey: API_KEY }

  function runMethod({ methodName, methodData, data }) {
    return runServiceMethod({ serviceId, serviceEntryPointPath, configs, ...data, methodName, methodData })
  }

  beforeAll(() => { httpMock = createNockMock() })
  afterEach(() => { httpMock.reset() })
  afterAll(() => { httpMock.dispose() })

  it('service config items', () => {
    expect(getServiceConfigItems(serviceId, serviceEntryPointPath)).toEqual([
      {
        name: 'apiKey', label: 'API Key', type: 'string',
        required: true, shared: false, options: [],
        description: 'Your API key, from Settings → Developers.',
      },
    ])
  })

  describe('Actions', () => {
    describe('getItems', () => {
      // One shortcut per describe, naming the method once.
      function runGetItemsMethod(limit, offset, data) {
        return runMethod({ methodName: 'getItems', methodData: { limit, offset }, data })
      }

      it('sends the defaults when no parameters are supplied', async () => {
        httpMock.onGet(`${ BASE }/items`).reply({ items: [], total: 0 })

        const result = await runGetItemsMethod()

        expect(result).toEqual({ items: [], total: 0 })

        expect(httpMock.history).toEqual([{
          method: 'get',
          url: `${ BASE }/items`,
          canonicalUrl: `${ BASE }/items`,
          fullUrl: `${ BASE }/items?limit=50&offset=0`,
          headers: HEADERS,
          query: { limit: 50, offset: 0 },
          body: undefined,
          formData: undefined,
          contentType: undefined,
          contentLength: 0,
          timeout: undefined,
        }])
      })

      // The `data` argument is how a suite proves auth is read from config rather than hardcoded.
      it('reads the key from config rather than a hardcoded one', async () => {
        httpMock.onGet(`${ BASE }/items`).reply({ items: [], total: 0 })

        await runGetItemsMethod(undefined, undefined, { configs: { apiKey: 'other-key' } })

        expect(httpMock.history[0].headers.Authorization).toBe('Bearer other-key')
      })

      it('rejects when the API answers with an error status', async () => {
        httpMock.onGet(`${ BASE }/items`).replyWithError({ message: 'Unauthorized' }, 401)

        await expect(runGetItemsMethod()).rejects.toThrow()
      })

      it('rejects when the connection drops', async () => {
        httpMock.onGet(`${ BASE }/items`).replyWithNetworkError('ECONNRESET')

        await expect(runGetItemsMethod()).rejects.toThrow()
      })
    })
  })
})
```

Nesting is fixed: `describe('<Service> Service')` → `describe('<Group>')` → `describe('<methodName>')` →
`it(...)`. Groups come from `registerAs`, never from subject matter — `sendPhoto` and `getChannel` are both
`Actions`.

## Choices — the test that is almost always missing

A label→wire mapping must guard its lookup with `Object.prototype.hasOwnProperty.call(mapping, value)`, or
`'constructor'` / `'toString'` / `'__proto__'` resolve to an inherited **function** and are sent as a
parameter value. Asserting only the matching side never catches that — a known label maps identically either
way. What catches it is a wire-level assertion on a pass-through choice:

```js
it('passes an unrecognized choice through as itself, even an inherited property name', async () => {
  httpMock.onGet(url).reply({})

  await runListThingsMethod(undefined, 'constructor')

  expect(httpMock.history[0].query).toEqual({ order: 'constructor' })  // a literal string, not a function
})
```

## Per kind — dictionaries, triggers, files

Recipes for the kinds a service may or may not have are
**[18-test-recipes.md](18-test-recipes.md)**. Load it for the kinds this service actually registers,
alongside this document — a suite for an actions-only service does not need it at all.

## What not to do

| | Why |
|---|---|
| `require()` the service entry file, or touch `global.Flowrunner` | Behaviour unreachable through a method call is not the service's contract |
| `jest.mock` a transport, or hand-write a fake client | The socket-level mock records what the service *sent*; a fake records what you expected it to send |
| `it.each`, or `expect` inside a loop | N results collapse into one unreadable failure |
| A catch-all `onAny()` handler | Every method under test calls a known URL; a catch-all only hides a wrong one |
| `toHaveProperty` / `toContain` as a default | They assert what you already suspected |
| Test that the vendor's API returns correct data | That is what deploying and running a flow is for |
| Change the service to make a test pass | A green test asserting a bug reads as "this is correct" — leave the path untested and report it |
