# 18 — Test Recipes

Per-kind testing recipes, split out of [15-unit-tests.md](15-unit-tests.md) so a suite only loads what its
service has. **Read 15 first** — the harness, `runServiceMethod`, the HTTP mock, the history record and the
suite skeleton are all there, and everything below assumes them.

Load only the sections that apply: a service with no triggers does not need *Triggers*, and one that never
touches a file does not need *Files API*.

## Dictionaries

`{ search?, cursor?, criteria? }` in, `{ items: [{ label, value, note? }], cursor }` out
([05-dictionaries.md](05-dictionaries.md)). Cover the mapping, a case-insensitive search, an absent
`search` listing everything, and empty data answering `{ items: [], cursor: null }`.

**A dictionary is invoked through a single `payload` param** — the runner synthesizes it, you never declare
it ([05-dictionaries.md](05-dictionaries.md)). So `methodData` wraps the whole `{ search, cursor, criteria }`
envelope in `payload`, and the per-`describe` shortcut is where that wrapper belongs, so no individual test
repeats it:

```js
// One shortcut per describe, naming the method once — the method name IS the dictionary id.
function runItemsDictionaryMethod(payload) {
  return runMethod({ methodName: 'getItemsDictionary', methodData: { payload } })
}
```

`payload` itself has to be there. `methodData: { payload: null }` is a malformed **call**, not an empty
search, and throws `MissingMethodParamError` — so "no search" means an absent or null `search` **inside** the
payload, which is `runItemsDictionaryMethod({})`.

Then the failure branch, and read [05-dictionaries.md](05-dictionaries.md) before deciding what to assert —
the two cases that look alike are not the same:

- **A search that matched nothing** is `{ items: [], cursor: null }`. That is the documented behaviour of a
  *resolve* dictionary and the right thing to pin.
- **A request that failed** — a rejected key, a dropped connection — is not "there are none". A dictionary
  that answers `{ items: [] }` there hands the flow author an empty dropdown and no way to tell why, and if
  the service does that behind a bare `catch`, pinning it as correct is how it stays.

```js
it('answers an unmatched search with an empty list', async () => {
  httpMock.onGet(`${ BASE }/items`).reply({ items: [] })

  await expect(runItemsDictionaryMethod({ search: 'nothing' })).resolves.toEqual({ items: [], cursor: null })
})

it('does not answer a rejected key with an empty list', async () => {
  httpMock.onGet(`${ BASE }/items`).replyWithError({ message: 'Invalid API key' }, 401)

  await expect(runItemsDictionaryMethod({})).rejects.toThrow()
})
```

If the second one fails because the service swallows the error, that is a finding to report — not a test to
rewrite into agreement with the service.

## Triggers

Only the SYSTEM methods are runnable, because only they are what the platform calls
([08-polling-triggers.md](08-polling-triggers.md), [09-realtime-triggers.md](09-realtime-triggers.md)). A
trigger's own handler is reached *through* one of them, and the mapping in between is the part most likely
to be wrong — calling the handler directly skips exactly that.

**Group by trigger, never by SYSTEM method.** The groups are `onMessage`, `onChannelPost`,
`onChatJoinRequest` — the things a flow author picks from a list. Declare the SYSTEM shortcuts once at the
top of `RealTime Triggers`, then call them inside each trigger's own `describe`. A
`describe('handleTriggerResolveEvents')` collecting every trigger's shaping tests is wrong: it reads as a
statement about the runtime rather than about the service, and it scatters one trigger's behaviour across
several groups.

Their payload goes one level down, under `invocation`:

