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
| Tool | Purpose |
|---|---|
| Vitest | Test runner and assertion library |
Fastify app.inject() | HTTP request simulation (no real network) |
| Separate test database | Tests run against pos_hq_test, never production |
Running Tests
# 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
// 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:
// 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:
- Creates the test database if it doesn't exist
- Runs migrations to create all tables
- Seeds reference data (admin user, cashier user, roles, permissions, store, department, tax rates, products)
- Writes seed data IDs to
.seed-data.jsonfor test files to reference
Per-File Setup
Each test file follows this pattern:
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.
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:
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:
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
Create the test file in
apps/hq-server/src/__tests__/(e.g.,my-module.test.ts)Follow the standard pattern:
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);
});
});- 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 credentialproducts.test.ts- CRUD, barcode lookup, product suppliersdepartments.test.ts- CRUD, hierarchytaxes.test.ts- Tax rates CRUDtax-groups.test.ts- Tax groups with ratessuppliers.test.ts- CRUDusers.test.ts- CRUD, role assignmentroles.test.ts- CRUD, permission managementstores.test.ts- CRUD, dashboardsales.test.ts- Create sale, void, listsale-item-taxes.test.ts- Tax breakdown per iteminventory.test.ts- Adjustments, stock levelstransfers.test.ts- Create, send, receive, cancelregisters.test.ts- Open, close, sessionstenders.test.ts- Payment methods CRUDcustomers.test.ts- CRUDsales-reps.test.ts- CRUD, toggle activestore-prices.test.ts- Price overridespurchase-orders.test.ts- Full lifecyclespecials.test.ts- Time-limited pricingworksheets.test.ts- Approval workflowcross-store-inventory.test.ts- Multi-store inventory viewsync.test.ts- Pull, push, dashboard, error handlingreports.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
beforeAlland clean up inafterAll. - 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.