Savvi Studio

Test Contexts Guide

Essential Guide: Understanding and using test contexts effectively

Overview

Savvi Studio uses a hierarchical test context system built on Vitest's test.extend(). This guide helps you:

  1. Understand available test contexts
  2. Choose the right context for your test
  3. Create custom test contexts
  4. Understand the context hierarchy

Quick Reference

Context Import Path Use When Provides
Base @/tests/config/base.context Graph ops, DB ops, general testing asUser, asAdmin, asSystem, graph, studio
Auth @/tests/fixtures/auth.context Auth, secrets, key operations admin, helpers + all base fixtures
Graph @/tests/fixtures/graph.context Graph-specific with clean state cleanGraph + all base fixtures

Context Hierarchy

tests/config/database.context.ts          # Database lifecycle (worker-scoped)
           ↓
tests/config/base.context.ts              # Core fixtures (asUser, asAdmin, etc.)
           ↓
tests/fixtures/auth.context.ts            # Auth-specific (admin, helpers)
tests/fixtures/graph.context.ts           # Graph-specific (cleanGraph)
tests/fixtures/[custom].context.ts        # Your custom contexts

Inheritance: Each level builds on the previous, adding new fixtures while keeping all parent fixtures available.

Base Context

Location: tests/config/base.context.ts
Extends: DatabaseContext (from database.context.ts)
Use For: Most integration tests

Available Fixtures

asUser(userId, orgId, callback)

Execute database operations with user role and RLS policies applied.

import { test } from '@/tests/config/base.context';

test('user can read own data', async ({ asUser }) => {
  await asUser('user-123', 'org-456', async (client) => {
    const result = await client.query(
      'SELECT * FROM graph.nodes WHERE owner_id = current_setting(\'jwt.claims.sub\')::text'
    );
    expect(result.rows.length).toBeGreaterThan(0);
  });
});

Key Points:

  • ✅ RLS policies enforced
  • ✅ Session context set automatically
  • ✅ Uses studio_user role
  • ✅ Transaction automatic
  • ✅ Cleanup automatic

asAdmin(userId, orgId, callback)

Execute database operations with admin role and RLS policies applied.

test('admin can access all org data', async ({ asAdmin }) => {
  await asAdmin('admin-123', 'org-456', async (client) => {
    const result = await client.query(
      'SELECT * FROM graph.nodes WHERE org_id = current_setting(\'jwt.claims.org\')::text'
    );
    expect(result.rows.length).toBeGreaterThan(0);
  });
});

Key Points:

  • ✅ Admin privileges within org
  • ✅ RLS policies enforced
  • ✅ Uses studio_admin role
  • ✅ Still limited to org scope

asSystem(callback)

Execute database operations with system privileges, bypassing RLS.

test('system can modify infrastructure', async ({ asSystem }) => {
  await asSystem(async (client) => {
    // Direct table access, no RLS
    await client.query('TRUNCATE TABLE graph.nodes CASCADE');
    expect(true).toBe(true); // Setup successful
  });
});

Key Points:

  • ⚠️ Bypasses all RLS
  • ⚠️ Use for infrastructure testing only
  • ✅ Full database access
  • ✅ Uses studio_test_role

systemClient

Direct access to database client from worker-scoped pool.

test('direct query access', async ({ systemClient }) => {
  const result = await systemClient.query('SELECT version()');
  expect(result.rows[0].version).toContain('PostgreSQL');
});

Key Points:

  • ⚠️ No RLS enforcement
  • ⚠️ No transaction wrapping
  • ✅ Direct pool access
  • ✅ Good for queries, not mutations

graph(userId, orgId, callback)

Execute graph operations with user context using GraphOperations.

test('create graph node', async ({ graph }) => {
  await graph('user-123', 'org-456', async (ops) => {
    const node = await ops.nodes.create({
      nodeType: 'custom.document',
      externalId: 'doc-123',
      data: { title: 'Test Doc' }
    });
    
    expect(node.owner_id).toBe('user-123');
  });
});

Key Points:

  • ✅ High-level GraphOperations API
  • ✅ User session context
  • ✅ RLS enforced
  • ✅ Type-safe operations

graphAsRoot(callback)

Execute graph operations with root privileges.

test('root can create system nodes', async ({ graphAsRoot }) => {
  await graphAsRoot(async (ops) => {
    const systemNode = await ops.nodes.create({
      nodeType: 'system.config',
      externalId: 'config-123',
      data: { setting: 'value' }
    });
    
    expect(systemNode).toBeDefined();
  });
});

Key Points:

  • ⚠️ Root privileges
  • ⚠️ Use for system setup only
  • ✅ No RLS restrictions
  • ✅ Full graph access

studio(userId, orgId, callback) and studioAsRoot(callback)

Similar to graph operations but for StudioOperations API.

test('studio operations', async ({ studio }) => {
  await studio('user-123', 'org-456', async (ops) => {
    // Studio-specific operations
  });
});

assertions

Helper assertions for checking session context.