```js
describe('RealTime Triggers', () => {
  function runHandleTriggerResolveEventsMethod(invocation) {
    return runMethod({ methodName: 'handleTriggerResolveEvents', methodData: { invocation } })
  }

  function runHandleTriggerUpsertWebhookMethod(invocation) {
    return runMethod({ methodName: 'handleTriggerUpsertWebhook', methodData: { invocation } })
  }

  describe('onMention', () => {
    it('shapes one event per mention in the delivery', async () => {
      const result = await runHandleTriggerResolveEventsMethod({
        body: { team_id: 'T1', event: { type: 'message', text: 'hey', user: 'U9' } },
      })

      // The WHOLE emission — one delivery often produces events for other triggers too.
      expect(result).toEqual({ eventScopeId: 'T1', events: [{ name: 'onMention', data: { … } }] })
    })

    it('registers one webhook covering this trigger', async () => {
      // the registration path belongs here too — not in a group named after the SYSTEM method
      const result = await runHandleTriggerUpsertWebhookMethod({ callbackUrl: 'https://cb/x', triggers: [] })

      expect(result).toEqual({ webhookData: { id: 'hook_1' } })
    })
  })
})
```

Polling is about state — the first poll seeds and emits nothing, later polls emit only what is new:

```js
const result = await runHandleTriggerPollingForEventMethod({
  eventName: 'onMessageFromQuery', triggerData: { query: 'x' }, state: { messages: [{ ts: '1.1' }] },
})

expect(result).toEqual({ events: [{ ts: '3.3' }], state: { messages: [{ ts: '1.1' }, { ts: '3.3' }] } })
```

Webhook lifecycle (`handleTriggerUpsertWebhook` / `DeleteWebhook`) is asserted like any action — the whole
history entry for the registration call, the whole returned `webhookData` — and it lives inside a trigger's
`describe` like everything else. One registration serves every trigger, so exercise it under the trigger
whose delivery the rest of that group covers rather than repeating it under all of them.

> **The live trigger set is out of reach.** `triggers` is resolved from persisted flow instances, which a
> test has none of, so whatever array a subscribe-path invocation carries is discarded silently. Do not
> build a test on it — see *What the harness cannot reach* in
> [13-runtime-contracts.md](13-runtime-contracts.md).

## Files API

Hand a backend to `runServiceMethod({ createFiles })` — the same seam the platform fills in production
([10-files-api.md](10-files-api.md)). Storage is real, so the test asserts **what ended up saved** rather
than what a stub was asked for.

```js
beforeAll(() => { httpMock = createNockMock(); filesAPI = createFilesSandbox() })
afterEach(() => { httpMock.reset(); filesAPI.reset() })
afterAll(() => { httpMock.dispose(); filesAPI.dispose() })

function runMethod({ methodName, methodData, data }) {
  return runServiceMethod({ serviceId, serviceEntryPointPath, configs,
    createFiles: filesAPI.inMemory, ...data, methodName, methodData })
}
```

```js
filesAPI.stored()        // [{ key, scope, filename, contents, size, ttl, generateUrl, … }]
filesAPI.urlFor('flow/test-flow-id/a.pdf')   // the URL the service hands back to the flow
filesAPI.keys() · contents(scope, name) · read(scope, name)
filesAPI.calls()         // [{ method, scope, filename, size, options }] — options as the service BUILT them
filesAPI.seed('FLOW', 'a.pdf', 'BYTES')      // storage an earlier run left behind
filesAPI.failNext(body, 507)                 // one-shot failure
```

Use `inMemory`: a saved file's URL is an output the flow reads, not something a third party fetches, so
local storage behind a `file://` URL is the whole requirement. The clock is frozen, so `createdAt` and
`urlExpiresAt` are byte-stable and a whole-object compare on a result works at all. `calls()` is where an
inverted option spread shows — `options` is the object the service built, so `options.filename` is whichever
name actually won.

With the client injected **nothing goes over the wire**, which is itself worth asserting: `httpMock.history`
then holds the vendor's calls and nothing else, so a whole-array compare on it proves the upload never
reached the platform's file service.

The full contract, limits included, is in the docstring at the top of `sandbox/files-mock.js`.
