# Kinds of test

Jadpo works out a test's kind from what it calls, so you don't declare it.
The report counts each kind separately, because each proves something
different: an action test doesn't prove that the route decodes input or
checks who is signed in. `jadpo test --kind route` runs one kind.

| Kind | The test calls | It proves |
|---|---|---|
| pure | functions only | the calculation |
| action | actions and queries directly | the behaviour and the data it writes |
| route | a route, through `request` | the whole HTTP boundary: input, sign-in, policy, failures, output |
| schedule | a schedule, through `.run()` | what one run of the schedule does |

A test that calls actions to set up its data and then makes a request is a
route test. A fifth kind, recording tests, has no `test` declaration; it
is described at the end of this topic.

## Action tests

An action test calls actions and queries directly. Calling an action is an
ordinary call, with no `call` word.

Only actions write records
([The operations every entity gets](the-operations-every-entity-gets.md)).
So a test changes data through the action that does it, which is what an
action test proves, and a fixture builds the data it starts from
([Shared test data: fixtures](shared-test-data-fixtures.md)). A test may
read straight from an entity with `get`, as it may call a query, and
either makes it an action test with its own database. A write straight to
an entity is `semantic.not_callable`:

```jadpo
test "a shelf counts its visits" {
    var shelf: Shelf = attempt Shelf.update("0192f0c4-3b5e-7c1a-9d2e-4f6a8b0c1d2e") {
        set: {
            visits: 3
        }
    }
    assert shelf.visits == 3
}
```

```text
error: A test can't call `Shelf.update`
 --> domains/shop/entities/shelf.test.jadpo:2:32
  |
2 |     var shelf: Shelf = attempt Shelf.update("0192f0c4-3b5e-7c1a-9d2e-4f6a8b0c1d2e") {
  |                                ^^^^^^^^^^^^ writes a record
  reason: Only actions write records (area 6), so a test changes data through the action that does it, which is what it proves; a fixture, a test-only action, builds the data a test starts from. A test may still read, with `Shelf.get` and queries.
  next: Call the action that writes `Shelf`, or move the write into a fixture (agent)
  rule: semantic.not_callable
  help: https://jadpo.dev/docs/errors/semantic.not_callable

check failed: 1 error, 4 files
```

## Route tests

A route test sends a typed request with `request { }`. The request goes
through the real HTTP layer Jadpo generates for your routes, without a
network:

```jadpo
// domains/planning/routes/todos.test.jadpo
test "a due date in the past is rejected" {
    clock: "2026-01-15T12:00:00Z"

    var alice: Principal.user = alice_with_todos()
    var response: Response = request {
        method: HttpMethod.post
        path: "/todos"
        as: alice
        input: CreateTodo {
            title: "Late"
            due_at: "2026-01-15T11:59:59Z"
        }
    }
    assert response.status == 422
    assert response.code == "due_date_in_past"
}
```

