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 |
pnpm --filter @activation-sys/database test | Database | packages/database/src/__tests__/**/*.test.ts |
pnpm --filter @activation-sys/queue test | Queue | packages/queue/tests/**/*.test.ts |
pnpm --filter @activation-sys/shared test | Shared | packages/shared/src/__tests__/**/*.test.ts |
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 |
moyasar.test.ts | Moyasar adapter, credentials, mode selection and disabled production fallback |
moyasar-settlement.test.ts | Settlement, original-ID method switching, checkout locking |
moyasar-reconciliation.test.ts | Server-side verification and failure paths |
payment-return.test.ts | Public neutral return page and protected confirmation boundary |
moyasar-routes.test.ts | Moyasar webhook handler |
paymob.test.ts | Paymob contracts, payment provider factory, client, adapter, settings and callback authentication |
paymob-routes.test.ts | Paymob webhook handler, checkout admin routes and Paymob confirmation |
paymob-reconciliation.test.ts | Paymob server-side verification and failure paths |
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-orchestration-service.test.ts | Notification creation, delivery handoff, reconciliation, and audit behavior |
notification-audience-service.test.ts | Recipient chunking and company-admin membership selection |
notification-batch-service.test.ts | Batch dispatch, scheduling, cancellation, and delivery counts |
notification-routes.test.ts | Authenticated inbox, ownership, pagination, read/seen/archive, and opt-outs |
admin-notification-routes.test.ts | Super-admin guards, acceptance, idempotent replay, compliance filtering, and batch reads |
notification-webhooks.test.ts | Signature checks, receipt deduplication, enqueue failure recovery, and outcome normalization |
notification-dispatch.test.ts | Provider selection, mock-only notification SMS, and unsupported capability handling |
notification-provider-callback.test.ts | Mock callback signature verification and normalized parsing |
notification-templates.test.ts | Registry rendering, bilingual output, and content validation |
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 | Legacy shared payment schemas/types; current Moyasar and Paymob API contracts are covered by API tests |
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 policies (activation: 3 attempts, payment: 5, notification email: 5, SMS: 3, orchestration: 5), queue names/instances, notification retention/priority/namespaces, and activation backoff, with mocked BullMQ and Redis configuration |
queue.test.ts | Queue integration behavior |
notification-queues.test.ts | Deterministic IDs, retained-failure retry races, bulk channel grouping, priorities, scheduling, and handoff sweep registration |
notification-channel-worker.test.ts | Post-claim recipient gates, claim fencing, retries, terminal outcomes, provider dispatch, metrics, and bounded shutdown |
notification-orchestration-worker.test.ts | Batch/sweep/callback dispatch, concurrency, metrics, and bounded shutdown |
notification-queue-lifecycle.test.ts | Closing all three producers and supplied clients on success, failure, and timeout |
notification-worker-lifecycle.test.ts | Forced cleanup of supplied and blocking worker connections |
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 |
payment-reconciliation.test.ts | payment-reconciliation durable handoff, deterministic job IDs (Moyasar IDs stable across the rename), retries/retention, and worker processing |
Notification Verification
The retired queue and service tests are replaced by the notification suites above. Run the queue and API package test commands from this guide. Within the API suite, also check startup-workers.test.ts, smtp-email-provider.test.ts, activation-notifications.test.ts, notification-esim-spike.test.ts, profile-compliance.test.ts, and compliance-retention.test.ts for wiring, SMTP deadlines, attachment preservation, export, and content scrubbing.
The orchestration service suite uses mocked persistence and enqueue functions to check handoff selection SQL and crash-boundary behavior. Those checks do not prove concurrent Postgres locking. Likewise, preserving an SMTP attachment in a mock does not prove live mail receipt or exactly-once delivery.
When changing notification behavior, cover invalid input, ownership and role denial, successful persistence and dispatch, provider rejection, and retry exhaustion. Include account deactivation or opt-out after enqueue, stale claim recovery, an old attempt finishing after a replacement, enqueue failure after persistence, cancellation racing a batch start, and callback arrival before the provider message ID is saved. Distinguish finished batch dispatch from successful channel outcomes, and keep addresses and rendered content out of queue assertions.
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.