Testing
The Activation System uses Vitest as its test framework across all packages in the monorepo. Each package has its own vitest.config.ts and runs tests independently via pnpm --filter. Tests cover route integration, service unit tests, Zod schema validation, database encryption, RLS policies, and queue worker logic.
Running Tests
Per-package commands
Each workspace package runs its own test suite. Use pnpm --filter to target a specific package:
| Command | Package | Test Files |
|---|---|---|
pnpm --filter @activation-sys/api test | API (Hono) | apps/api/tests/**/*.test.ts (35 files) |
pnpm --filter @activation-sys/database test | Database | packages/database/src/__tests__/**/*.test.ts (8 files) |
pnpm --filter @activation-sys/queue test | Queue | packages/queue/tests/**/*.test.ts (4 files) |
pnpm --filter @activation-sys/shared test | Shared | packages/shared/src/__tests__/**/*.test.ts (5 files) |
Watch mode
To run tests in watch mode during development (re-runs on file changes):
# API watch mode
pnpm --filter @activation-sys/api test:watch
# Database watch mode
pnpm --filter @activation-sys/database test:watch
# Queue watch mode
pnpm --filter @activation-sys/queue test:watch
# Shared watch mode
pnpm --filter @activation-sys/shared test:watchRun all tests
There is no root-level test script. Run all packages individually:
pnpm --filter @activation-sys/api test && \
pnpm --filter @activation-sys/database test && \
pnpm --filter @activation-sys/queue test && \
pnpm --filter @activation-sys/shared testPrerequisites
Before running tests, ensure typecheck and lint pass:
pnpm check # turbo typecheck across all packages
pnpm lint # biome check .Test Framework and Setup
Framework: Vitest (^3.0.0 in API/shared, ^3.2.4 in database, ^4.1.4 in queue)
Configuration: Each package defines its own vitest.config.ts with package-specific settings:
API config (apps/api/vitest.config.ts):
{
"test": {
"globals": true,
"testTimeout": 10000,
"include": ["tests/**/*.test.ts"]
},
"resolve": {
"alias": {
"@": "<project_root>/apps/api/src",
"@activation-sys/shared": "<project_root>/packages/shared/src",
"@activation-sys/database": "<project_root>/packages/database/src"
}
}
}Shared config (packages/shared/vitest.config.ts):
{
"test": {
"globals": true,
"testTimeout": 10000,
"include": ["src/__tests__/**/*.test.ts"]
}
}Queue config (packages/queue/vitest.config.ts):
{
"test": {
"testTimeout": 30000,
"hookTimeout": 30000,
"teardownTimeout": 15000
}
}The queue package uses extended timeouts because Redis integration tests need additional time for connection and cleanup.
Database package: No vitest.config.ts file — uses Vitest defaults with the src/__tests__/ glob convention.
Global test setup: globals: true in API and shared configs means describe, it, expect, vi are available without explicit imports.
Test Categories
Route/Integration Tests (apps/api/tests/)
These tests exercise Hono HTTP endpoints using the app.request() test client. They verify request validation, middleware behavior, response shapes, and bilingual (Arabic/English) output:
| Test File | Area |
|---|---|
auth.test.ts | Registration, login, OTP verification, logout, profile, phone change (phone + email) |
auth-middleware.test.ts | Auth middleware — JWT verification, role checks, public path handling |
email-auth-middleware.test.ts | Email-based auth middleware |
email-auth-service.test.ts | Email auth service logic |
operators.test.ts | Operator CRUD and listing endpoints |
packages.test.ts | Package listing and detail endpoints |
search.test.ts | Package search endpoint |
availability.test.ts | Package availability checks |
sync-packages.test.ts | Package sync endpoint |
activations.test.ts | SIM activation endpoints |
activation-service.test.ts | Activation business logic |
payments.test.ts | Payment intent and processing endpoints |
payment-service.test.ts | Payment service logic |
payment-provider.test.ts | Payment provider abstraction |
stripe-webhook.test.ts | Stripe webhook handler |
rate-limit.test.ts | Rate limiting middleware |
health.test.ts | Health check endpoint |
cold-start.test.ts | Server startup + live health check |
error-handler.test.ts | Error handler middleware |
env-validation.test.ts | Environment variable Zod validation |
notification-service.test.ts | Notification delivery |
otp-service.test.ts | OTP generation and verification |
session-service.test.ts | Session management |
sms-provider.test.ts | SMS provider abstraction |
email-provider.test.ts | Email provider abstraction |
operator-plugin.test.ts | Operator plugin system |
profile-routes.test.ts | User profile endpoints |
profile-compliance.test.ts | Profile data compliance (PDPL) |
compliance-retention.test.ts | Data retention and compliance |
status-tracking.test.ts | Activation status tracking |
realtime-types.test.ts | Realtime event type definitions |
Behavioral Tests (apps/api/tests/*-behavioral.test.ts)
These tests validate domain-specific behaviors — what the system should do from a user perspective, not just how individual endpoints respond:
| Test File | Validates |
|---|---|
search-behavioral.test.ts | Search with q param returns results, pg_trgm fallback, filters apply within search scope, requires q param, matchedBy indicator |
packages-behavioral.test.ts | Bilingual package responses, filter behavior, promotional flags |
sync-packages-behavioral.test.ts | Sync behavior — idempotency, delta handling |
availability-behavioral.test.ts | Availability checks for packages and operators |
operators-behavioral.test.ts | Filters isActive=true, returns bilingual nameEn/nameAr, excludes sensitive apiConfig/apiBaseUrl |
Schema Validation Tests (packages/shared/src/__tests__/)
Zod schema validation tests ensure request/response schemas accept valid data and reject invalid data:
| Test File | Validates |
|---|---|
schemas.test.ts | User, operator, package, activation, payment, ticket schemas — accepts valid data, rejects invalid formats, enforces bilingual fields |
errors.test.ts | AppError hierarchy — AuthError (401/403), ValidationError (400), NotFoundError (404), ActivationError (422), PaymentError (402/409), OperatorError (502/504), toApiResponse bilingual output |
payment-contracts.test.ts | Payment intent, STC Pay, Apple Pay, Stripe webhook schemas and TypeScript types |
status-profile-contracts.test.ts | Activation history query, profile update, profile export, delete profile schemas |
email-auth.test.ts | Email authentication schema validation |
Database Schema Tests (packages/database/src/__tests__/)
These tests verify database schema correctness, encryption, and Row Level Security:
| Test File | Validates |
|---|---|
encryption.test.ts | AES-256-GCM encrypt/decrypt round-trip, random IV uniqueness, tamper detection, unicode/Arabic text handling, empty string |
rls-policies.test.ts | 17 tables have RLS enabled, CREATE POLICY statements present, covers 5 roles (end_user, b2b_admin, activation_officer, super_admin, support), audit_logs immutability trigger |
schema.test.ts | Table structure, column types, relationships |
tls-config.test.ts | TLS configuration for database connections |
catalog-schema.test.ts | Catalog (operators/packages) schema structure |
payment-schema.test.ts | Payment table schema |
email-hash-schema.test.ts | Email hash storage schema |
status-events-schema.test.ts | Activation status events schema |
Queue Worker Tests (packages/queue/tests/)
| Test File | Validates |
|---|---|
queue-unit.test.ts | RETRY_CONFIG (activation: 3 attempts, payment: 5, notification: 2), QUEUE_NAMES, activationBackoff delay calculation, queue instances with correct names, redis createConnection/disconnect — all without real Redis |
queue.test.ts | Queue integration behavior |
payment-worker.test.ts | processPaymentJob — settle_payment enqueues activation, duplicate handling, reconcile_failure skips activation, missing data throws for BullMQ retry, operator slug from DB not hardcoded |
payment-settlement.test.ts | Payment settlement worker logic |
Test Patterns
Hono route test using app.request()
The Hono test client allows sending HTTP requests directly to the app instance without starting a server:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';
function createTestApp(): Hono {
const app = new Hono();
app.onError((err, c) => {
// Error handler converts AppError subclasses to HTTP responses
if (err instanceof AuthError) {
return c.json({ error: { code: err.code, messageEn: err.messageEn, messageAr: err.messageAr } }, 401);
}
return c.json({ error: { code: 'INTERNAL_ERROR', message: err.message } }, 500);
});
app.use('*', languageMiddleware);
app.use('*', authMiddleware);
app.post('/auth/register', validate(registerSchema), async (c) => {
// route handler
});
return app;
}
describe('Auth Routes', () => {
it('should return 201 with valid registration data', async () => {
const app = createTestApp();
const res = await app.request('/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept-Language': 'ar-SA',
},
body: JSON.stringify({
phone: '+966501234567',
name: 'مستخدم تجريبي',
consents: [
{ type: 'terms_of_service', version: '1.0' },
{ type: 'privacy_policy', version: '1.0' },
{ type: 'data_processing', version: '1.0' },
],
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.message).toBe('تم إرسال رمز التحقق'); // Arabic response
});
});Key points:
- Mock external dependencies (
redis,database,supabase) at the module level usingvi.mock() - Use
vi.hoisted()for mock objects referenced invi.mock()factory functions - Import modules after mock declarations
- Test bilingual responses by setting
Accept-Languageheader
Zod schema validation test
import { describe, it, expect } from 'vitest';
import { createUserSchema, packageSchema } from '../schemas/index.js';
describe('Zod Schema Validation', () => {
it('createUserSchema accepts valid user data', () => {
expect(() => createUserSchema.parse({
phone: '0501234567',
email: 'user@example.com',
name: 'Ali',
role: 'end_user',
identityType: 'citizen',
})).not.toThrow();
});
it('createUserSchema rejects invalid role', () => {
expect(() => createUserSchema.parse({
phone: '0501234567',
email: 'user@example.com',
name: 'Ali',
role: 'hacker', // not in enum
identityType: 'citizen',
})).toThrow();
});
it('packageSchema requires bilingual name fields', () => {
const validPackage = {
id: '550e8400-e29b-41d4-a716-446655440000',
nameEn: 'Test Package',
nameAr: 'باقة تجريبية',
// ... other required fields
};
expect(() => packageSchema.parse(validPackage)).not.toThrow();
});
});Key points:
- Use
.parse()to test both acceptance and rejection - Test boundary conditions: empty strings, negative numbers, wrong enums, missing required fields
- Verify bilingual fields (
nameEn/nameAr) are required where applicable
Database encryption round-trip test
import { describe, it, expect, beforeEach } from 'vitest';
import { encrypt, decrypt } from '../utils/encryption.js';
const TEST_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
describe('encryption', () => {
beforeEach(() => {
process.env.ENCRYPTION_KEY = TEST_KEY;
});
it('should encrypt plaintext and decrypt back to the original value', () => {
const plaintext = 'hello world';
const encrypted = encrypt(plaintext);
const decrypted = decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should produce different ciphertext for same plaintext (random IV)', () => {
const encrypted1 = encrypt('hello');
const encrypted2 = encrypt('hello');
expect(encrypted1).not.toBe(encrypted2); // random IV
expect(decrypt(encrypted1)).toBe('hello');
expect(decrypt(encrypted2)).toBe('hello');
});
it('should throw when decrypting tampered ciphertext (GCM auth tag)', () => {
const encrypted = encrypt('sensitive data');
const tampered = encrypted.slice(0, -4) + 'dead';
expect(() => decrypt(tampered)).toThrow();
});
it('should handle unicode/Arabic text', () => {
const plaintext = 'مرحبا بالعالم';
const encrypted = encrypt(plaintext);
expect(decrypt(encrypted)).toBe(plaintext);
});
});Key points:
- Set
ENCRYPTION_KEYenvironment variable inbeforeEach - Test AES-256-GCM properties: random IV, authentication tag, unicode support
- Arabic text encryption is critical for PDPL-compliant PII storage
Queue worker test with mock Redis
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock BullMQ to avoid real Redis connections
vi.mock('bullmq', () => ({
Worker: vi.fn().mockImplementation((_name, processor, _opts) => {
return { on: vi.fn(), close: vi.fn() };
}),
Queue: vi.fn().mockImplementation(() => ({
add: vi.fn().mockResolvedValue({ id: 'job-1' }),
on: vi.fn(),
})),
}));
describe('Payment Worker', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it('settle_payment enqueues activation job for completed payment', async () => {
const { processPaymentJob } = await import('../src/workers/payment-worker.js');
const mockDb = {
queryPayments: vi.fn().mockResolvedValue([{ id: 'pay-1', status: 'completed' }]),
queryOrderItems: vi.fn().mockResolvedValue([{ id: 'oi-1', orderId: 'ord-1' }]),
queryActivations: vi.fn().mockResolvedValue([{ id: 'act-1', operatorId: 'op-1' }]),
queryOperator: vi.fn().mockResolvedValue({ id: 'op-1', slug: 'stc' }),
};
const result = await processPaymentJob({
action: 'settle_payment',
paymentId: 'pay-1',
}, mockDb);
expect(result.success).toBe(true);
});
});Key points:
- Mock
bullmqmodule entirely — no Redis connection needed - Use
vi.resetModules()inbeforeEachfor dynamicimport()tests - Pass mock database objects to worker functions for isolated testing
- Worker tests verify retry behavior (throws on missing data) and idempotency
Environment Handling
Tests run with NODE_ENV=test. The environment schema in apps/api/src/config/env.ts uses Zod with sensible defaults:
NODE_ENV: z.enum(['development', 'production', 'test']).default('development')
PORT: z.coerce.number().default(3001)
SMS_PROVIDER: z.enum(['mock', 'twilio', 'taqnyat']).default('mock')
JWT_EXPIRY_SECONDS: z.coerce.number().default(3600)No real Supabase or Redis required for unit tests. All external dependencies are mocked:
- Redis — mocked via
vi.mock('../src/config/redis.js')with stub methods (exists,set,get,incr,del,expire,eval) - Supabase — mocked via
vi.mock('@supabase/supabase-js')withcreateClientreturning stubauth.adminmethods - Database (Drizzle) — mocked via
vi.mock('../src/config/database.js')with chainableselect/insert/updatestubs - BullMQ — mocked via
vi.mock('bullmq')with stubWorkerandQueueconstructors
Queue tests skip gracefully when Redis is unavailable — the mock prevents any real connection attempts. The queue package's vitest.config.ts uses longer timeouts (testTimeout: 30000) to accommodate tests that may need connection setup time.
CI Integration
No CI/CD pipeline detected in the repository (no .github/workflows/ directory). Before tests can reliably run in CI, the following pre-checks should pass locally:
- Typecheck:
pnpm check— runsturbo run typecheckacross all packages - Lint:
pnpm lint— runsbiome check .
Recommended CI workflow order:
pnpm install --frozen-lockfilepnpm check(typecheck)pnpm lint(biome)- Run test suites per package
pnpm --filter @activation-sys/api testpnpm --filter @activation-sys/database testpnpm --filter @activation-sys/queue testpnpm --filter @activation-sys/shared test
Coverage
Run coverage reports with the --coverage flag:
pnpm --filter @activation-sys/api exec vitest run --coverage
pnpm --filter @activation-sys/database exec vitest run --coverage
pnpm --filter @activation-sys/queue exec vitest run --coverage
pnpm --filter @activation-sys/shared exec vitest run --coverageNo coverage threshold is configured in any vitest.config.ts file. Coverage is informational only.
Example coverage report output:
% Coverage report from v8
----------------------------------------------------
File | Lines | Branches | Funcs | Stmts |
------------------------|-------|----------|-------|-------|
src/middleware/auth.ts | 85.7 | 75.0 | 100.0 | 87.5 |
src/routes/health.ts | 100.0 | 100.0 | 100.0 | 100.0 |
src/routes/operators.ts | 92.3 | 88.8 | 100.0 | 93.1 |
------------------------|-------|----------|-------|-------|
All files | 91.2 | 84.1 | 100.0 | 92.0 |Coverage configuration can be added to any vitest.config.ts:
{
"test": {
"coverage": {
"provider": "v8",
"reporter": ["text", "html", "lcov"],
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**", "src/types/**"]
}
}
}Writing New Tests
File naming convention:
| Package | Location | Pattern |
|---|---|---|
| API | apps/api/tests/ | *.test.ts |
| Database | packages/database/src/__tests__/ | *.test.ts |
| Queue | packages/queue/tests/ | *.test.ts |
| Shared | packages/shared/src/__tests__/ | *.test.ts |
No shared test helpers directory exists — each test file sets up its own mocks inline. When adding a new test:
- Place the test file in the correct location per the table above
- Follow the existing mock patterns for the package (see Test Patterns)
- Use
describe/itblocks with descriptive names (English, not Arabic) - For route tests, use
app.request()— never start a real HTTP server (exceptcold-start.test.tswhich explicitly tests server startup) - For behavioral tests, name the file
<feature>-behavioral.test.tsand test domain requirements - For encryption/security tests, set the required env vars in
beforeEach
Frontend Checks
The new frontend workspaces currently have type/build checks but no behavior test suites yet:
| Workspace | Check command | Notes |
|---|---|---|
apps/web-b2c | pnpm --filter @activation-sys/web-b2c typecheck | Next.js B2C web shell and bilingual content modules |
apps/site | pnpm --filter @activation-sys/site typecheck | Vite landing site shell and bilingual content modules |
When meaningful customer behavior is added, place tests near the changed frontend code and cover validation, auth state handling, localization parity, and API failure paths where applicable.