Testing
Testing
How to test an API built with Nuxt Auto API — and how the package tests itself.
Test your API end to end
The most valuable tests go through the real routes: they exercise the whole pipeline (authentication, context
extenders, tenant resolution, permissions, validation, row visibility, hooks) exactly as production does. Use
@nuxt/test-utils with a small fixture app.
A test-only identity
Let a request header pick the caller, so every test can state who it is. Only register this plugin in the fixture, never in the app:
// test/fixtures/api/server/plugins/test-user.ts
import { getHeader } from 'h3'
const USERS: Record<string, any> = {
alice: { id: 1, organizationId: 'org_a', permissions: ['posts:update'] },
bob: { id: 2, organizationId: 'org_b', permissions: [] },
root: { id: 3, organizationId: 'org_a', permissions: ['*'] },
}
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => {
const user = USERS[getHeader(event, 'x-test-user') ?? '']
if (user) {
event.context.user = user
event.context.permissions = user.permissions
}
})
})
Assert the secure behaviour
// test/api.test.ts
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import { setup, fetch } from '@nuxt/test-utils/e2e'
const as = (user?: string) => ({ headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': user } : {}) } })
describe('posts API', async () => {
await setup({ rootDir: fileURLToPath(new URL('./fixtures/api', import.meta.url)) })
it('refuses anonymous writes', async () => {
const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify({ title: 'x' }), ...as() })
expect(res.status).toBe(401)
})
it('only lists the caller\'s organization', async () => {
const { data } = await (await fetch('/api/posts', as('alice'))).json()
expect(data.every((p: any) => p.organizationId === 'org_a')).toBe(true)
})
it('hides other tenants\' rows as not found', async () => {
const res = await fetch('/api/posts/2', as('alice')) // post 2 belongs to org_b
expect(res.status).toBe(404)
})
it('ignores server-owned columns in the body', async () => {
await fetch('/api/posts/1', { method: 'PATCH', body: JSON.stringify({ organizationId: 'org_b' }), ...as('alice') })
const { data } = await (await fetch('/api/posts/1', as('alice'))).json()
expect(data.organizationId).toBe('org_a')
})
})
Write the negative cases first — "bob cannot see alice's rows", "a viewer cannot delete" — they are the ones a
refactor breaks silently. The package's own
test/security.test.ts
is a complete example with a fixture.
An in-memory database
A fixture can create its schema in memory on startup, so every run starts from the same rows:
// test/fixtures/api/server/plugins/00.db.ts
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { initializeDatabase } from '@websideproject/nuxt-auto-api/database'
import * as schema from '../database/schema'
export default defineNitroPlugin(() => {
const sqlite = new Database(':memory:')
sqlite.exec(`
CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, organization_id TEXT);
INSERT INTO posts (title, organization_id) VALUES ('A1', 'org_a'), ('B1', 'org_b');
`)
initializeDatabase(drizzle(sqlite, { schema }), 'better-sqlite3')
})
For D1-specific behaviour (no transactions, the 100-parameter limit) run the same tests against
wrangler dev --local, or use Miniflare's D1 in a Vitest pool.
Unit-test your rules
Permission functions, objectLevel and listFilter are plain functions — test them without a server. The
server utils import nitropack/runtime, which only loads inside a Nitro build, so give plain Vitest a stub for it:
// vitest.config.ts
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
alias: { 'nitropack/runtime': fileURLToPath(new URL('./test/nitro-stub.ts', import.meta.url)) },
},
})
// test/nitro-stub.ts
export const useRuntimeConfig = () => ({})
export const defineNitroPlugin = (fn: any) => fn
import { describe, it, expect } from 'vitest'
import { evaluatePermission } from '@websideproject/nuxt-auto-api/utils'
import { postsAuth } from '../modules/blog/auth'
const ctx = (user: any) => ({ user, permissions: user?.permissions ?? [] }) as any
describe('posts rules', () => {
it('only org admins delete', async () => {
expect(await evaluatePermission(postsAuth.permissions!.delete, ctx({ id: 1, orgRole: 'member' }))).toBe(false)
expect(await evaluatePermission(postsAuth.permissions!.delete, ctx({ id: 1, orgRole: 'admin' }))).toBe(true)
})
it('authors edit their own posts', async () => {
const author = { id: 7, orgRole: 'member' }
expect(await postsAuth.objectLevel!({ authorId: 7 } as any, { ...ctx(author), operation: 'update' })).toBe(true)
expect(await postsAuth.objectLevel!({ authorId: 8 } as any, { ...ctx(author), operation: 'update' })).toBe(false)
})
})
evaluatePermission is the function the pipeline itself uses, so undefined (undeclared) is false and '*'
passes — the same answers as a request.
The package's own suites
For contributors, in packages/nuxt-auto-api:
bun run test # everything (unit, integration, e2e fixtures)
bun run test:unit
bun run test:integration
AUTO_API_DIST=1 bunx vitest run test/basic.test.ts test/security.test.ts # e2e against the BUILT module
| Directory | What it covers |
|---|---|
test/unit/ | Pure functions: filters, sorting, cursors, permissions, tenancy, includes, protected fields, adapters, plugins |
test/integration/ | Handlers against a real SQLite database: CRUD, bulk, soft delete and cascade, M2M, includes, aggregations |
test/security.test.ts | The security guarantees through a real Nuxt server (fixture test/fixtures/security) |
test/basic.test.ts | A basic app through a real Nuxt server (fixture test/fixtures/basic) |
test/module.test.ts | Module wiring that only a loaded Nuxt exercises (server auto-imports) |
CI runs lint, typecheck, the full suite, the e2e suites against the built module, and — informationally — the suite against the Nuxt 5 nightly.
Frontend Composables
TanStack Query (Vue Query) composables for every generated route: queries, mutations with cache invalidation, bulk, aggregates, M2M, permissions and custom endpoints. They are auto-imported; they can also be imported from @websideproject/nuxt-auto-api/composables.
Pagination
@websideproject/nuxt-auto-api supports offset (page) pagination and cursor (keyset) pagination. Every list is capped: ?limit defaults to pagination.defaultLimit (20) and can never exceed pagination.maxLimit (100).