# App Testing

Tests live in `tests/` as `*.test.js` files and run with `test_run` (or `gipity test`). A test calls your functions over REST (`ctx.fn.call`) and/or imports your app's own modules directly. **You do NOT need to `gipity deploy` before testing** - the run registers your functions from the current working tree first, so a freshly-written or just-edited function is callable immediately. Every template has a test story - frontend-only ones included. For writing the functions under test see [app-development](app-development.md).

**In test files, do NOT `import` `node:test` or `node:assert`** - the harness provides `test()` and `assert` as globals. Writing `import { test } from 'node:test'` or `import assert from 'node:assert/strict'` will cause a duplicate-identifier SyntaxError.

**Import the module you're testing - `functions/`, `src/`, AND `tests/` are all mounted beside the test.** A function helper (`import { sanitize } from '../../functions/song-render/sanitize.js'`) and a frontend module (`import { decideWinner } from '../src/js/game.js'`, including kits under `src/packages/`) both resolve. That's how a frontend-only app - a game, a camera app - unit-tests its own logic: keep the rules in a module that doesn't touch `document` at import time, and test it directly. **Everything under `tests/` is mounted beside your test files** - only `*.test.js`/`*.test.mjs` execute as tests, so shared helper modules (`import { makeUser } from '../helpers/seed.js'`) and binary fixtures (`readFileSync('tests/fixtures/sample.pdf')`) both just work; never base64-inline a fixture into a test file. The harness is Node, not a browser: there's no DOM, so verify rendering with `gipity page inspect <url>` / `gipity page eval` instead.

## Writing a test file

Create a test file for each function. `ctx.fn.call` / `callAs` return your function's return value directly - the `{ data }` envelope is unwrapped, exactly like the client `Gipity.fn` - so read `result.field` (an error your function returns is `result.error`). They throw on a 4xx/5xx.

```js
// tests/api/get-items.test.js
test('get-items returns items for a category', async (ctx) => {
    const result = await ctx.fn.call('get-items', { category: 'fruit' });
    assert.ok(Array.isArray(result), 'should return an array');
});

test('get-items handles missing category', async (ctx) => {
    const result = await ctx.fn.call('get-items', {});
    assert.ok(result.error, 'should return an error');
});
```

Test happy path AND edge cases. Run with `test_run`. Filter: `test_run filter_path=api/get-items`. To see which test files exist (and which a filter would select) WITHOUT running them, use `gipity test list [path]` - cheap discovery, no sandbox run.