The request names its route by `method:` and by its path as
`jadpo routes` prints it. Each placeholder in the path gets its value in
`params:`, as in `path: "/todos/{todo_id}"` with
`params: { todo_id: todo.id }`. `input:` is the route's `Input`. A
generated magic-link route takes the built-in input the API record names
([Magic links in tests](in-development-and-tests.md#magic-links-in-tests)). The
request goes through the runtime's HTTP component exactly as a client's
would, and carries your app's own `Origin`, as a browser's does.

`as:` signs someone in for the request, through the application's real
authentication: a browser session, an API token or, for
`Principal.service(service)`, a service key
([Signing in, end to end](starting-and-ending-a-session.md#signing-in-end-to-end)). Leave
it out and the request is sent with no one signed in.

`request` gives a `Response`, which has:

- `status`, the HTTP status;
- `code`, the failure's code, or `none` on success;
- `output`, the route's typed output, or `none` unless it succeeded.

A route that streams a model's answer gives `pieces`, the text of each
event in order, in place of `output`. Its `code` is that of the error
event that ended the stream, if one did
([Streaming a model's answer](streaming-a-model-s-answer.md)). A
defect behind the response, which is a bug such as an overflow, ends the
test as `test.fault`, with its frames.

In this example the route lets anyone call it, so the test signs no one
in. `add_memo` reaches no entity, so the root `policy { }` says who may
call it ([Actions](actions.md)):

```jadpo
type Memo = Text {
    max_length: 200
}

type AddMemo = Input {
    text: Memo
}

type MemoView = Output {
    text: Memo
}

action add_memo(input: AddMemo) -> MemoView {
    return MemoView {
        text: input.text
    }
}

routes Memos {
    prefix: /memos

    POST / {
        authentication: RouteAuthentication.none
        input: AddMemo
        output: MemoView
        status: SuccessStatus.created
        run: add_memo(input)
    }
}

policy {
    actions: {
        add_memo: [Access.public]
    }
}

test "a memo comes back as it was sent" {
    var response: Response = request {
        method: HttpMethod.post
        path: "/memos"
        input: AddMemo {
            text: "Call the plumber"
        }
    }
    assert response.status == 201
    assert response.code == none
    assert response.output == MemoView {
        text: "Call the plumber"
    }
}
```

## Idempotency keys

An idempotency key is a value a client sends with a write so that, if the
request is sent again, the write happens once. A route test sends a fresh
`Idempotency-Key` with every request but a `GET`, as a real client would.
So a route on an API with `idempotency: Idempotency.required`, which
refuses a write without a key, is tested through its success like any
other ([Idempotent writes](idempotent-writes.md)).

To test what a repeat gets back, or how reusing a key for another request
is refused, give the key yourself with `idempotency_key:`. The request
sends that key instead of a fresh one.

This API refuses a write without a key:

```jadpo
application {
    apis: {
        main: {
            prefix: /
            idempotency: Idempotency.required
        }
    }
}
```

The first test sends no key of its own and is answered as any client
would be. The other two send `memo-1` twice. A repeat gets the first
answer back and leaves no second memo. The same key with another memo is
a 422 with the code `idempotency_key_reused`:

```jadpo
use notes/actions { AddMemo }

test "a memo is left through its route, with a fresh key" {
    var response: Response = request {
        method: HttpMethod.post
        path: "/memos"
        input: AddMemo {
            text: "Call the plumber"
        }
    }
    assert response.status == 201
}

test "a memo sent again with its key is left once" {
    var first: Response = request {
        method: HttpMethod.post
        path: "/memos"
        idempotency_key: "memo-1"
        input: AddMemo {
            text: "Call the plumber"
        }
    }
    var resent: Response = request {
        method: HttpMethod.post
        path: "/memos"
        idempotency_key: "memo-1"
        input: AddMemo {
            text: "Call the plumber"
        }
    }
    assert resent.status == 201
    assert resent.output == first.output
    var on_file: Response = request {
        method: HttpMethod.get
        path: "/memos"
    }
    match first.output {
        some(memo) -> {
            assert on_file.output == [memo]
        }
        none -> {
            assert first.output != none
        }
    }
}

test "a key sent again with another memo is refused" {
    var first: Response = request {
        method: HttpMethod.post
        path: "/memos"
        idempotency_key: "memo-1"
        input: AddMemo {
            text: "Call the plumber"
        }
    }
    assert first.status == 201
    var other: Response = request {
        method: HttpMethod.post
        path: "/memos"
        idempotency_key: "memo-1"
        input: AddMemo {
            text: "Fix the tap"
        }
    }
    assert other.status == 422
    assert other.code == "idempotency_key_reused"
}
```

`idempotency_key:` takes text. The runtime checks it as it checks a
client's, so a key that isn't 1 to 255 printable characters is a 400 with
the code `invalid_request`. A `GET` reads no key, so a request to one that
gives `idempotency_key:` is `semantic.request_invalid`.

## Schedule tests

A schedule is work Jadpo runs on a timetable, such as sending reminders
every hour ([Schedules](schedules.md)). A schedule test
runs one with `.run()`, once, at the test's clock:

```jadpo
// domains/planning/schedules/overdue_reminders.test.jadpo
test "an overdue todo gets one reminder" {
    clock: "2026-01-15T12:00:00Z"
    fakes: {
        ReminderMail.send_overdue_reminder: [ReminderReceipt { accepted_at: "2026-01-15T12:00:00Z" }]
    }

    one_overdue_todo()
    overdue_reminders.run()
    assert list.count(ReminderMail.send_overdue_reminder.requests) == 1
}
```

The run calls the schedule's action as the tick does. The tick is the
runtime's regular pass that runs each schedule when it is due
([How a schedule runs](schedules.md#how-a-schedule-runs)). So the
action gets no arguments, and runs as the system, with no signed-in
caller, reaching every tenant (each customer organisation whose data the
app keeps apart). It runs through the runtime's action boundary at the
test's clock, which stays fixed through the run.

`.run()` gives `Unit` whatever the action returns, since a tick keeps no
value. It fails with the action's failures, so a bare call of a schedule
that can fail takes `attempt`, and
`assert overdue_reminders.run() fails ReminderFailed` checks a failing
run.

`.run()` takes no arguments (`type.argument_count`). It writes no run
record and takes no lease, as the tick does: a schedule test proves what
one run does, and the compiler's own live tests prove the tick's records.
What the run emits is delivered once it comes back, as any step's is
([Caches and events](caches-and-events.md)).

A test that runs a schedule is a schedule test, whatever it calls to set
up its data, a `request` included. `--kind schedule` runs only those.

An entity's built-in purge schedule has no name a test can write, so no
test runs it. Purge is the compiler's own operation, proven by the
compiler's live tests rather than each application's
([The purge schedule](schedules.md#the-purge-schedule)).

Only a test runs a schedule by hand. In the application the tick runs it
when it is due, and an operator runs it with `jadpo schedules run`. A test
runs a schedule it can see, as it calls an action, and anything else that
runs one is `semantic.not_callable`:

```jadpo
/// Tidies up at once.
action tidy_now() -> Unit {
    tidy_up.run()
}
```

```text
error: An action can't run schedule `tidy_up`
 --> domains/planning/actions/tidy_now.jadpo:3:5
  |
3 |     tidy_up.run()
  |     ^^^^^^^^^^^ runs a schedule
  reason: Only a test runs a schedule by hand, with `.run()`, to prove what one run does; in the application the tick runs it when it is due, and an operator runs it now with `jadpo schedules run`.
  next: Call the action `tidy_up` runs, or move this into the schedule's test (agent)
  rule: semantic.not_callable
  help: https://jadpo.dev/docs/errors/semantic.not_callable

check failed: 1 error, 4 files
```

## Recording tests

A recording is a saved answer from an outside service, kept so the app's
code can be checked against what the service really sends
([Recordings](recordings.md)). Each recording of the app's
own integrations,
`shared/providers/<name>/<version>/recordings/<resource>.<operation>.json`,
is a test of kind `recording`. It is named
`<resource>.<operation>'s recording decodes`, and it reads the recording
as its operation's output through the package's decoders. You don't write
a `test` declaration for it.

`jadpo test --kind recording` runs only these, and giving a recording's
file runs it alone. A recording that doesn't decode is
`test.recording_undecodable` or `test.round_trip_failed`.

Each recording of a prompt's answer,
`prompts/recordings/<prompt>/<name>.json`, is a test of the same kind,
named `<prompt>'s <name> recording decodes`. It is read through each type
the prompt is asked for as ([Testing AI calls](ai-tests.md)). A recording made
from an older version of its prompt passes, with an alert on the report.
