Skip to content

Testing Guide

Overview

The POS system uses Vitest for all testing. Tests cover the HQ server API, store server API, web client components, and shared packages. The test suite currently has 655+ tests.

Test Framework

ToolPurpose
VitestTest runner and assertion library
Fastify app.inject()HTTP request simulation (no real network)
Separate test databaseTests run against pos_hq_test, never production

Running Tests

bash
# Run all tests for a specific app
pnpm --filter hq-server test
pnpm --filter store-server test
pnpm --filter web-client test

# Run with dot reporter (used in CI/deploy)
pnpm --filter hq-server test -- --reporter=dot

# Run a specific test file
pnpm --filter hq-server test -- src/__tests__/products.test.ts

# Run tests matching a pattern
pnpm --filter hq-server test -- -t "should create product"

Test Configuration

Vitest Config

typescript
// apps/hq-server/vitest.config.ts
export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    globalSetup: ['./src/__tests__/global-setup.ts'],
    include: ['src/**/*.test.ts'],
    testTimeout: 30000,
    fileParallelism: false,    // Tests run sequentially
    pool: 'forks',
    poolOptions: {
      forks: { singleFork: true },
    },
    env: {
      DATABASE_URL: 'postgresql://pos_admin:pos_dev_password@localhost:5434/pos_hq_test',
    },
  },
});

Key points:

  • Single fork: All test files run in the same process (no parallel execution), preventing database conflicts
  • Global setup: Runs once before all tests to create the test database and seed data
  • 30-second timeout: API tests involve real database operations

Database Safety Guard

The test setup includes a critical safety check:

typescript
// apps/hq-server/src/__tests__/setup.ts
if (!process.env.DATABASE_URL.includes('pos_hq_test')) {
  throw new Error('REFUSING TO RUN TESTS: DATABASE_URL does not point to test database');
}

This prevents accidental test execution against a production database.

Test Setup Pattern

Global Setup

global-setup.ts runs once before the entire test suite:

  1. Creates the test database if it doesn't exist
  2. Runs migrations to create all tables
  3. Seeds reference data (admin user, cashier user, roles, permissions, store, department, tax rates, products)
  4. Writes seed data IDs to .seed-data.json for test files to reference

Per-File Setup

Each test file follows this pattern:

typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildTestApp, getAuthToken, getSeedData } from './setup.js';

describe('Products API', () => {
  let app: FastifyInstance;

  beforeAll(async () => {
    app = await buildTestApp();
  });

  afterAll(async () => {
    await app.close();
  });

  it('should list products', async () => {
    const token = await getAuthToken(app);

    const response = await app.inject({
      method: 'GET',
      url: '/api/v1/products',
      headers: {
        authorization: `Bearer ${token}`,
      },
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.success).toBe(true);
    expect(Array.isArray(body.data)).toBe(true);
  });
});

buildTestApp()

This function imports buildApp() from the production code, passing { logger: false } to suppress log output during tests. It uses the same application code as production -- no mocks, no separate test server.

typescript
export async function buildTestApp(): Promise<FastifyInstance> {
  const { buildApp } = await import('../app.js');
  return buildApp({ logger: false });
}

Lazy Config

The config.ts module uses lazy getters so environment variables are read at access time, not import time. This allows test setup to override process.env before the config values are captured:

typescript
export const config = {
  get databaseUrl() { return process.env.DATABASE_URL || '...'; },
  get jwtSecret() { return process.env.JWT_SECRET || '...'; },
};

getAuthToken()

Helper that logs in with the test admin user and returns a JWT:

typescript
export async function getAuthToken(app: FastifyInstance): Promise<string> {
  const response = await app.inject({
    method: 'POST',
    url: '/api/v1/auth/login',
    payload: { username: 'testadmin', pin: 'testpin1234' },
  });
  return JSON.parse(response.body).data.accessToken;
}

getSeedData()

Returns the IDs of seeded reference data (users, roles, store, products, etc.) so tests can reference them without hardcoding UUIDs.

Writing Tests for a New Route

  1. Create the test file in apps/hq-server/src/__tests__/ (e.g., my-module.test.ts)

  2. Follow the standard pattern:

typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildTestApp, getAuthToken, getSeedData } from './setup.js';

