Testing

How to test an API built with Nuxt Auto API — and how the package tests itself.

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
DirectoryWhat 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.tsThe security guarantees through a real Nuxt server (fixture test/fixtures/security)
test/basic.test.tsA basic app through a real Nuxt server (fixture test/fixtures/basic)
test/module.test.tsModule 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.

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.