test('session context', async ({ assertions, asUser }) => {
  await asUser('user-123', 'org-456', async (client) => {
    await assertions.userIdToBe('user-123');
    
    // Or use promise-based assertion
    await expect(assertions.userId()).resolves.toBe('user-123');
  });
});

When to Use Base Context

Use base context when:

  • Testing graph operations
  • Testing database queries with RLS
  • Testing user/admin access patterns
  • General integration testing

Don't use base context when:

  • Testing auth/secrets operations (use auth context)
  • Need pre-configured AdminOperations (use auth context)

Auth Context

Location: tests/fixtures/auth.context.ts
Extends: BaseContext
Use For: Auth, secrets, JWT, key management tests

Additional Fixtures

admin

Pre-configured AdminOperations instance with MEK initialized.

import { test } from '@/tests/fixtures/auth.context';

test('create secret', async ({ admin }) => {
  const keyId = await admin.secrets.upsertSecret({
    keyName: 'test-key',
    secret: Buffer.from('secret').toString('base64'),
    keyType: 'hmac',
    algorithm: 'aes'
  });
  
  expect(keyId).toBeDefined();
});

Key Points:

  • ✅ AdminOperations ready to use
  • ✅ MEK automatically initialized
  • ✅ No manual setup needed
  • ✅ Cleanup automatic

helpers

Auth-specific test helpers.

test('create test key', async ({ helpers }) => {
  const key = await helpers.createTestKey({
    keyPrefix: 'test',
    keyType: 'hmac',
    markAsSigning: true
  });
  
  expect(key.is_signing_key).toBe(true);
});

test('create key hierarchy', async ({ helpers }) => {
  const { parent, child } = await helpers.createKeyHierarchy();
  
  expect(child.parent_key_name).toBe(parent.key_name);
});

Available Helpers:

  • createTestKey(options) - Create test key with common config
  • createKeyHierarchy(options) - Create parent/child keys
  • verifySecretHash(decryptedKey, dbSecret) - Verify key hash
  • getCurrentVersion(keyName) - Get current key version

When to Use Auth Context

Use auth context when:

  • Testing secret management
  • Testing key rotation
  • Testing JWT operations
  • Testing AdminOperations

Don't use auth context when:

  • Only need database operations (use base context)
  • Don't need AdminOperations

Graph Context

Location: tests/fixtures/graph.context.ts
Extends: DatabaseContext
Use For: Graph tests that need clean state

Additional Fixtures

cleanGraph

Database client with graph tables truncated.

import { test } from '@/tests/fixtures/graph.context';

test('create node in clean graph', async ({ cleanGraph }) => {
  // Tables already truncated
  const result = await cleanGraph.query(
    'SELECT COUNT(*) FROM graph.nodes'
  );
  expect(parseInt(result.rows[0].count)).toBe(0);
  
  // Now create nodes...
});

Key Points:

  • ✅ Graph tables truncated before each test
  • ✅ Clean slate for graph operations
  • ✅ Prevents test interference
  • ⚠️ Use for tests that need isolation

When to Use Graph Context

Use graph context when:

  • Need clean graph state
  • Testing graph-specific functionality
  • Want to avoid interference from other tests

Don't use graph context when:

  • Graph state doesn't matter
  • Want to test with existing data

Creating Custom Contexts

Pattern: Extend Existing Context

// tests/fixtures/custom.context.ts
import { test as baseTest } from '../config/base.context';

export interface CustomContext {
  customSetup: { /* your type */ };
}

export const test = baseTest.extend<CustomContext>({
  customSetup: async ({ asSystem }, use) => {
    // Setup
    const setup = await asSystem(async (client) => {
      // Your setup logic
      return { /* your data */ };
    });
    
    await use(setup);
    
    // Cleanup (if needed)
  }
});

Pattern: Compose Multiple Contexts

// tests/fixtures/full-stack.context.ts
import { test as authTest } from './auth.context';
import { test as graphTest } from './graph.context';

// Combine auth and graph contexts
export const test = authTest.extend({
  // Add graph's cleanGraph to auth context
  cleanGraph: graphTest._extendTest.cleanGraph
});

// Now has: admin, helpers, cleanGraph, and all base fixtures

Pattern: Parameterized Context Factory

// tests/fixtures/permission-context.ts
export function createPermissionTest(level: 'read' | 'write' | 'admin') {
  return baseTest.extend({
    userWithPermission: async ({ asSystem }, use) => {
      const user = await asSystem(async (client) => {
        const { user } = await createUserWithOrgSetup(client);
        await grantPermission(client, user.nodeId, level);
        return user;
      });
      
      await use(user);
    }
  });
}

// Usage
const readTest = createPermissionTest('read');
const adminTest = createPermissionTest('admin');

readTest('read-only access', async ({ userWithPermission }) => { /* test */ });
adminTest('admin access', async ({ userWithPermission }) => { /* test */ });

Decision Tree: Which Context?

Need to test...