describe('My Module Routes', () => {
  let app: FastifyInstance;
  let token: string;

  beforeAll(async () => {
    app = await buildTestApp();
    token = await getAuthToken(app);
  });

  afterAll(async () => {
    await app.close();
  });

  it('should create an item', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/v1/my-module',
      headers: { authorization: `Bearer ${token}` },
      payload: {
        name: 'Test Item',
        value: 1000, // cents
      },
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.success).toBe(true);
    expect(body.data.name).toBe('Test Item');
  });

  it('should return 401 without auth', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/api/v1/my-module',
    });
    expect(response.statusCode).toBe(401);
  });

  it('should return 403 without permission', async () => {
    // Login as cashier (limited permissions)
    const cashierResponse = await app.inject({
      method: 'POST',
      url: '/api/v1/auth/login',
      payload: { username: 'testcashier', pin: 'testpin1234' },
    });
    const cashierToken = JSON.parse(cashierResponse.body).data.accessToken;

    const response = await app.inject({
      method: 'POST',
      url: '/api/v1/my-module',
      headers: { authorization: `Bearer ${cashierToken}` },
      payload: { name: 'Test' },
    });
    expect(response.statusCode).toBe(403);
  });
});
  1. Test common scenarios:
    • Successful CRUD operations
    • Authentication required (401)
    • Permission denied (403)
    • Validation errors (400)
    • Not found (404)
    • Edge cases (duplicate SKUs, negative amounts, etc.)

Test Categories

API Tests (HQ Server)

Located in apps/hq-server/src/__tests__/. Cover all route modules:

  • auth.test.ts - Login, refresh, me, change credential
  • products.test.ts - CRUD, barcode lookup, product suppliers
  • departments.test.ts - CRUD, hierarchy
  • taxes.test.ts - Tax rates CRUD
  • tax-groups.test.ts - Tax groups with rates
  • suppliers.test.ts - CRUD
  • users.test.ts - CRUD, role assignment
  • roles.test.ts - CRUD, permission management
  • stores.test.ts - CRUD, dashboard
  • sales.test.ts - Create sale, void, list
  • sale-item-taxes.test.ts - Tax breakdown per item
  • inventory.test.ts - Adjustments, stock levels
  • transfers.test.ts - Create, send, receive, cancel
  • registers.test.ts - Open, close, sessions
  • tenders.test.ts - Payment methods CRUD
  • customers.test.ts - CRUD
  • sales-reps.test.ts - CRUD, toggle active
  • store-prices.test.ts - Price overrides
  • purchase-orders.test.ts - Full lifecycle
  • specials.test.ts - Time-limited pricing
  • worksheets.test.ts - Approval workflow
  • cross-store-inventory.test.ts - Multi-store inventory view
  • sync.test.ts - Pull, push, dashboard, error handling
  • reports.test.ts - All report endpoints

API Tests (Store Server)

Located in apps/store-server/src/__tests__/. Cover store-specific routes.

Frontend Tests (Web Client)

Located in apps/web-client/src/__tests__/. Cover Vue components and composables.

Package Tests

Located in packages/*/src/__tests__/. Cover shared type validation, tax engine calculations, sync protocol, and backend adapter.

Tips

  • Tests modify the database (creates sales, adjustments, etc.). Since they run sequentially in a single fork, each test can rely on data created by previous tests in the same file.
  • If you need to seed additional data for your tests, do it in beforeAll and clean up in afterAll.
  • Use app.inject() rather than real HTTP requests -- it is faster and does not require the server to listen on a port.
  • Always test with both admin and restricted users to verify permission enforcement.