Skip to main content

Testing Strategy

Test Pyramid

        ┌─────────────────────────────────┐
│ E2E Tests (Few) │ Playwright / Supertest full flows
│ onboarding, earn, redeem │
├─────────────────────────────────┤
│ Contract Tests (Some) │ POS webhook payload validation
│ Square, Clover shape checks │
├─────────────────────────────────┤
│ Integration Tests (More) │ DB transactions, API auth, idempotency
│ With real Postgres (Docker) │
├─────────────────────────────────┤
│ Unit Tests (Most) │ Reward engine, adapters, validators
│ Fast, isolated, no I/O │
└─────────────────────────────────┘

Unit Tests

What to Unit Test

ModuleKey Behaviors
reward-engineRule matching (amount, day-of-week, operator type), multiplier stacking, rule version isolation
pos-adaptersField normalization, amount parsing (cents vs dollars), negative amounts rejected
wallet serviceLedger arithmetic, over-redemption guard, concurrency invariants
supabase authSupabase OTP session exchange, JWT validation, tenant context resolution
validatorsAll Zod schemas — accepted shapes, rejected shapes, edge values

Unit Test Example

// reward-engine.test.ts

describe('RuleEngine.evaluate', () => {
it('awards points for matching rule', () => {
const rule = buildRule({ type: 'POINTS_PER_DOLLAR', value: 10, minAmount: 1000 });
const tx = buildTransaction({ amount: 2500 });
const result = engine.evaluate([rule], tx);
expect(result.points).toBe(25); // 2500 cents / 100 * 10
});

it('returns 0 points when below minimum amount', () => {
const rule = buildRule({ type: 'POINTS_PER_DOLLAR', value: 10, minAmount: 5000 });
const tx = buildTransaction({ amount: 1000 });
const result = engine.evaluate([rule], tx);
expect(result.points).toBe(0);
expect(result.outcome).toBe('NO_MATCH');
});

it('isolates rule versions — old transaction uses rule version at time of transaction', () => {
const result = engine.evaluateWithVersion(ruleV1, tx);
const resultNew = engine.evaluateWithVersion(ruleV2, tx);
expect(result.ruleVersion).not.toBe(resultNew.ruleVersion);
expect(result.points).not.toBe(resultNew.points); // different rule, different outcome
});
});

Integration Tests

Run against a real Postgres instance (Docker Compose for local and CI).

What to Integration Test

ScenarioValidation
Concurrent wallet deductionsTwo simultaneous requests for same wallet → one succeeds, one gets INSUFFICIENT_BALANCE — never goes negative
Idempotent webhook processingPOST same webhook twice → ledger has exactly one credit, response is 200 both times
Transaction + wallet atomicityIf reward credit fails, the transaction record is also rolled back (single DB transaction)
Supabase OTP rate limiting6th OTP request within 10 minutes returns 429
Cross-tenant accessMerchant for tenant A cannot read wallet for tenant B — gets 403
JWT expiry enforcementRequest with expired access token gets 401; refreshed token works

Integration Test Setup

// test/helpers/db.ts
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';

let db: PrismaClient;

beforeAll(async () => {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
execSync('npx prisma migrate deploy');
db = new PrismaClient();
await db.$connect();
});

afterEach(async () => {
// Truncate tables between tests for isolation
await db.$executeRaw`TRUNCATE wallets, transactions, wallet_ledger, otp_attempts RESTART IDENTITY CASCADE`;
});

afterAll(async () => {
await db.$disconnect();
});

Contract Tests

Validate that incoming POS webhook payloads match the shape SalesArck expects. This protects against silent breaking changes when POS providers update their APIs.

// contract/square-webhook.contract.test.ts

const squarePaymentExamples = loadFixtures('square/payment-*.json');

test.each(squarePaymentExamples)('parses Square payment: %s', (fixture) => {
const result = squareAdapter.parseWebhook(fixture);
expect(result).toMatchObject({
externalTransactionId: expect.any(String),
amount: expect.any(Number),
currency: 'USD',
consumerId: expect.any(String),
timestamp: expect.any(Date),
});
expect(result.amount).toBeGreaterThan(0);
});

Fixtures are captured from real POS sandbox webhooks and committed to test/fixtures/.


E2E Tests

Full happy-path flows using the real server (test environment with seeded data).

Critical E2E Flows

□ Merchant onboarding
Register → Supabase OTP → Tenant created → Square OAuth → Rule configured
Assert: webhook can now be processed for this tenant

□ Consumer earn flow
Consumer Supabase login → POS transaction webhook arrives → Wallet credited
Assert: wallet balance increased by correct amount; ledger has credit entry

□ Consumer redeem flow
Consumer authenticated → Requests redemption → Merchant approves
Assert: wallet debited; redemption record created; cannot redeem same amount twice

□ Admin wallet adjustment
Admin authenticated → Adjusts wallet balance with reason
Assert: audit log entry created; wallet reflects new balance

□ Supabase OTP rate limit enforcement
5 OTP requests succeed → 6th returns 429
Assert: correct error code; subsequent request after window succeeds

Quality Gates

These checks must pass in CI before any merge to main:

# .github/workflows/ci.yml

quality-gates:
- name: Unit tests
command: npm run test:unit
required: true

- name: Integration tests
command: npm run test:integration
required: true
services: [postgres]

- name: Contract tests
command: npm run test:contract
required: true

- name: Type check
command: npx tsc --noEmit
required: true

- name: Lint
command: npx eslint . --max-warnings 0
required: true

- name: Build
command: npm run build
required: true

- name: Coverage (reward engine)
command: npm run test:unit -- --coverage reward-engine
threshold: 90%
required: true

Test Data Builders

Use builder patterns for test data to reduce fragility:

// test/builders.ts

export function buildRule(overrides: Partial<RewardRule> = {}): RewardRule {
return {
id: ulid(),
tenantId: 'tenant_test',
type: 'POINTS_PER_DOLLAR',
value: 10,
multiplier: 1.0,
minAmount: 0,
maxAmount: null,
activeDays: [0, 1, 2, 3, 4, 5, 6],
isActive: true,
version: 1,
createdAt: new Date(),
...overrides,
};
}

export function buildTransaction(overrides: Partial<Transaction> = {}): Transaction {
return {
id: ulid(),
tenantId: 'tenant_test',
amount: 1000, // $10.00 in cents
currency: 'USD',
externalTransactionId: randomUUID(),
posType: 'SQUARE',
timestamp: new Date(),
...overrides,
};
}
Written byDhruv Doshi