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
| Module | Key Behaviors |
|---|---|
reward-engine | Rule matching (amount, day-of-week, operator type), multiplier stacking, rule version isolation |
pos-adapters | Field normalization, amount parsing (cents vs dollars), negative amounts rejected |
wallet service | Ledger arithmetic, over-redemption guard, concurrency invariants |
supabase auth | Supabase OTP session exchange, JWT validation, tenant context resolution |
validators | All 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
| Scenario | Validation |
|---|---|
| Concurrent wallet deductions | Two simultaneous requests for same wallet → one succeeds, one gets INSUFFICIENT_BALANCE — never goes negative |
| Idempotent webhook processing | POST same webhook twice → ledger has exactly one credit, response is 200 both times |
| Transaction + wallet atomicity | If reward credit fails, the transaction record is also rolled back (single DB transaction) |
| Supabase OTP rate limiting | 6th OTP request within 10 minutes returns 429 |
| Cross-tenant access | Merchant for tenant A cannot read wallet for tenant B — gets 403 |
| JWT expiry enforcement | Request 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