├─ Auth operations (secrets, keys, JWT)?
│  └─ Use: Auth Context (admin, helpers)
│
├─ Graph operations with clean state?
│  └─ Use: Graph Context (cleanGraph)
│
├─ Graph operations (state doesn't matter)?
│  └─ Use: Base Context (graph, graphAsRoot)
│
├─ Database operations with RLS?
│  └─ Use: Base Context (asUser, asAdmin)
│
├─ Database operations without RLS?
│  └─ Use: Base Context (asSystem)
│
├─ Pure function (no I/O)?
│  └─ Use: No context (unit test)
│
└─ Custom scenario (3+ tests)?
   └─ Create: Custom Context

Common Patterns

Pattern 1: Using Multiple Contexts

// Can use fixtures from base + auth
import { test } from '@/tests/fixtures/auth.context';

test('full auth flow', async ({ admin, graph, asUser }) => {
  // admin from auth context
  // graph from base context
  // asUser from base context
  
  const key = await admin.secrets.upsertSecret({ /* ... */ });
  
  await graph('user-123', 'org-456', async (ops) => {
    // Use graph operations
  });
  
  await asUser('user-123', 'org-456', async (client) => {
    // Use database client
  });
});

Pattern 2: Conditional Setup

const test = baseTest.extend({
  setupData: async ({ asSystem }, use, testInfo) => {
    // Can access test metadata
    const needsPermissions = testInfo.title.includes('permission');
    
    const data = await asSystem(async (client) => {
      const user = await createUser(client);
      
      if (needsPermissions) {
        await grantPermissions(client, user);
      }
      
      return user;
    });
    
    await use(data);
  }
});

Pattern 3: Fixture Dependencies

const test = baseTest.extend({
  // First level
  user: async ({ asSystem }, use) => {
    const user = await createUser();
    await use(user);
  },
  
  // Second level depends on user
  workspace: async ({ user, asSystem }, use) => {
    const workspace = await createWorkspace(user.id);
    await use(workspace);
  },
  
  // Third level depends on workspace
  document: async ({ workspace, asSystem }, use) => {
    const doc = await createDocument(workspace.id);
    await use(doc);
  }
});

// Request only what you need - dependencies auto-resolved
test('test 1', async ({ user }) => { /* just user */ });
test('test 2', async ({ workspace }) => { /* user + workspace */ });
test('test 3', async ({ document }) => { /* user + workspace + document */ });

Best Practices

✅ DO

  1. Use the most specific context

    // Good - specific context
    import { test } from '@/tests/fixtures/auth.context';
    test('secret ops', async ({ admin }) => { /* ... */ });
    
  2. Extend contexts for shared setup

    const test = authTest.extend({
      testKey: async ({ admin }, use) => { /* shared key setup */ }
    });
    
  3. Request only needed fixtures

    // Good - only what's needed
    test('simple test', async ({ user }) => { /* ... */ });
    
    // Not: async ({ user, workspace, document, admin }) => { /* only need user */ }
    
  4. Use worker scope for expensive setup

    const test = baseTest.extend({
      expensiveSetup: [async ({}, use) => {
        const resource = await expensiveOperation();
        await use(resource);
        await cleanup(resource);
      }, { scope: 'worker' }] // Shared across file
    });
    

❌ DON'T

  1. Don't create AdminOperations manually

    // Bad
    test('secret ops', async ({ asSystem }) => {
      await asSystem(async (client) => {
        const admin = new AdminOperations(client);
        // ...
      });
    });
    
    // Good
    import { test } from '@/tests/fixtures/auth.context';
    test('secret ops', async ({ admin }) => {
      // admin already configured
    });
    
  2. Don't use asSystem for user operations

    // Bad - bypasses RLS
    test('user access', async ({ asSystem }) => {
      await asSystem(async (client) => {
        // RLS not enforced!
      });
    });
    
    // Good - enforces RLS
    test('user access', async ({ asUser }) => {
      await asUser('user-123', 'org-456', async (client) => {
        // RLS enforced
      });
    });
    
  3. Don't duplicate fixture logic

    // Bad - duplicated across tests
    test('test 1', async ({ admin }) => {
      const key = await admin.secrets.upsertSecret({ /* ... */ });
      // ...
    });
    
    test('test 2', async ({ admin }) => {
      const key = await admin.secrets.upsertSecret({ /* ... */ }); // Duplicate!
      // ...
    });
    
    // Good - extract to fixture
    const test = authTest.extend({
      testKey: async ({ admin }, use) => {
        const key = await admin.secrets.upsertSecret({ /* ... */ });
        await use(key);
      }
    });
    

Summary

  • Base Context: Use for most tests (graph, DB, RLS testing)
  • Auth Context: Use for auth, secrets, keys, AdminOperations
  • Graph Context: Use when you need clean graph state
  • Custom Context: Create when you have 3+ tests with shared setup

Key Principle: Choose the most specific context that provides what you need, and extend it when you have shared setup across multiple tests.


Questions? See real examples in tests/integration/auth/ and tests/integration/graph/.