> **Every test FILE runs against its own auto-reset database - you don't write teardown.** `ctx.fn.call`/`callAs` invoke your real functions (registered from the current working tree at run start), but during a test run they read/write a **throwaway copy of your database** (built from your own `migrations/`, plus any seed files the `sql` phase declares via an explicit `seed: <dir>` key), never the live one. The harness **resets it - truncate + reseed - before every file**, so files can't see each other's rows, and rows a test creates never reach the deployed app, never show on the live page, and never need cleaning up. Just write the assertion:
> ```js
> test('save-item then it appears in the list', async (ctx) => {
>     const { short_guid } = await ctx.fn.call('save-item', { name: 'test widget' });
>     const list = await ctx.fn.call('list-items', {});
>     assert.ok(list.some(i => i.short_guid === short_guid));
> });
> ```
> `ctx.cleanup(fn)` still runs after the suite (reverse order) but is now only for side-effects **outside** that database. **Reference/lookup rows that must survive the reset have two supported paths** (there is no automatic `seeds/` folder - don't drop a `seeds/` dir and expect it to load): (1) **`test.preserve` (simplest):** keep the `INSERT` in a migration and list the table under `test.preserve` in `gipity.yaml`; the reset's truncate skips preserved tables, so the rows stay. Use this for runtime-written config rows too. (2) **a seed dir:** point the `sql` phase at an explicit `seed: <dir>` (see [deploy](deploy.md)); the reset re-runs those `.sql` files after truncating.
>
> `test.preserve` shape in `gipity.yaml` (list every table the reset's truncate must skip):
> ```yaml
> test:
>   preserve:
>     - app_settings
>     - plans
> ```

## Test mode: `ctx.isTest`

Inside a test run your functions see **`ctx.isTest === true`** - a first-class flag (never sniff the request IP for "am I a test"). Use it to branch test-only behavior, e.g. skip your app's own rate limiting so a function-heavy suite doesn't throttle itself:

```js
if (!ctx.isTest) await enforceRateLimit(ctx);  // tests bypass your limiter
```

The platform also honors it to **suppress outbound push**: `notify()` becomes a no-op that returns `{ suppressed: true }` during tests, so a suite can never spam your real subscribers - you don't have to gate notifications yourself.

**What else is isolated during a test run** (so async side-effects can't escape the throwaway schema):
- **Record triggers don't fire.** A write your test makes lands in the test schema, so the platform skips the record-change → workflow trigger for it. Your workflow logic isn't exercised end-to-end by a test write - assert on the function's own output instead, and test workflow steps directly where you can.
- **`jobs.submit()` is suppressed** - it enqueues nothing (so a test never burns real compute/credits or runs a job's `on_complete` against your real data) but still returns a well-formed `{ run_guid, status: 'suppressed' }` with a **fresh, unique `run_guid` per call** (a real `jr_…` guid, just like a live submit). Your submit function's normal `return { run_guid }` path works unchanged under test - and because each guid is unique, app code that stores or matches a DB row on run_guid works exactly as in prod, so don't synthesize a fake run_guid in app code to keep the pipeline testable. (A later `jobs.status(run_guid)` returns "not found" since nothing ran, so assert on the submit response, not on a completed job.)
- **Generative services still run for real.** `llm()`, `image()`, `tts()`, etc. make real provider calls and bill real credits during a test (so you can assert on a real response). If you don't want that cost on every run, gate them yourself with `if (!ctx.isTest)` or stub the call when `ctx.isTest`.
- **The platform per-IP rate limit is waived.** All test runs share one sandbox egress IP, so the platform's 300-requests/5-min per-IP limit (on function calls, `ctx.services.*`, uploads, and records) is skipped for a project's own test run — a call-heavy suite won't 429 itself, and concurrent runs from other projects don't throttle each other. (This is separate from your *app's own* limiter, which you still gate with `ctx.isTest` as shown above.)

### Request context in a test: your function sees a real caller IP

`ctx.fn.call` / `callAs` are **real HTTPS requests** made from the test sandbox, so the function under test receives a genuine request context - including an `x-forwarded-for` caller IP (the sandbox's egress IP, physically located wherever the platform runs). It is *not* an empty or synthetic context.

This matters for any function that branches on the caller's request rather than on its params. A geolocation function that falls back from GPS coords to a visitor-IP lookup will, in a test, take the **visitor-IP branch** - never the "no IP at all" branch:

```js
// Function: coords -> GPS; else caller IP -> 'ip'; else server IP -> 'server-ip'
test('falls back to an IP lookup when no coords are given', async (ctx) => {
    const r = await ctx.fn.call('log-location', {});   // no coords
    assert.equal(r.source, 'ip');        // ✅ the harness call HAS a caller IP
    // assert.equal(r.source, 'server-ip');  // ❌ never reached from a test
});
```

To exercise a branch that depends on the *absence* of request context, gate it with `ctx.isTest` in the function and assert the gated behavior - don't expect the harness to strip the IP.

## Own your fixtures

Every test **file** gets its own database, reset before it starts, so files can't interfere - two files may safely write the same singleton/config row, hold the same fixed ids, or both mutate `ctx.users.alice`'s data. Write the obvious assertion; no naming discipline needed.

- **Build the data a test needs in `setup(fn)` and stash it on `ctx.fixtures`.** `setup` runs once before the file's tests, against the freshly reset database - so you own your starting state:
  ```js
  setup(async (ctx) => { ctx.fixtures.item = await ctx.fn.call('save-item', { name: 'base' }); });
  test('get-item returns it', async (ctx) => {
      const got = await ctx.fn.call('get-item', { id: ctx.fixtures.item.id });
      assert.equal(got.id, ctx.fixtures.item.id);
  });
  ```
- **Don't assert deltas on state outside your app's database** ("the limit *grew*") - plan/usage counters are account-wide and other runs move them. Assert the **absolute** value (`plan === 'pro'` ⇒ storage limit === 250 GB). Your own tables are yours alone, so table-wide COUNT/SUM assertions are fine.

**There is no read-after-write lag to design around.** The app DB is a single primary with strong read-after-write consistency: a value you just wrote is visible to the very next read, in this call and any later one. So a freshly-written value that reads back missing or stale is a **real bug** - a non-atomic multi-write a concurrent call interleaved with, a wrong `LEFT JOIN`, or a delete that raced your insert - **never** "replication lag". Don't paper over it with `setTimeout`/retry/poll loops; find the race.

## Testing `auth: user` functions

`ctx.fn.call()` is unauthenticated (it carries only a public app token), so for a function with `auth: user` it exercises the **logged-out** path - the call rejects with `401 LOGIN_REQUIRED` (a non-member calling an `auth: member` function rejects with `403 FORBIDDEN`). The thrown error carries `err.status` and `err.code` and puts both tokens in its message - assert on those stable tokens, never on the human message text:

```js
test('save-doc rejects anonymous callers', async (ctx) => {
    await assert.rejects(() => ctx.fn.call('save-doc', { title: 't', body: 'b' }), /401 LOGIN_REQUIRED/);
});
```

**For any function that PERSISTS user data, the anonymous gate is not enough** - it never writes a row, so save's happy path stays unverified. Make the primary assertion a `callAs` happy-path test: save a record as `ctx.users.alice`, then read it back (`list`/`get`) and assert the row is present and owner-scoped:

```js
test('save-activity persists and reads back the row', async (ctx) => {
    const saved = await ctx.fn.callAs(ctx.users.alice, 'save-activity', { kind: 'run', minutes: 30 });
    const list  = await ctx.fn.callAs(ctx.users.alice, 'list-activity', {});
    assert.ok(list.some(a => a.short_guid === saved.short_guid), 'alice reads back her saved row');
});
```

To test the **signed-in** path and **cross-user isolation**, use `ctx.fn.callAs(user, name, params)`. The harness provides two ready-made signed-in fixture users, `ctx.users.alice` and `ctx.users.bob`:

```js
test('a user only sees their own documents', async (ctx) => {
    const saved = await ctx.fn.callAs(ctx.users.alice, 'save-doc', { title: 'mine', body: '...' });
    const mine  = await ctx.fn.callAs(ctx.users.alice, 'list-docs', {});
    const other = await ctx.fn.callAs(ctx.users.bob, 'list-docs', {});
    assert.ok(mine.some(d => d.short_guid === saved.short_guid), 'alice sees her doc');
    assert.ok(!other.some(d => d.short_guid === saved.short_guid), 'bob does not see it');
});
```

For `auth: member` functions, call as the app owner - `ctx.fn.callAs(ctx.users.owner, …)` - the owner is a member of their own project, and their test-run writes go to the same throwaway test database. alice/bob are **not** members, so they double as the negative case:

```js
test('only a member can approve', async (ctx) => {
    await assert.rejects(() => ctx.fn.callAs(ctx.users.alice, 'approve-request', { id: 1 }), /403 FORBIDDEN/);
    const done = await ctx.fn.callAs(ctx.users.owner, 'approve-request', { id: ctx.fixtures.req.id });
    assert.equal(done.status, 'approved');
});
```

## Verifying against the live app

Prefer `gipity test` for behavior coverage: it runs against a throwaway database, so nothing leaks. But a final end-to-end sanity pass often drives the **live** deployed app, and there the writes are real.

- **Undo the writes with a checkpoint - don't hand-scrub rows.** `gipity db checkpoint` snapshots every table, `gipity db restore` puts them all back (`--keep` to re-run the same test). `gipity page eval --restore-db` wraps one browser run in that pair automatically. The snapshot lives in a hidden schema, so the app never sees it, and restore is a single transaction - no marker columns, no targeted `DELETE`, nothing left in the user's data.

```bash
gipity db checkpoint
gipity fn call submit-request '{"email":"smoke@example.com","order":"1042"}' --anon
# ... drive the flow, run the workflow, click through the page ...
gipity db restore   # every table back exactly as the user left it
```

- **Call functions the way the right persona would.** `gipity fn call <name> '<json>'` calls as your signed-in account (the owner persona). For a public function a signed-out visitor hits, use `gipity fn call <name> '<json>' --anon` - it takes the same anonymous path a real visitor does and returns the same unwrapped value (no raw `curl`, no `{"data":{...}}` envelope to special-case).
- **Sending an image / audio / document to a function? Use `--file <field>=@<path>`, not a hand-rolled base64.** For a vision, OCR, transcription, or upload function that expects file bytes, `gipity fn call ocr --file image=@receipt.png` reads the file, base64-encodes it, and attaches it under `image` in the JSON body - no `base64 | python json.dumps` dance, and no "Argument list too long" from stuffing a photo into a shell argument. Repeat `--file` for multiple attachments and combine with an inline body: `gipity fn call ocr '{"lang":"en"}' --file page=@scan.jpg`. (Same flag on `gipity job run` for a long-running job.)

## Testing file serving with `ctx.upload`

To test anything that **serves a stored file** - a download link, a share page, an `<audio>`/`<img>` source, a `storage.fileUrl` / `Gipity.fileUrl` resolution - you need a *real* file guid. **Don't fabricate a `storage_guid` string**: `storage.fileUrl(guid)` (and the durable serve endpoint) reject a guid that doesn't point at a real stored blob, so a made-up value makes the serve path fail.

`ctx.upload(name, opts?)` creates a **real** stored file server-side (no presigned round-trip) and returns its metadata - `{ guid, url, size, is_public, content_type, variants, ... }`, the same shape `Gipity.upload` returns. The `guid` genuinely resolves through `storage.fileUrl` / `Gipity.fileUrl`. The file auto-cleans after the suite (no `ctx.cleanup` needed for it).

```js
test('a private file resolves to a durable, serve-able URL', async (ctx) => {
    // Make a real file, then register it the way your app does.
    const up = await ctx.upload('song.mp3', { contentType: 'audio/mpeg', sizeBytes: 1234 });
    const { node_id } = await ctx.fn.callAs(ctx.users.alice, 'upload-register', {
      name: 'song.mp3', storage_guid: up.guid, size_bytes: 1234, content_type: 'audio/mpeg',
    });
    ctx.cleanup(() => ctx.fn.callAs(ctx.users.alice, 'nodes-delete', { node_ids: [node_id] }));

    // A function that resolves a share/delivery now returns a working URL.
    const share = await ctx.fn.call('public-resolve', { node_id });
    assert.ok(share.url.includes('/serve?t='), 'branded serve URL, not a raw S3 link');
});
```

`opts`: `{ contentType?, public?, sizeBytes?, bytesBase64?, table?, recordId?, path? }`. Pass `sizeBytes` to synthesize that many zero bytes (default 8) - synthesized bytes aren't decodable media, so **no variants are generated** even with an image content type - or `bytesBase64` for real content, e.g. a tiny PNG to exercise a `thumbnail` variant. `public: true` yields a permanent CDN URL; the default (private) yields a durable serve URL that re-signs storage per request. Files are small-only (max 5 MB) - this is the fixture path, not the 30 GB presigned flow.

