{
  "name": "app-testing",
  "title": "App Testing - Function Tests",
  "description": "Testing deployed functions: the test()/ctx.fn.call/callAs contract, the isolated auto-reset test database, ctx.isTest, concurrency-safe fixtures, auth:user and auth:member tests (ctx.users.owner), and ctx.upload file-serving tests",
  "guid": "sk_plat_atst",
  "category": "App development",
  "requiredTools": [
    "test_run",
    "fn_manage"
  ],
  "content": "# App Testing\n\nTests live in `tests/` as `*.test.js` files and run with `test_run` (or `gipity test`). A test calls your **deployed** functions over REST (`ctx.fn.call`) and/or imports your app's own modules directly. Every template has a test story - frontend-only ones included. For writing the functions under test see [app-development](app-development.md).\n\n**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.\n\n**Import the module you're testing - both `functions/` and `src/` are 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. The harness is Node, not a browser: there's no DOM, so verify rendering with `gipity page inspect <url>` / `gipity page eval` instead.\n\n## Writing a test file\n\nCreate 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.\n\n```js\n// tests/api/get-items.test.js\ntest('get-items returns items for a category', async (ctx) => {\n    const result = await ctx.fn.call('get-items', { category: 'fruit' });\n    assert.ok(Array.isArray(result), 'should return an array');\n});\n\ntest('get-items handles missing category', async (ctx) => {\n    const result = await ctx.fn.call('get-items', {});\n    assert.ok(result.error, 'should return an error');\n});\n```\n\nTest 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.\n\n> **Tests run against an isolated, auto-reset test database - you don't write teardown.** `ctx.fn.call`/`callAs` invoke your real deployed functions, 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 run**, so rows a test creates never reach the deployed app, never show on the live page, and never need cleaning up. Just write the assertion:\n> ```js\n> test('save-item then it appears in the list', async (ctx) => {\n>     const { short_guid } = await ctx.fn.call('save-item', { name: 'test widget' });\n>     const list = await ctx.fn.call('list-items', {});\n>     assert.ok(list.some(i => i.short_guid === short_guid));\n> });\n> ```\n> `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.\n\n## Test mode: `ctx.isTest`\n\nInside 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:\n\n```js\nif (!ctx.isTest) await enforceRateLimit(ctx);  // tests bypass your limiter\n```\n\nThe 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.\n\n**What else is isolated during a test run** (so async side-effects can't escape the throwaway schema):\n- **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.\n- **`jobs.submit()` is suppressed** - it returns `{ status: 'suppressed' }` and enqueues nothing, so a test never burns real compute/credits or runs a job's `on_complete` against your real data.\n- **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`.\n- **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.)\n\n### Request context in a test: your function sees a real caller IP\n\n`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.\n\nThis 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:\n\n```js\n// Function: coords -> GPS; else caller IP -> 'ip'; else server IP -> 'server-ip'\ntest('falls back to an IP lookup when no coords are given', async (ctx) => {\n    const r = await ctx.fn.call('log-location', {});   // no coords\n    assert.equal(r.source, 'ip');        // ✅ the harness call HAS a caller IP\n    // assert.equal(r.source, 'server-ip');  // ❌ never reached from a test\n});\n```\n\nTo 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.\n\n## Own your fixtures, keep files independent\n\nEach run starts from a clean, seeded database, so cross-**run** pollution is gone. But test **files still run concurrently** against that one shared test database, so don't let two files fight over the same row:\n\n- **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 schema - so you own your starting state instead of depending on a shared global:\n  ```js\n  setup(async (ctx) => { ctx.fixtures.item = await ctx.fn.call('save-item', { name: 'base ' + ctx.testId }); });\n  test('get-item returns it', async (ctx) => {\n      const got = await ctx.fn.call('get-item', { id: ctx.fixtures.item.id });\n      assert.equal(got.id, ctx.fixtures.item.id);\n  });\n  ```\n- **Namespace anything uniquely-named with `ctx.testId`** (a stable per-file id) so a sibling file can't collide on a name / email / key.\n- **`ctx.users.alice` / `ctx.users.bob` are shared sign-in identities.** Their app data is isolated per run, but two files both calling `callAs(alice, …)` still touch the same in-schema row - if both mutate alice's profile they can race. Give each file its own entity, or key the data by `ctx.testId`.\n- **Verify by reading back the exact thing you created, by id - not by scanning a shared list.** A listing/search/inbox endpoint is shared, growing, and **paged**; under concurrency your just-created row can fall off page 1, so `list.items.some(i => i.id === mine)` passes in isolation and flakes in a full run. Point-read instead - `get`/`info` by the id the create call returned.\n- **Make write functions return the id(s) they create**, so the test has something to point-read. (A \"deliver\" that links files into a recipient inbox should return those node ids; the test then asserts `node-info(id)`, never \"find my file by name in the inbox\".)\n- **Don't assert deltas on shared global state** (\"the limit *grew*\"). Assert the **absolute** value (`plan === 'pro'` ⇒ storage limit === 250 GB), not a change relative to an unknown starting point. Same for whole-table tallies (COUNT/SUM \"totals\"): they move while sibling files insert - scope the aggregate to rows you created (filter by `ctx.testId`) instead of asserting the table-wide number.\n\n**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.\n\n## Testing `auth: user` functions\n\n`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:\n\n```js\ntest('save-doc rejects anonymous callers', async (ctx) => {\n    await assert.rejects(() => ctx.fn.call('save-doc', { title: 't', body: 'b' }), /401 LOGIN_REQUIRED/);\n});\n```\n\n**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:\n\n```js\ntest('save-activity persists and reads back the row', async (ctx) => {\n    const saved = await ctx.fn.callAs(ctx.users.alice, 'save-activity', { kind: 'run', minutes: 30 });\n    const list  = await ctx.fn.callAs(ctx.users.alice, 'list-activity', {});\n    assert.ok(list.some(a => a.short_guid === saved.short_guid), 'alice reads back her saved row');\n});\n```\n\nTo 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`:\n\n```js\ntest('a user only sees their own documents', async (ctx) => {\n    const saved = await ctx.fn.callAs(ctx.users.alice, 'save-doc', { title: 'mine', body: '...' });\n    const mine  = await ctx.fn.callAs(ctx.users.alice, 'list-docs', {});\n    const other = await ctx.fn.callAs(ctx.users.bob, 'list-docs', {});\n    assert.ok(mine.some(d => d.short_guid === saved.short_guid), 'alice sees her doc');\n    assert.ok(!other.some(d => d.short_guid === saved.short_guid), 'bob does not see it');\n});\n```\n\nFor `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:\n\n```js\ntest('only a member can approve', async (ctx) => {\n    await assert.rejects(() => ctx.fn.callAs(ctx.users.alice, 'approve-request', { id: 1 }), /403 FORBIDDEN/);\n    const done = await ctx.fn.callAs(ctx.users.owner, 'approve-request', { id: ctx.fixtures.req.id });\n    assert.equal(done.status, 'approved');\n});\n```\n\n## Verifying against the live app - and cleaning up after yourself\n\nPrefer `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 - the user opens their app and finds your smoke-test rows sitting in it.\n\n- **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).\n- **Mark every live verification row with a recognizable marker**, then delete by that marker when done and confirm the empty state:\n\n```bash\ngipity fn call submit-request '{\"email\":\"smoke@example.com\",\"order\":\"1042\"}' --anon\n# ... drive the flow ...\ngipity db query \"DELETE FROM requests WHERE email = 'smoke@example.com'\"\ngipity db query \"SELECT count(*)::int AS remaining FROM requests\"   # expect 0\n```\n\nUse one fixed marker per run (an email like `smoke@example.com`, or a `smoke-` prefix on names) so the teardown is a single targeted `DELETE` - never a table-wide wipe.\n\n## Testing file serving with `ctx.upload`\n\nTo 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.\n\n`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).\n\n```js\ntest('a private file resolves to a durable, serve-able URL', async (ctx) => {\n    // Make a real file, then register it the way your app does.\n    const up = await ctx.upload('song.mp3', { contentType: 'audio/mpeg', sizeBytes: 1234 });\n    const { node_id } = await ctx.fn.callAs(ctx.users.alice, 'upload-register', {\n      name: 'song.mp3', storage_guid: up.guid, size_bytes: 1234, content_type: 'audio/mpeg',\n    });\n    ctx.cleanup(() => ctx.fn.callAs(ctx.users.alice, 'nodes-delete', { node_ids: [node_id] }));\n\n    // A function that resolves a share/delivery now returns a working URL.\n    const share = await ctx.fn.call('public-resolve', { node_id });\n    assert.ok(share.url.includes('/serve?t='), 'branded serve URL, not a raw S3 link');\n});\n```\n\n`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.\n"
}
