Testing Framework Testing Guide Bilko — Testing Guide Status: Active but partially stale — implementation moved to Kotlin/Ktor API and current Playwright partitioning is in progress Version: 2.1 Last Updated: 2026-05-21 Author: ALAI Documentation Team Canonical testing policy: see TEST-STRATEGY.md , E2E-TEST-PLAN.md , and DEMO-TESTING-PLAN.md . Older sections in this guide may still mention Express/Prisma/API Vitest inventory and should not override the current Ktor + Playwright policy. Table of Contents Testing Philosophy Testing Pyramid Tech Stack Actual Test Files Running Tests Test Configuration Test Setup & Mocking CI Integration Writing New Tests Coverage Reporting Testing Best Practices Debugging Tests 1. Testing Philosophy Financial software has a higher correctness bar than typical web apps. Bilko's testing strategy prioritizes: Financial Logic Accuracy — VAT calculations, double-entry bookkeeping, currency conversion Data Integrity — No lost transactions, no balance discrepancies Regression Prevention — Once fixed, bugs stay fixed Fast Feedback — Prefer fast unit tests and targeted integration/contract tests; use real PostgreSQL/Testcontainers where behavior depends on the database 2. Testing Pyramid /\ /E2E\ ← 10% (Critical user flows only) /------\ / Integ \ ← 20% (API endpoints, mocked DB) /----------\ / Unit \ ← 70% (Business logic, financial engine) /--------------\ Distribution: 70% Unit Tests — Fast, isolated, test business logic in @bilko/core 20% Integration Tests — Test API endpoints with mocked Prisma client 10% E2E Tests — Full API integration test (single file in tests/e2e/ ) Coverage Targets by Module Module Unit Integration Reason @bilko/core accounting engine 95% N/A Double-entry errors = financial loss @bilko/core tax/VAT calculations 95% N/A Tax miscalculations = regulatory penalty @bilko/core multi-currency 90% N/A FX errors = revenue leakage Auth API 85% 90% Security boundary Invoice API 80% 90% Core revenue feature Expense API 80% 85% Core cost tracking Reports API 75% 80% Regulatory output Banking API 75% 75% Complex matching logic Multi-tenant isolation N/A 100% GDPR + security critical 3. Tech Stack Test Type Framework Purpose Unit Vitest @bilko/core business logic (financial engine) Integration Vitest + Supertest API endpoint testing with mocked Prisma E2E Vitest + Supertest Full API integration ( tests/e2e/api.test.ts ) Why Vitest (not Jest) Native ESM support, Vite-based — faster than Jest Compatible with Turborepo workspace structure Watch mode with HMR Same API as Jest (easy migration) Why Supertest (not Postman) Programmatic API testing within Vitest Works with Express app instance directly No running server required 4. Actual Test Files apps/api/tests/ — Mock Suite (11 test files) Tests with mocked Prisma — fast, no database required. File Tests What It Covers setup.ts — Shared test setup: Prisma mock, JWT helpers, test data factories auth.test.ts 11 Register, login, refresh token, logout, GET /me — auth flows with mocked DB invoices.test.ts 11 List, create, get, update, status change (send/pay), delete invoice endpoints expenses.test.ts 9 List, create, get, update, approve/reject, delete expense endpoints contacts.test.ts 9 List, create, get, update, delete contact endpoints accounts.test.ts 4 List, get, create, delete chart-of-accounts endpoints banking.test.ts 10 Bank account management, transaction import, reconciliation endpoints reports.test.ts 9 P&L, balance sheet, VAT report, trial balance endpoints transactions.test.ts 9 Transaction ledger list, filter, and detail endpoints country.test.ts 27 Country plugin integration — tax rates for RS/BA/HR, invoice number formats chatbot.test.ts 9 Chatbot message, history, clear history — rate limit (429) test invoice-gl-reversal.test.ts 6 Invoice cancellation GL reversal — double-entry stays balanced new-endpoints.test.ts ~10 Receipt, VAT export PDF/XML, dashboard, security audit log, data export Total: ~120+ mock suite test cases apps/api/tests/unit/ — Unit Suite (4 test files) Service-layer tests. Mocked Prisma. No HTTP layer. File Tests What It Covers invoice-service-calculations.test.ts ~15 InvoiceService.createInvoice() arithmetic — line totals, VAT, FX two-factor.test.ts 8 TwoFactorService — enable, verify, disable TOTP with backup codes sef-submission.test.ts ~10 SefClient HTTP calls, InvoiceService.submitToSef() fire-and-forget vat-calculation.test.ts ~20 Pure VAT functions from country-rs, country-ba, country-hr packages apps/api/tests/e2e/ — E2E Suite (2 test files) End-to-end workflow tests. Mocked services, no real DB required. File Tests What It Covers api.test.ts — Full Express stack integration test (live server, no mocks) billing-flow.e2e.test.ts ~8 Full billing workflow: contact → invoice → send → pay → P&L → credit note apps/api/tests/integration/ — Real DB Suite (5 test files) Requires docker-compose.test.yml PostgreSQL. Run with npm run test:integration . File Tests What It Covers auth.integration.test.ts 4 Registration + login + refresh against real DB invoice.integration.test.ts 5 Full invoice CRUD lifecycle against real DB credit-note-gl.integration.test.ts 3 Credit note GL entries balance in real DB report.integration.test.ts 3 Reports with real seeded transactions tenant-isolation.integration.test.ts 5 Cross-tenant data isolation (org A cannot read org B's data) Grand Total: ~390 tests across 27 test files packages/core/tests/ — Unit Tests (5 test files) File Tests What It Covers accounting.test.ts 20 validateDoubleEntry , createJournalEntry , calculateTrialBalance — double-entry engine chart-of-accounts.test.ts 32 Chart of accounts operations: account creation, hierarchy, account types invoicing.test.ts 22 generateInvoiceNumber , calculateInvoiceTotals , validateLineItem multi-currency.test.ts 24 Currency conversion, exchange rate locking, precision handling tax.test.ts 23 VAT calculations for RS (20%), BA (17%), HR (25%), mixed rates, edge cases Total: 121 unit test cases Grand Total: ~220 tests across 14 test files 5. Running Tests Run All Tests (Turborepo) # From project root — runs all tests in all packages npm run test # Or with turbo directly npx turbo run test Run API Mock Suite cd apps/api npx vitest run # Watch mode npx vitest # Specific test file npx vitest run tests/auth.test.ts npx vitest run tests/chatbot.test.ts # Specific test by name pattern npx vitest run --reporter=verbose -t "POST /api/v1/auth/login" Run API Unit Suite cd apps/api npx vitest run tests/unit/ # Specific service test npx vitest run tests/unit/two-factor.test.ts npx vitest run tests/unit/vat-calculation.test.ts Run API E2E Suite cd apps/api npx vitest run tests/e2e/ # Full billing flow npx vitest run tests/e2e/billing-flow.e2e.test.ts Run Real DB Integration Suite Requires Docker. Uses docker-compose.test.yml (port 5433). # Start test database docker-compose -f docker-compose.test.yml up -d # Run integration tests cd apps/api npm run test:integration # Or directly: npx vitest run tests/integration/ # Stop test database docker-compose -f docker-compose.test.yml down Run Core Unit Tests cd packages/core npx vitest run # Watch mode npx vitest # Specific file npx vitest run tests/tax.test.ts # With coverage npx vitest run --coverage Run in Verbose Mode npx vitest run --reporter=verbose Test Without Building Tests resolve workspace packages from TypeScript source (not dist/ ) via vitest.config.ts aliases. No build step required before running tests. 6. Test Configuration apps/api-express/vitest.config.ts import { defineConfig } from 'vitest/config' import path from 'path' export default defineConfig({ resolve: { alias: { // Resolves workspace packages from source — no dist/ build needed '@bilko/core': path.resolve(__dirname, '../../packages/core/src/index.ts'), '@bilko/domain-rs': path.resolve(__dirname, '../../packages/domain-rs/src/index.ts'), '@bilko/domain-ba': path.resolve(__dirname, '../../packages/domain-ba/src/index.ts'), '@bilko/domain-hr': path.resolve(__dirname, '../../packages/domain-hr/src/index.ts'), '@bilko/database': path.resolve(__dirname, '../../packages/database/src/index.ts'), }, }, test: { globals: false, environment: 'node', setupFiles: [], // Setup is imported per test file via tests/setup.ts include: ['tests/**/*.test.ts'], exclude: ['node_modules', 'dist'], }, }) packages/core/vitest.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, root: '.', include: ['tests/**/*.test.ts'], }, }) Key Configuration Differences Setting apps/api-express packages/core globals false — imports explicit true — describe/it/expect global setupFiles None (per-file import of ./setup ) None aliases Workspace package path aliases Not needed environment node (explicit) Default node 7. Test Setup & Mocking apps/api/tests/setup.ts The API integration tests use a mocked Prisma client — tests run without a real PostgreSQL database. The setup file: Sets required environment variables (JWT secrets, rate limit config) Mocks the entire src/lib/prisma module with vi.mock() Provides test data factories and JWT token generators // Import setup (must be first import in test file) import { createTestUser, generateTestAccessToken, generateTestRefreshToken, TEST_USER_EMAIL, TEST_USER_ID, TEST_ORG_ID, } from './setup' Mock Prisma Pattern // In test file — reference the mocked prisma import { prisma } from '../src/lib/prisma' const mockPrisma = prisma as any // Configure mock for a specific test mockPrisma.user.findUnique.mockResolvedValue(testUser) mockPrisma.user.findUnique.mockResolvedValue(null) // simulate not found // Clear mocks between tests beforeEach(() => { vi.clearAllMocks() }) Auth Token Generation // Generate a valid JWT for test requests const authToken = generateTestAccessToken() // Generate token with specific role const adminToken = generateTestAccessToken({ role: 'admin' }) const viewerToken = generateTestAccessToken({ role: 'viewer' }) // Generate refresh token (used in cookies) const refreshToken = generateTestRefreshToken(TEST_USER_ID) Service Mocking Pattern (for route tests) // Mock entire service module vi.mock('../src/services/invoice.service', () => { const mockService = { listInvoices: vi.fn(), createInvoice: vi.fn(), // ... } return { invoiceService: mockService } }) import { invoiceService } from '../src/services/invoice.service' const mockInvoiceService = invoiceService as any // Configure per test mockInvoiceService.createInvoice.mockResolvedValue(newInvoice) // Verify calls expect(mockInvoiceService.createInvoice).toHaveBeenCalledWith( TEST_ORG_ID, TEST_USER_ID, expect.any(Object), ) 8. CI Integration Tests run via GitHub Actions on every push and pull request. See .github/workflows/ci.yml . CI Pipeline (4 parallel jobs) # .github/workflows/ci.yml on: push: branches: ['*'] pull_request: branches: [main, staging] jobs: lint: # npx turbo run lint type-check: # npx turbo run type-check (after prisma generate) test: # npx turbo run test (unit + integration) build: # npx turbo run build (needs lint, type-check, test) Job Details Job Command Needs Prisma Generate Blocks lint npx turbo run lint No build type-check npx turbo run type-check Yes build test npx turbo run test Yes build build npx turbo run build Yes Deploy Prisma Client Generation (required in CI) - name: Generate Prisma Client run: npx prisma generate --schema=packages/database/prisma/schema.prisma This is required before type-check and test because the API source references @prisma/client types. Concurrency concurrency: group: ci-${{ github.ref }} cancel-in-progress: true Cancels in-flight CI runs on the same branch when new commits are pushed. CI Gate Rules Gate Condition Blocks Lint Any lint error PR merge + build Type check Any TypeScript error PR merge + build Tests Any test failure PR merge + build Build Lint/type-check/test all pass Deploy 9. Writing New Tests API Integration Test — Pattern // apps/api/tests/my-feature.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' import request from 'supertest' import { generateTestAccessToken, TEST_ORG_ID, TEST_USER_ID } from './setup' import app from '../src/app' // Mock the service vi.mock('../src/services/my-feature.service', () => { return { myFeatureService: { listItems: vi.fn(), createItem: vi.fn(), }, } }) import { myFeatureService } from '../src/services/my-feature.service' const mockService = myFeatureService as any const authToken = generateTestAccessToken() describe('GET /api/v1/my-feature', () => { beforeEach(() => { vi.clearAllMocks() }) it('returns 200 with paginated list', async () => { mockService.listItems.mockResolvedValue({ data: [], total: 0, page: 1, pageSize: 20 }) const res = await request(app) .get('/api/v1/my-feature') .set('Authorization', `Bearer ${authToken}`) expect(res.status).toBe(200) expect(res.body).toHaveProperty('data') }) it('returns 401 without auth', async () => { const res = await request(app).get('/api/v1/my-feature') expect(res.status).toBe(401) }) }) Core Unit Test — Pattern // packages/core/tests/my-calculation.test.ts import { describe, it, expect } from 'vitest' import Decimal from 'decimal.js' import { myCalculation } from '../src/my-module/index' describe('myCalculation', () => { it('returns correct result for standard input', () => { const result = myCalculation('100', '20') expect(result.eq(new Decimal('20'))).toBe(true) }) it('handles zero input', () => { const result = myCalculation('0', '20') expect(result.eq(new Decimal('0'))).toBe(true) }) it('throws for negative amounts', () => { expect(() => myCalculation('-100', '20')).toThrow() }) }) Naming Conventions // Describe block: HTTP method + route OR function name describe('POST /api/v1/invoices', () => { ... }) describe('calculateInvoiceTotals', () => { ... }) // It block: be specific about scenario and expected outcome it('returns 201 with created invoice when data is valid', ...) it('returns 400 when customerId is missing', ...) it('calculates Serbian VAT at 20% on 100 RSD as 20 RSD', ...) it('throws for negative amounts', ...) 10. Coverage Reporting Generate Coverage (core unit tests) cd packages/core npx vitest run --coverage Coverage Output Format File | % Stmts | % Branch | % Funcs | % Lines -------------------------|---------|----------|---------|-------- All files | XX.X | XX.X | XX.X | XX.X accounting/index.ts | 95.0 | 90.0 | 100.0 | 95.0 invoicing/index.ts | 88.2 | 80.5 | 85.0 | 88.2 tax/index.ts | 92.0 | 88.0 | 100.0 | 92.0 Coverage Targets Category Target Rationale Financial logic ( @bilko/core ) >95% Critical for correctness API routes ( apps/api-express ) >80% Integration-level coverage Services >80% Business logic layer 11. Testing Best Practices 1. Arrange-Act-Assert (AAA) it('creates invoice with correct totals', async () => { // ARRANGE const newInvoice = mockInvoice({ status: 'draft' }) mockInvoiceService.createInvoice.mockResolvedValue(newInvoice) // ACT const res = await request(app) .post('/api/v1/invoices') .set('Authorization', `Bearer ${authToken}`) .send({ customerId: CUSTOMER_UUID, items: [...] }) // ASSERT expect(res.status).toBe(201) expect(res.body.invoiceNumber).toBe('INV-2026-001') }) 2. Test Behavior, Not Implementation // BAD — tests internal mock call order expect(mockService.createInvoice.mock.calls[0][0]).toBe(orgId) // GOOD — tests observable API behavior expect(res.status).toBe(201) expect(res.body).toHaveProperty('invoiceNumber') 3. Always Clear Mocks beforeEach(() => { vi.clearAllMocks() }) 4. Test Edge Cases for Financial Logic Always test in core unit tests: Zero amounts ( calculateInvoiceTotals([]) ) Empty input arrays Negative values (should throw) Decimal precision ( quantity: '3', unitPrice: '33.33' ) Large amounts (overflow protection) 5. Never Hard-Code UUIDs in Tests // BAD const orgId = '123e4567-e89b-12d3-a456-426614174000' // GOOD const orgId = TEST_ORG_ID // from setup.ts, generated per-run 12. Debugging Tests Run in Verbose Mode npx vitest run --reporter=verbose Run Single Test by Name npx vitest run -t "returns 201 with created invoice" Debug Mode (Node Inspector) npx vitest --inspect-brk # Then attach VS Code debugger or open chrome://inspect Print Mock Call History // In a test, add temporarily: console.log(mockService.createInvoice.mock.calls) VS Code Launch Configuration { "type": "node", "request": "launch", "name": "Debug Vitest", "runtimeExecutable": "npx", "runtimeArgs": ["vitest", "--inspect-brk", "--no-coverage"], "console": "integratedTerminal", "cwd": "${workspaceFolder}/apps/api-express" } Related Documents Test Inventory: TEST-INVENTORY.md Backend Architecture: ../backend/BACKEND-ARCHITECTURE.md CI/CD Pipeline: ../infrastructure/CI-CD.md Last Updated: 2026-03-02 Status: Active — ~390 tests across 27 files (mock + unit + E2E + real-DB suites) Coverage Target: >95% for @bilko/core financial logic, >80% for API routes Test Inventory Bilko — Test Inventory Status: Partially stale — recount required after Kotlin/Ktor migration and Playwright partitioning Version: 2.1 Last Updated: 2026-05-21 Author: ALAI Documentation Team This inventory catalogs implemented tests in Bilko, organized by package and file. It currently contains historical Express/Prisma-era details and must not be used as the source of truth for current test counts. For current policy, use TEST-STRATEGY.md , E2E-TEST-PLAN.md , and DEMO-TESTING-PLAN.md . Summary Refresh note (2026-05-21): quick filesystem inventory found apps/api/src/test/kotlin with many Kotlin test files, packages/core/tests , and apps/e2e/tests . The table below is historical until regenerated from the current tree. Package Test Files Test Cases Status apps/api/tests/ (mock suite) 11 test files ~130 Implemented apps/api/tests/unit/ (unit suite) 4 test files ~60 Implemented apps/api/tests/e2e/ (E2E suite) 2 test files ~30 Implemented apps/api/tests/integration/ (real DB) 5 test files ~50 Implemented packages/core/tests/ (unit) 5 test files 121 Implemented Total 27 ~390 Active Test Category Breakdown Category Description Requires DB Mock suite ( tests/*.test.ts ) API endpoint tests with mocked Prisma — fast, no DB No Unit suite ( tests/unit/ ) Service-layer tests, financial logic, SEF client No E2E suite ( tests/e2e/ ) End-to-end billing flows with mocked services No Integration suite ( tests/integration/ ) Real DB tests (docker-compose.test.yml) Yes — PostgreSQL Core unit ( packages/core/ ) Pure business logic — no HTTP, no DB No packages/core/tests/ — Unit Tests Pure unit tests for the @bilko/core financial engine. No database, no HTTP. Uses globals: true (no explicit imports of describe / it / expect ). accounting.test.ts — 20 tests Tests double-entry bookkeeping engine: validateDoubleEntry , createJournalEntry , calculateTrialBalance . Test Name What It Tests Status validateDoubleEntry - returns true for balanced entry Debit total equals credit total Implemented validateDoubleEntry - returns false for unbalanced entry Debit ≠ credit Implemented validateDoubleEntry - returns false for fewer than 2 lines Minimum 2 lines required Implemented validateDoubleEntry - returns false for empty lines Empty array → false Implemented validateDoubleEntry - returns false for negative amounts Negative amounts invalid Implemented validateDoubleEntry - returns false for zero amounts Zero amounts invalid Implemented validateDoubleEntry - handles multiple lines that sum to balanced Multi-line balanced entry Implemented validateDoubleEntry - handles decimal amounts with precision NUMERIC(19,4) precision Implemented createJournalEntry - returns entry when valid and balanced Happy path Implemented createJournalEntry - throws for fewer than 2 lines Error: "at least 2 lines" Implemented createJournalEntry - throws for empty lines array Error: "at least 2 lines" Implemented createJournalEntry - throws for missing description Error: "must have a description" Implemented createJournalEntry - throws for whitespace-only description Whitespace = missing Implemented createJournalEntry - throws for missing date Error: "must have a date" Implemented createJournalEntry - throws for unbalanced entry Error shows debit vs credit amounts Implemented createJournalEntry - throws for negative amount lines Negative amounts rejected Implemented calculateTrialBalance - returns balanced from balanced entries isBalanced=true, totals correct Implemented calculateTrialBalance - groups by account number Rows aggregated per account Implemented calculateTrialBalance - returns empty rows for no transactions Empty array → zero totals Implemented calculateTrialBalance - sorts rows by account number Rows sorted ascending Implemented chart-of-accounts.test.ts — 32 tests Tests chart of accounts operations: account creation, parent-child hierarchy, account type validation. Test Area Test Count Status Account creation (valid + invalid inputs) ~10 Implemented Account hierarchy (parent-child relationships) ~8 Implemented Account type validation (Asset/Liability/Equity/Revenue/Expense) ~7 Implemented Account code format validation (1xxx-5xxx range) ~7 Implemented invoicing.test.ts — 22 tests Tests invoice number generation, total calculations, and line item validation. Test Name What It Tests Status generateInvoiceNumber - generates INV-YYYY-NNN format Standard format Implemented generateInvoiceNumber - increments from last number Sequential numbering Implemented generateInvoiceNumber - pads number to 3 digits Zero-padding Implemented generateInvoiceNumber - handles numbers beyond 999 No truncation for 1000+ Implemented generateInvoiceNumber - throws for empty prefix Error: "prefix is required" Implemented generateInvoiceNumber - throws for whitespace-only prefix Whitespace = invalid Implemented generateInvoiceNumber - throws for negative lastNumber Error: "non-negative integer" Implemented generateInvoiceNumber - throws for non-integer lastNumber Float rejected Implemented calculateInvoiceTotals - calculates line item total quantity × unitPrice Implemented calculateInvoiceTotals - calculates subtotal as sum of lines Sum of all line totals Implemented calculateInvoiceTotals - calculates tax per line item lineTotal × taxRate / 100 Implemented calculateInvoiceTotals - calculates total = subtotal + tax Final total Implemented calculateInvoiceTotals - handles items without taxRate Missing tax = 0 Implemented calculateInvoiceTotals - returns zeros for empty items Empty array → all zeros Implemented calculateInvoiceTotals - handles multiple items with different rates Mixed 20%/10% tax Implemented calculateInvoiceTotals - maintains decimal precision 3 × 33.33 = 99.99 Implemented validateLineItem - returns true for valid item Happy path Implemented validateLineItem - returns false for zero quantity qty=0 invalid Implemented validateLineItem - returns false for negative quantity qty<0 invalid Implemented validateLineItem - returns false for negative unitPrice price<0 invalid Implemented validateLineItem - returns false for empty description Description required Implemented validateLineItem - returns false for whitespace-only description Whitespace = empty Implemented multi-currency.test.ts — 24 tests Tests currency conversion, exchange rate locking, and NUMERIC precision handling. Test Area Test Count Status Currency conversion (EUR/RSD/BAM) ~8 Implemented Exchange rate locking at transaction date ~6 Implemented NUMERIC(19,4) precision (no float drift) ~5 Implemented Edge cases (zero rate, missing rate, same currency) ~5 Implemented tax.test.ts — 23 tests Tests VAT calculations for all supported countries and edge cases. Test Area Test Count Status Serbia PDV (20% standard, 10% reduced, 0% exempt) ~6 Implemented BiH PDV (17% standard) ~4 Implemented Croatia PDV (25% standard, 13% reduced, 5% super-reduced) ~5 Implemented Mixed tax rates on single invoice ~3 Implemented Zero-rate exports ~2 Implemented Reverse VAT / gross-to-net ~3 Implemented apps/api/tests/ — Mock API Tests Integration tests for Express API endpoints. Tests use mocked Prisma client — no real database required. Setup in tests/setup.ts . setup.ts — Test Infrastructure (not a test file) Provides: Prisma client mock via vi.mock('../src/lib/prisma') Environment variable setup (JWT secrets, rate limits) createTestUser() — factory for test user objects generateTestAccessToken() — valid JWT for authenticated requests generateTestRefreshToken() — valid refresh token Constants: TEST_ORG_ID , TEST_USER_ID , TEST_USER_EMAIL auth.test.ts — 11 tests Test Name Route Status returns 201 with user, organization, and tokens POST /auth/register Implemented returns 400 for duplicate email POST /auth/register Implemented returns 200 with tokens for valid credentials POST /auth/login Implemented returns 401 for invalid password POST /auth/login Implemented returns 401 for non-existent email POST /auth/login Implemented returns 200 with new access token for valid refresh token POST /auth/refresh Implemented returns 401 when no refresh token cookie is present POST /auth/refresh Implemented returns 204 and clears cookie POST /auth/logout Implemented returns 200 with current user when authenticated GET /auth/me Implemented returns 401 when no token provided GET /auth/me Implemented returns 401 for invalid token GET /auth/me Implemented invoices.test.ts — 11 tests Test Name Route Status returns 200 with paginated list GET /invoices Implemented returns 401 without auth GET /invoices Implemented returns 201 with created invoice POST /invoices Implemented returns 200 with full invoice GET /invoices/:id Implemented returns 404 when invoice not found GET /invoices/:id Implemented returns 200 when updating draft invoice PUT /invoices/:id Implemented returns 400 when updating sent invoice PUT /invoices/:id Implemented returns 200 when sending invoice (draft -> sent) PATCH /invoices/:id/status Implemented returns 200 when marking invoice as paid (sent -> paid) PATCH /invoices/:id/status Implemented returns 204 when deleting draft invoice DELETE /invoices/:id Implemented returns 400 when deleting sent invoice DELETE /invoices/:id Implemented expenses.test.ts — 9 tests Test Area Route Status List expenses (200 + auth) GET /expenses Implemented Create expense (201) POST /expenses Implemented Get expense by ID (200 + 404) GET /expenses/:id Implemented Update expense (200 + 400 for non-pending) PUT /expenses/:id Implemented Approve expense (200 + role check) PATCH /expenses/:id/approve Implemented Delete expense (204 + 400 for approved) DELETE /expenses/:id Implemented contacts.test.ts — 9 tests Test Area Route Status List contacts (200 + auth) GET /contacts Implemented Create contact (201 + validation) POST /contacts Implemented Get contact (200 + 404) GET /contacts/:id Implemented Update contact (200) PUT /contacts/:id Implemented Delete contact (204) DELETE /contacts/:id Implemented accounts.test.ts — 4 tests Test Area Route Status List chart of accounts (200) GET /accounts Implemented Get account by ID (200 + 404) GET /accounts/:id Implemented Create account (201) POST /accounts Implemented Delete account (204) DELETE /accounts/:id Implemented banking.test.ts — 10 tests Test Area Route Status List bank accounts (200) GET /banking/accounts Implemented Create bank account (201) POST /banking/accounts Implemented Import bank statement (200 + validation) POST /banking/accounts/:id/import Implemented List bank transactions (200) GET /banking/accounts/:id/transactions Implemented Reconcile transaction (200 + 400 for mismatch) POST /banking/accounts/:id/reconcile Implemented reports.test.ts — 9 tests Test Area Route Status Profit & Loss report (200 + date range) GET /reports/profit-loss Implemented Balance sheet (200) GET /reports/balance-sheet Implemented VAT report for RS (200 + correct rates) GET /reports/vat Implemented Trial balance (200) GET /reports/trial-balance Implemented Auth required on all report endpoints All report routes Implemented transactions.test.ts — 9 tests Test Area Route Status List transactions (200 + pagination) GET /transactions Implemented Filter by account (200) GET /transactions?accountId= Implemented Get transaction by ID (200 + 404) GET /transactions/:id Implemented Auth required All transaction routes Implemented country.test.ts — 27 tests Tests the country plugin integration — routes that return country-specific tax configuration. Test Area Route Status Serbian PDV rates (20/10/0) for RS org GET /country/tax-rates Implemented Bosnian PDV rate (17) for BA org GET /country/tax-rates Implemented Croatian PDV rates (25/13/5/0) for HR org GET /country/tax-rates Implemented Invoice number format per country GET /country/invoice-format Implemented Auth required All country routes Implemented Unknown country code (400) GET /country/tax-rates Implemented chatbot.test.ts — Chatbot API Tests Test Name Route Status returns 200 with assistant response POST /chatbot/message Implemented returns 400 when message is empty POST /chatbot/message Implemented returns 429 when rate limit exceeded POST /chatbot/message Implemented returns 401 without auth POST /chatbot/message Implemented returns 200 with conversation history GET /chatbot/history Implemented returns empty array when no history exists GET /chatbot/history Implemented returns 401 without auth on history GET /chatbot/history Implemented returns 204 when history cleared DELETE /chatbot/history Implemented returns 401 without auth on clear history DELETE /chatbot/history Implemented invoice-gl-reversal.test.ts — Invoice GL Reversal Tests Tests InvoiceService.cancelInvoice() — when a SENT invoice is cancelled, reversing double-entry GL entries are created to undo the original booking. Test Area Status Cancels draft invoice (sets status to cancelled, no GL reversal) Implemented Cancels sent invoice (creates reversing GL transactions) Implemented Reversal debits = original credits (GL stays balanced) Implemented Throws 404 when invoice not found Implemented Throws 400 when invoice is already cancelled Implemented Throws 400 when invoice is paid (cannot cancel paid invoices) Implemented new-endpoints.test.ts — Additional Endpoint Tests Tests for supplemental endpoints not covered in the main mock suite. Test Area Route Status Receipt not attached (404) GET /expenses/:id/receipt Implemented Receipt auth required (401) GET /expenses/:id/receipt Implemented VAT export PDF (200 / 404) GET /reports/vat/export/pdf Implemented VAT export XML (200 / 404) GET /reports/vat/export/xml Implemented Dashboard metrics (200) GET /reports/dashboard Implemented Dashboard auth required (401) GET /reports/dashboard Implemented Audit log (200 + owner/admin only) GET /security/audit-log Implemented Audit log role check (403 for viewer) GET /security/audit-log Implemented Data export (200 + owner only) POST /security/data-export Implemented Data export role check (403 for admin) POST /security/data-export Implemented e2e/api.test.ts — Full E2E (no mocks, live server) End-to-end API integration test. Exercises the full Express application stack with a live server. Status Implemented e2e/billing-flow.e2e.test.ts — Billing Workflow E2E Tests the full billing flow through HTTP endpoints with mocked services (no real DB required): Create contact Create invoice Send invoice (draft → sent) Mark invoice paid (sent → paid) Check P&L shows revenue Verify trial balance returns isBalanced=true Multi-currency invoice in EUR with country VAT rates Credit note creation Status Implemented apps/api/tests/unit/ — Unit Tests (service layer) Service-level unit tests with mocked Prisma. No HTTP layer. Tests business logic in individual service classes. invoice-service-calculations.test.ts — Invoice Arithmetic Tests InvoiceService.createInvoice() arithmetic at the service layer. Verifies: lineTotal = quantity × unitPrice lineTax = lineTotal × taxRate / 100 subtotal = sum(lineTotals) taxAmount = sum(lineTaxes) total = subtotal + taxAmount baseAmount = total × exchangeRate Test Area Status Single-line invoice arithmetic Implemented Multi-line invoice with mixed tax rates Implemented Multi-currency exchange rate application Implemented Zero-quantity line items rejected Implemented NUMERIC(19,4) precision maintained Implemented two-factor.test.ts — Two-Factor Authentication Service Tests TwoFactorService at the service level. Mocks: bcryptjs, speakeasy, qrcode, Prisma. Test Name Status enable - wrong password → throws unauthorized Implemented enable - correct password → returns secret + QR data URL + 10 codes Implemented enable - backup codes are hashed before storing Implemented enable - plaintext backup codes returned once only Implemented verify - valid TOTP + window=1 → activates 2FA Implemented verify - invalid TOTP → throws badRequest Implemented disable - correct password → clears secret + backup codes Implemented disable - wrong password → throws unauthorized Implemented sef-submission.test.ts — SEF (Serbia E-Invoicing) Client Tests SefClient class and InvoiceService.submitToSef() fire-and-forget behavior. HTTP calls are mocked via vi.spyOn(global, 'fetch') . Test Area Status SefClient submits UBL XML to SEF sandbox URL Implemented SefClient uses production URL in production env Implemented SefClient retries on network failure Implemented createSefClientFromEnv() reads credentials from env vars Implemented generateSEFInvoiceXML() produces valid UBL 2.1 XML Implemented InvoiceService.submitToSef() never re-throws errors Implemented vat-calculation.test.ts — VAT Calculation Tests (Country Packages) Tests pure calculation functions from @bilko/country-rs , @bilko/country-ba , and @bilko/country-hr . No Prisma, no HTTP — stateless math functions. Country Functions Tested Status Serbia calculateSerbianPDV , calculateNetFromGrossPDV , qualifiesForPausalRegime , requiresVATRegistration , calculateSerbianCIT Implemented Bosnia calculateBosnianPDV , calculateNetFromGrossPDV , requiresVATRegistration Implemented Croatia calculateCroatianPDV , calculateNetFromGrossPDV , requiresVATRegistration Implemented All rates Standard, reduced, zero, super-reduced rates per country Implemented apps/api/tests/integration/ — Real Database Tests Integration tests that run against a real PostgreSQL database via docker-compose.test.yml . Requires Docker. Run with: cd apps/api docker-compose -f ../../docker-compose.test.yml up -d npm run test:integration auth.integration.test.ts — Auth Integration Full registration + login + refresh flow against real DB. Test Area Status Register new organization + owner user Implemented Login with correct credentials Implemented Refresh access token via cookie Implemented Reject duplicate email registration Implemented invoice.integration.test.ts — Invoice CRUD Integration Invoice lifecycle against real DB: create → read → update → status change. Test Area Status Create invoice with line items Implemented Read invoice by ID Implemented Update draft invoice Implemented Send invoice (draft → sent) Implemented Mark paid (sent → paid) Implemented credit-note-gl.integration.test.ts — Credit Note GL Integration Tests credit note creation against real DB — verifies reversing GL entries balance. Test Area Status Credit note creates reversing journal entries Implemented Reversing entries balance (debits = credits) Implemented Original invoice marked as credited Implemented report.integration.test.ts — Reports Integration Report generation against real DB with seeded transactions. Test Area Status P&L report returns revenue/expense data Implemented VAT report returns correct tax amounts Implemented Trial balance returns isBalanced=true Implemented tenant-isolation.integration.test.ts — Multi-Tenant Security Verifies multi-tenant isolation: Organization A cannot read or modify Organization B's data. Test Area Status Org A cannot list Org B's invoices Implemented Org A cannot read Org B's invoice by ID Implemented Org A cannot update Org B's invoice Implemented Org A cannot delete Org B's contact Implemented Cross-tenant requests return 404 (not 403 — no data leakage) Implemented Coverage Tracking Module Current Status Target @bilko/core accounting engine Tests implemented (20 cases) >95% @bilko/core invoicing Tests implemented (22 cases) >95% @bilko/core tax/VAT Tests implemented (23 cases) >95% @bilko/core multi-currency Tests implemented (24 cases) >90% @bilko/core chart of accounts Tests implemented (32 cases) >90% Auth API Tests implemented (11 cases) >85% Invoices API Tests implemented (11 cases) >80% Expenses API Tests implemented (9 cases) >80% Banking API Tests implemented (10 cases) >75% Reports API Tests implemented (9 cases) >75% Country/Tax-Rates API Tests implemented (27 cases) >80% Chatbot API Tests implemented (9 cases) >80% Security/Audit API Tests implemented (via new-endpoints) >80% Invoice GL Reversal (service layer) Tests implemented >90% Two-Factor Auth (service layer) Tests implemented (8 cases) >90% SEF Client + Submission Tests implemented >85% VAT Calculations (country packages) Tests implemented >95% Multi-tenant isolation (real DB) Tests implemented (5 scenarios) 100% Invoice CRUD (real DB) Tests implemented >80% Credit note GL (real DB) Tests implemented >90% Test Execution Commands # All tests (from project root — mock + unit + E2E suites) npm run test # Core unit tests only cd packages/core && npx vitest run # API mock suite only (no DB required) cd apps/api-express && npx vitest run # API unit suite only cd apps/api-express && npx vitest run tests/unit/ # API E2E suite only (mocked services, no DB) cd apps/api-express && npx vitest run tests/e2e/ # Real DB integration tests (requires docker-compose.test.yml) docker-compose -f docker-compose.test.yml up -d cd apps/api-express && npm run test:integration # Watch mode (re-run on change) cd packages/core && npx vitest cd apps/api-express && npx vitest # Specific file cd apps/api-express && npx vitest run tests/auth.test.ts cd apps/api-express && npx vitest run tests/unit/two-factor.test.ts # With coverage cd packages/core && npx vitest run --coverage # Verbose output npx vitest run --reporter=verbose Related Documents Testing Guide: TESTING-GUIDE.md Backend Architecture: ../backend/BACKEND-ARCHITECTURE.md Last Updated: 2026-03-02 Status: Active Total Tests: ~390 across 27 test files (mock + unit + E2E + integration) Test Plan Bilko — Test Plan Version: 1.1 Date: 2026-05-21 Project ID: bbd77cc0 Status: Partially stale — use docs/testing/TEST-STRATEGY.md , docs/testing/E2E-TEST-PLAN.md , and docs/testing/DEMO-TESTING-PLAN.md for current policy Update note: this long-form plan still contains historical Express/Prisma examples. Current Bilko policy is layered testing: focused unit tests, strong Ktor/PostgreSQL integration/contract tests, critical Playwright E2E, non-destructive real-demo smoke, and resettable full-demo rehearsal. Table of Contents Test Philosophy Unit Test Strategy Integration Test Strategy End-to-End Test Strategy Accounting Scenario Tests Regulatory Compliance Tests Performance Benchmarks Security Tests Test Infrastructure Test Coverage Targets 1. Test Philosophy 1.1 Existing Tests The @bilko/core package has unit tests written with Vitest : Test File Coverage packages/core/tests/accounting.test.ts validateDoubleEntry , createJournalEntry , calculateTrialBalance packages/core/tests/tax.test.ts calculateVAT , getDefaultVATRate , getVATRates , calculateNetFromGross , calculateCIT packages/core/tests/multi-currency.test.ts convertCurrency , lockExchangeRate , calculateForexGainLoss packages/core/tests/invoicing.test.ts Invoice calculation helpers packages/core/tests/chart-of-accounts.test.ts Chart structure validation 1.2 Test Pyramid ┌─────────┐ │ E2E │ ← Fewer, slower, critical user flows │ Tests │ └────┬────┘ ┌────┴────┐ │ Integ │ ← API endpoints with real test DB │ Tests │ └────┬────┘ ┌─────────┴──────────┐ │ Unit Tests │ ← Core engine, services, validators (fast, many) └────────────────────┘ graph TD subgraph PYRAMID["Bilko Test Pyramid"] E2E["E2E Tests — 10%
Playwright
5 critical flows
Staging environment
~60s per test"] INT["Integration Tests — 30%
Supertest + Vitest
Real PostgreSQL
API endpoints
~5s per test"] UNIT["Unit Tests — 60%
Vitest
@bilko/core engine
Pure functions
~50ms per test"] end E2E --> INT INT --> UNIT UNIT --> U1["accounting.test.ts"] UNIT --> U2["tax.test.ts"] UNIT --> U3["multi-currency.test.ts"] UNIT --> U4["bank-import.test.ts"] UNIT --> U5["chart-of-accounts.test.ts"] INT --> I1["auth.test.ts"] INT --> I2["invoices.test.ts"] INT --> I3["expenses.test.ts"] INT --> I4["reports.test.ts"] INT --> I5["isolation.test.ts"] E2E --> E1["invoice-lifecycle.spec.ts"] E2E --> E2["expense-flow.spec.ts"] E2E --> E3["bank-reconciliation.spec.ts"] E2E --> E4["reports.spec.ts"] E2E --> E5["auth.spec.ts"] style PYRAMID fill:#f8f9fa,stroke:#dee2e6 style E2E fill:#dc3545,color:#fff,stroke:#c82333 style INT fill:#fd7e14,color:#fff,stroke:#e8690b style UNIT fill:#198754,color:#fff,stroke:#157347 1.3 Non-Negotiable Rules Money is never JavaScript number — all monetary tests use Decimal.js or string assertions Double-entry always balanced — every test that creates a financial transaction verifies debit = credit Organization isolation — cross-org data access must be impossible (tested explicitly) Immutability — locked transactions cannot be modified (must throw/fail) Audit trail — mutations must create LoggedAction entries (tested in integration) 2. Unit Test Strategy Framework: Vitest (already configured in packages/core/vitest.config.ts ) Run: cd packages/core && npx vitest 2.1 Core Accounting Engine ( @bilko/core ) accounting/index.ts — Double-Entry Engine File: packages/core/tests/accounting.test.ts (EXISTS) Test Case Assertion Balanced entry: debit = credit validateDoubleEntry returns true Unbalanced entry: debit ≠ credit Returns false Less than 2 lines Returns false Negative amounts Returns false Zero amounts Returns false Multiple lines summing to balanced Returns true Decimal amounts with 4dp precision Returns true createJournalEntry with valid data Returns entry unchanged Missing description Throws "must have a description" Missing date Throws "must have a date" Unbalanced amounts in error message Error shows actual debit/credit totals calculateTrialBalance from balanced entries isBalanced = true , sums correct calculateTrialBalance groups by account number Same account accumulated correctly calculateTrialBalance empty input isBalanced = true , empty rows calculateTrialBalance sorts by account number Rows sorted ascending Additional tests needed: describe('Immutable transaction locking', () => { it('locked transactions cannot have amount changed') it('locked transactions cannot change debit/credit accounts') it('locked = true after period close') }) tax/index.ts — VAT/CIT Calculator File: packages/core/tests/tax.test.ts (EXISTS) Test Case Assertion Serbia PDV 20% on 1000 base=1000, tax=200, total=1200 BiH PDV 17% on 1000 base=1000, tax=170, total=1170 Croatia PDV 25% on 1000 base=1000, tax=250, total=1250 Zero rate tax=0, total=base Decimal base amounts (123.45 at 20%) tax=24.69, total=148.14 Negative amount Throws "non-negative" Negative rate Throws "non-negative" Large amounts (999,999,999.9999) No precision loss Decimal input accepted Same result as string getDefaultVATRate('RS') Returns 20 getDefaultVATRate('BA') Returns 17 getDefaultVATRate('HR') Returns 25 Unsupported country Throws "Unsupported country" getVATRates('RS') 3 rates: 20, 10, 0 getVATRates('BA') 2 rates: 17, 0 getVATRates('HR') 3 rates: 25, 13, 0 Returns copies (immutable) Mutation doesn't affect originals Reverse VAT (BiH 1170 gross) base≈1000, tax≈170 CIT at 15% 100000 → 15000 Additional tests needed (country modules): // packages/country-rs/src/tax/index.ts describe('Serbian tax specifics', () => { it('calculateSerbianPDV standard 20%') it('calculateSerbianPDV reduced 10%') it('calculateSerbianPDV zero rate') it('calculateSerbianCIT 15% flat') it('qualifiesForPausalRegime: revenue < 6M RSD → true') it('qualifiesForPausalRegime: revenue >= 6M RSD → false') it('requiresVATRegistration: revenue >= 8M RSD → true') it('requiresVATRegistration: revenue < 8M RSD → false') }) // packages/country-ba/src/tax/index.ts describe('Bosnian tax specifics', () => { it('calculateBosnianPDV single 17% rate') it('calculateCITFBiH 10%') it('calculateCITRS 10%') it('calculateDividendWHT FBiH: dividends 5%') it('calculateDividendWHT RS: dividends 10%') it('requiresVATRegistration: >= 100000 BAM → true') }) // packages/country-hr/src/tax/index.ts describe('Croatian tax specifics', () => { it('calculateCroatianPDV standard 25%') it('calculateCroatianPDV reduced 13%') it('calculateCroatianPDV superReduced 5%') it('calculateCroatianCIT: revenue < 1M EUR → 10%') it('calculateCroatianCIT: revenue >= 1M EUR → 18%') it('qualifiesForPausalni: revenue < 60000 EUR → true') it('requiresVATRegistration: revenue >= 60000 EUR → true') }) multi-currency/index.ts — Currency Conversion File: packages/core/tests/multi-currency.test.ts (EXISTS) Test Case Assertion Same currency Rate = 1, no conversion RSD to EUR at rate 0.0086 Correct base amount lockExchangeRate returns ExchangeRate object Correct fields lockExchangeRate same currency Throws error lockExchangeRate rate ≤ 0 Throws "must be positive" convertCurrency with zero fromRate Throws calculateForexGainLoss gain scenario gain > 0 , loss = 0 calculateForexGainLoss loss scenario gain = 0 , loss > 0 isSupportedCurrency('EUR') true isSupportedCurrency('XYZ') false Precision: toFixed(4) on result 4 decimal places bank-import/index.ts — CSV Parser File: packages/core/tests/bank-import.test.ts (MISSING — needs creation) describe('parseCSV', () => { it('parses ISO date format YYYY-MM-DD') it('parses Balkan dot format DD.MM.YYYY') it('parses slash format DD/MM/YYYY') it('skips header line') it('skips empty lines') it('returns empty array for empty string') it('returns empty array for header-only CSV') it('sets direction: inbound by default') it('sets direction: outbound when field is "outbound"') it('handles quoted fields with commas') it('generates deterministic IDs for dedup') }) describe('detectDuplicates', () => { it('detects exact duplicate by date+amount+currency+reference') it('returns empty array when no duplicates') it('returns empty array when either list is empty') it('does NOT flag as duplicate if amount differs') it('does NOT flag as duplicate if date differs') it('does NOT flag as duplicate if reference differs (but amount/date same)') }) 2.2 Validator Unit Tests Framework: Vitest Location: apps/api/src/validators/*.ts describe('Invoice validators (createInvoiceSchema)', () => { it('valid invoice passes') it('missing customerId fails') it('invalid date format fails') it('negative unitPrice fails') it('empty items array fails') it('taxRate > 100 fails') it('invalid currencyCode (5 chars) fails') it('invalid UUID for customerId fails') }) describe('Auth validators (registerSchema)', () => { it('valid registration passes') it('invalid email fails') it('password too short fails (< 8 chars)') it('invalid country code (not RS/BA/HR) fails') it('missing organizationName fails') }) Integration Test Architecture sequenceDiagram participant TC as Test Case participant ST as Supertest participant APP as Express App participant MID as Middleware
(Auth + RBAC) participant SVC as Service Layer participant PRI as Prisma ORM participant DB as Test PostgreSQL TC->>ST: HTTP request + Bearer token ST->>APP: Forward request APP->>MID: Authenticate JWT MID->>MID: Verify organizationId scope MID->>SVC: Authorized request SVC->>PRI: DB query (org-scoped) PRI->>DB: Parameterized SQL DB-->>PRI: Result rows PRI-->>SVC: Typed objects SVC-->>APP: Response data APP-->>ST: HTTP response ST-->>TC: Assert status + body Note over DB: beforeEach: seed
afterEach: truncate
(reverse FK order) 3. Integration Test Strategy Framework: Supertest + Vitest (or Jest) Database: Test PostgreSQL instance (separate from dev/prod) Setup: Prisma migrations applied before tests; data seeded per test suite; truncated after each test 3.1 Test Database Setup // test/setup.ts import { prisma } from '../src/lib/prisma' import { execSync } from 'child_process' beforeAll(async () => { // Apply migrations to test DB execSync('npx prisma migrate deploy', { env: { DATABASE_URL: process.env.TEST_DATABASE_URL } }) }) beforeEach(async () => { // Seed minimal data: 1 org, 1 owner user, default accounts await seedTestOrg() }) afterEach(async () => { // Clean up in reverse FK order await prisma.loggedAction.deleteMany() await prisma.bankTransaction.deleteMany() await prisma.bankAccount.deleteMany() await prisma.transaction.deleteMany() await prisma.invoiceItem.deleteMany() await prisma.invoice.deleteMany() await prisma.expense.deleteMany() await prisma.contact.deleteMany() await prisma.account.deleteMany() await prisma.user.deleteMany() await prisma.organization.deleteMany() }) 3.2 Auth Endpoints describe('POST /api/v1/auth/register', () => { it('creates org + owner user, returns tokens') it('returns 409 for duplicate email') it('returns 400 for missing required fields') it('password is hashed (not stored plain)') it('sets refreshToken httpOnly cookie') it('org baseCurrency defaults to EUR') }) describe('POST /api/v1/auth/login', () => { it('returns accessToken + sets cookie on valid credentials') it('returns 401 for wrong password') it('returns 401 for non-existent email') it('updates lastLoginAt on success') it('rememberMe=true extends cookie to 30 days') }) describe('POST /api/v1/auth/refresh', () => { it('returns new accessToken from valid refresh cookie') it('returns 401 when no cookie') it('returns 401 for expired refresh token') }) describe('POST /api/v1/auth/logout', () => { it('clears refreshToken cookie') it('returns 204') }) describe('GET /api/v1/auth/me', () => { it('returns user + org data for valid token') it('returns 401 for missing token') it('returns 401 for expired token') }) 3.3 Invoice Endpoints describe('GET /api/v1/invoices', () => { it('returns paginated invoices for organization') it('does NOT return invoices from other orgs') it('filters by status') it('filters by customerId') it('filters by date range') it('returns empty data array when no invoices') it('returns 401 without auth') }) describe('POST /api/v1/invoices', () => { it('creates invoice in draft status') it('auto-generates invoice number INV-YYYY-001') it('increments invoice number sequentially') it('calculates subtotal correctly from line items') it('calculates taxAmount at specified rate') it('sets baseAmount = totalAmount when currency = baseCurrency') it('locks exchange rate from ExchangeRate table') it('returns 404 for non-existent customerId') it('returns 400 for contact that is vendor only (not customer)') }) describe('PATCH /api/v1/invoices/:id/status → send', () => { it('changes status from draft to sent') it('creates Transaction: DR Receivable / CR Revenue') it('transaction.amount = invoice.totalAmount') it('transaction.referenceType = invoice, referenceId = invoice.id') it('returns 400 if invoice already sent') it('returns 400 if required accounts not in chart of accounts') }) describe('PATCH /api/v1/invoices/:id/status → mark-paid', () => { it('changes status from sent to paid') it('creates Transaction: DR Bank / CR Receivable') it('sets paidAt to provided date') it('returns 400 if invoice is still draft') }) describe('DELETE /api/v1/invoices/:id', () => { it('deletes draft invoice') it('returns 400 when trying to delete sent invoice') it('returns 404 for non-existent invoice') it('cannot delete invoice from another org') }) 3.4 Expense Endpoints describe('POST /api/v1/expenses', () => { it('creates expense in pending status') it('auto-generates expense number EXP-YYYY-001') it('stores taxAmount separately') it('locks exchange rate at expenseDate') }) describe('PATCH /api/v1/expenses/:id/approve', () => { it('changes status from pending to approved') it('creates Transaction: DR Expense / CR Payable') it('returns 400 for non-pending expense') }) describe('PATCH /api/v1/expenses/:id/pay', () => { it('changes status from approved to paid') it('creates Transaction: DR Payable / CR Bank') it('returns 400 for non-approved expense') }) 3.5 Transaction Endpoints describe('POST /api/v1/transactions (manual journal)', () => { it('accountant can create manual transaction') it('viewer cannot create manual transaction (403)') it('debit and credit account must be different (422)') it('debit account must belong to same org (404)') it('credit account must belong to same org (404)') it('creates transaction with correct amounts') it('referenceType = manual') }) describe('GET /api/v1/transactions', () => { it('filters by accountId (both debit and credit sides)') it('filters by referenceType') it('filters by date range') it('does not return transactions from other orgs') }) 3.6 Report Endpoints describe('GET /api/v1/reports/trial-balance', () => { it('returns balanced trial balance (totalDebits = totalCredits)') it('returns balanced = true when no transactions') it('includes all accounts with transactions') it('debit-normal accounts: balance = debit - credit') it('credit-normal accounts: balance = credit - debit') }) describe('GET /api/v1/reports/profit-loss', () => { it('revenue accounts (type=4) in revenue section') it('expense accounts (type=5) in expenses section') it('netProfit = revenue - expenses') it('respects date range filter') }) describe('GET /api/v1/reports/vat', () => { it('outputVAT sum from invoice.taxAmount for sent/paid invoices') it('inputVAT sum from expense.taxAmount for approved/paid expenses') it('netVAT = outputVAT - inputVAT') it('draft invoices excluded from output VAT') it('pending expenses excluded from input VAT') }) 3.7 Multi-Tenancy Isolation Tests describe('Organization isolation', () => { let org1Token: string; let org2Token: string; let org1InvoiceId: string; beforeEach(async () => { // Create two separate organizations org1Token = await registerAndLogin('org1@test.rs'); org2Token = await registerAndLogin('org2@test.rs'); // Create invoice in org1 const res = await createInvoice(org1Token, { ... }); org1InvoiceId = res.body.id; }); it('org2 cannot GET invoice from org1 (returns 404)'); it('org2 cannot PUT invoice from org1 (returns 404)'); it('org2 cannot DELETE invoice from org1 (returns 404)'); it('org2 list invoices does not include org1 invoices'); it('org2 cannot GET org1 contacts'); it('org2 cannot GET org1 transactions'); it('org2 cannot GET org1 bank accounts'); it('org2 trial balance does not include org1 accounts'); }); Invoice Lifecycle — Integration Test Flow stateDiagram-v2 [*] --> Draft: POST /api/v1/invoices
(test: creates draft, generates INV-YYYY-NNN) Draft --> Sent: PATCH /status → send
(test: DR Receivable / CR Revenue) Draft --> Deleted: DELETE /invoices/:id
(test: draft can be deleted) Sent --> Paid: PATCH /status → mark-paid
(test: DR Bank / CR Receivable) Sent --> Deleted_ERR: DELETE attempt
(test: returns 400 — cannot delete sent) Paid --> [*]: Trial balance balanced
(test: Receivable = 0, balanced=true) state "Sent → mark-paid" as Paid { [*] --> TX_Created: Transaction created TX_Created --> GL_Updated: General Ledger updated GL_Updated --> Reconciled: BankTransaction matched } note right of Draft Auto-generates invoice number Locks exchange rate if foreign currency Validates customerId belongs to org end note note right of Sent Creates accounting transaction referenceType = invoice referenceId = invoice.id end note 4. End-to-End Test Strategy Framework: Playwright Target: Critical business flows that span the full stack Environment: Staging environment with seeded data 4.1 User Registration and Setup test('New user can register, set up org, and access dashboard', async ({ page }) => { // 1. Navigate to /register // 2. Fill in org name, country=RS, email, password // 3. Submit → redirected to dashboard // 4. Dashboard loads with zero-state (empty metrics) // 5. Logout → redirected to /login // 6. Login with same credentials → dashboard again }) 4.2 Complete Invoice Flow test('Create invoice → send → mark paid → check P&L', async ({ page }) => { // Step 1: Create contact (customer) await page.goto('/contacts/new') await fillContactForm({ name: 'Test Customer', type: 'customer' }) await page.click('button[type=submit]') // Step 2: Create invoice await page.goto('/invoices/new') // Fill 6-step wizard: customer, date, items (1000 RSD + 20% PDV), review await completeInvoiceWizard({ customer: 'Test Customer', amount: 1000, taxRate: 20 }) // Verify: status = draft, total = 1200 RSD // Step 3: Send invoice await page.click('button:text("Send Invoice")') // Verify: status = sent // Step 4: Mark paid await page.click('button:text("Mark as Paid")') await page.fill('[name=paidAt]', '2026-02-20') await page.click('button:text("Confirm")') // Verify: status = paid, paidAt set // Step 5: Check P&L report await page.goto('/reports?from=2026-01-01&to=2026-12-31') await expect(page.locator('[data-testid=revenue-total]')).toContainText('1,200.00') // Step 6: Check trial balance (balanced) await page.goto('/reports/trial-balance') await expect(page.locator('[data-testid=balanced-indicator]')).toBeVisible() }) 4.3 Expense Approval Flow test('Create expense → approve → pay → check trial balance', async ({ page }) => { // Step 1: Create expense (office supplies, 5000 RSD, 17% PDV) // Step 2: Approve expense → DR Office Expense / CR Accounts Payable // Step 3: Pay expense → DR Accounts Payable / CR Bank // Step 4: Verify trial balance is still balanced // Step 5: Verify P&L shows expense in correct category }) 4.4 Bank Reconciliation Flow test('Import bank statement → reconcile with invoice payment', async ({ page }) => { // Pre-condition: Paid invoice exists (DR Bank / CR Receivable transaction) // Step 1: Go to Banking page // Step 2: Import CSV with matching payment entry // Step 3: Verify imported: 1, duplicates: 0 // Step 4: Match bank transaction to GL transaction // Step 5: Verify BankTransaction.reconciled = true // Step 6: Verify Transaction.reconciled = true }) 4.5 VAT Report Generation test('VAT report reflects invoices and expenses for period', async ({ page }) => { // Pre-condition: 3 sent invoices with 20% PDV, 2 approved expenses with PDV // Step 1: Navigate to Reports → VAT Report // Step 2: Set period to current month // Step 3: Verify: output VAT = sum of invoice tax amounts // Step 4: Verify: input VAT = sum of expense tax amounts // Step 5: Verify: net VAT = output - input // Step 6: Download/export VAT report (future feature) }) E2E Test Flow — Complete Invoice Lifecycle flowchart TD START([Browser: /login]) --> LOGIN[Fill credentials
demo@bilko.io] LOGIN --> DASH[Dashboard loaded
assert: zero-state metrics] DASH --> NEW_CONTACT["/contacts/new
Create: Test Customer"] NEW_CONTACT --> NEW_INV["/invoices/new
6-step wizard"] NEW_INV --> W1["Step 1: Select Customer
assert: customer appears in dropdown"] W1 --> W2["Step 2: Set dates
invoiceDate, dueDate"] W2 --> W3["Step 3: Add line items
1000 RSD + 20% PDV = 1200 RSD total"] W3 --> W4["Step 4: Currency & exchange rate"] W4 --> W5["Step 5: Notes / payment terms"] W5 --> W6["Step 6: Review & Create
assert: subtotal=1000, tax=200, total=1200"] W6 --> INV_DETAIL["Invoice detail page
assert: status=draft, number=INV-YYYY-NNN"] INV_DETAIL --> SEND["Click: Send Invoice
assert: status=sent"] SEND --> PAY["Click: Mark as Paid
Enter paidAt date"] PAY --> PAID["assert: status=paid, paidAt set"] PAID --> PL["/reports?from=...&to=...
assert: revenue-total = 1,200.00"] PL --> TB["/reports/trial-balance
assert: balanced-indicator visible
assert: Receivable balance = 0"] style START fill:#198754,color:#fff style TB fill:#0d6efd,color:#fff style PAID fill:#198754,color:#fff 5. Accounting Scenario Tests These tests verify correctness of the double-entry system under real-world accounting scenarios. 5.1 Invoice → Payment → Reconciliation Scenario: Company issues invoice, receives payment, reconciles bank statement test('Full invoice lifecycle creates correct ledger entries', async () => { // 1. Create invoice: 100,000 RSD net + 20,000 RSD PDV = 120,000 RSD total // 2. Send invoice → Transaction: DR Receivable 120,000 / CR Revenue 120,000 // 3. Mark paid → Transaction: DR Bank 120,000 / CR Receivable 120,000 // 4. Trial balance: Bank +120,000 / Revenue +120,000 (balanced) // 5. Receivable account balance = 0 (opened and closed) // 6. General ledger shows both entries on Receivable account const trialBalance = await getTrialBalance() expect(trialBalance.balanced).toBe(true) const receivable = trialBalance.accounts.find((a) => a.code.startsWith('12')) expect(receivable.balance).toBe('0.0000') }) 5.2 Multi-Currency Invoice Scenario: RSD-based company invoices EUR customer test('EUR invoice stored and reported in RSD base currency', async () => { // Exchange rate: 1 EUR = 117.25 RSD (locked at invoice date) // Invoice: 1,000 EUR + 200 EUR PDV = 1,200 EUR // Expected baseAmount: 1,200 × 117.25 = 140,700 RSD const invoice = await createInvoice({ currencyCode: 'EUR', items: [{ quantity: 1, unitPrice: 1000, taxRate: 20 }], invoiceDate: '2026-02-01', // rate exists for this date }) expect(invoice.currencyCode).toBe('EUR') expect(invoice.totalAmount).toBe('1200.0000') expect(invoice.exchangeRate).toBe('117.250000') expect(invoice.baseAmount).toBe('140700.0000') // When paid: DR Bank 140,700 RSD / CR Receivable 140,700 RSD await markPaid(invoice.id, '2026-02-15') const transaction = await getTransactionForInvoice(invoice.id, 'payment') expect(transaction.baseAmount).toBe('140700.0000') }) 5.3 VAT Calculation Accuracy test('VAT calculated with Decimal precision, no float errors', async () => { // Known float trap: 0.1 + 0.2 ≠ 0.3 in JavaScript float // Test with amounts that expose float precision issues const result = calculateVAT('123.45', '20') // Expected: tax = 123.45 × 0.20 = 24.69 (not 24.690000000000003) expect(result.tax.toString()).toBe('24.6900') expect(result.total.toString()).toBe('148.1400') // Large amount const large = calculateVAT('999999.9999', '17') expect(large.tax.toString()).toBe('169999.9998') // exact }) 5.4 Trial Balance After Multiple Transactions test('Trial balance remains balanced after 10 invoices and 5 expenses', async () => { // Create 10 invoices (all sent + paid) for (let i = 0; i < 10; i++) { const inv = await createAndSendInvoice(orgId, 10000 + i * 100) await markPaid(inv.id, today) } // Create 5 expenses (all approved + paid) for (let i = 0; i < 5; i++) { const exp = await createAndApproveExpense(orgId, 5000 + i * 50) await payExpense(exp.id) } const tb = await getTrialBalance(orgId) expect(tb.balanced).toBe(true) // Total debits must equal total credits expect(new Decimal(tb.totals.debit)).toEqual(new Decimal(tb.totals.credit)) }) 5.5 Expense Approval Double-Entry test('Expense approval creates correct DR Expense / CR Payable entry', async () => { const expense = await createExpense({ amount: 5000, taxRate: 17 }) // 850 PDV await approveExpense(expense.id) const transactions = await getTransactionsForExpense(expense.id) expect(transactions).toHaveLength(1) const tx = transactions[0] // Verify debit is an expense account expect(tx.debitAccountCode).toMatch(/^5/) // Verify credit is payable expect(tx.creditAccountCode).toMatch(/^22/) // Amount matches expense amount expect(tx.amount).toBe('5000.0000') }) 6. Regulatory Compliance Tests 6.1 Serbia (RS) describe('Serbia regulatory compliance', () => { it('PDV 20% standard rate applied to default supplies', async () => { const result = calculateSerbianPDV('10000', 'standard') expect(result).toBe('2000.00') }) it('PDV 10% reduced rate applied to food/medicine', async () => { const result = calculateSerbianPDV('10000', 'reduced') expect(result).toBe('1000.00') }) it('Business below 8M RSD threshold does not require VAT registration', () => { expect(requiresVATRegistration('7999999')).toBe(false) }) it('Business at 8M RSD threshold requires VAT registration', () => { expect(requiresVATRegistration('8000000')).toBe(true) }) it('Business below 6M RSD qualifies for pausal regime', () => { expect(qualifiesForPausalRegime('5999999')).toBe(true) }) it('CIT calculated at flat 15%', () => { const cit = calculateSerbianCIT('100000') expect(cit).toBe('15000.00') }) it('Invoice number follows Serbian format requirements', () => { // INV-YYYY-NNN format with sequential numbering expect(invoiceNumber).toMatch(/^INV-\d{4}-\d{3,}$/) }) it('VAT report groups output and input VAT separately', async () => { const report = await getVATReport(orgId, { from: '2026-01-01', to: '2026-01-31' }) expect(report).toHaveProperty('outputVAT') expect(report).toHaveProperty('inputVAT') expect(report).toHaveProperty('netVAT') expect(new Decimal(report.netVAT)).toEqual( new Decimal(report.outputVAT.total).sub(new Decimal(report.inputVAT.total)), ) }) }) 6.2 Bosnia & Herzegovina (BA) describe('BiH regulatory compliance', () => { it('Single PDV rate of 17% applied uniformly', () => { const result = calculateBosnianPDV('10000') expect(result).toBe('1700.00') }) it('BiH has no reduced VAT rate (only standard and zero)', () => { const rates = Object.keys(bosnianVATRates) expect(rates).toEqual(['standard', 'zero']) }) it('VAT registration required at 100,000 BAM', () => { expect(requiresVATRegistration('99999')).toBe(false) expect(requiresVATRegistration('100000')).toBe(true) }) it('FBiH CIT at 10%', () => { expect(calculateCITFBiH('100000')).toBe('10000.00') }) it('RS CIT at 10%', () => { expect(calculateCITRS('100000')).toBe('10000.00') }) it('Dividend WHT: FBiH 5%, RS 10%', () => { expect(calculateDividendWHT('100000', 'fbih')).toBe('5000.00') expect(calculateDividendWHT('100000', 'rs')).toBe('10000.00') }) }) 6.3 Croatia (HR) describe('Croatia regulatory compliance', () => { it('Standard PDV rate is 25%', () => { const result = calculateCroatianPDV('10000', 'standard') expect(result).toBe('2500.00') }) it('Reduced PDV rate is 13% (food, accommodation)', () => { const result = calculateCroatianPDV('10000', 'reduced') expect(result).toBe('1300.00') }) it('Super-reduced PDV rate is 5% (books, medicines)', () => { const result = calculateCroatianPDV('10000', 'superReduced') expect(result).toBe('500.00') }) it('CIT 10% for small business (revenue < 1M EUR)', () => { const cit = calculateCroatianCIT('50000', '900000') expect(cit).toBe('5000.00') }) it('CIT 18% for large business (revenue >= 1M EUR)', () => { const cit = calculateCroatianCIT('50000', '1000000') expect(cit).toBe('9000.00') }) it('VAT registration threshold 60,000 EUR (EU 2025 aligned)', () => { expect(requiresVATRegistration('59999')).toBe(false) expect(requiresVATRegistration('60000')).toBe(true) }) }) 6.4 Audit Trail Compliance describe('Immutable audit trail', () => { it('LoggedAction created on invoice create', async () => { await createInvoice(orgId, ...); const logs = await prisma.loggedAction.findMany({ where: { tableName: 'invoices', action: 'INSERT' } }); expect(logs).toHaveLength(1); expect(logs[0].rowData).toBeTruthy(); }); it('LoggedAction created on invoice status change', async () => { await sendInvoice(invoiceId); const logs = await prisma.loggedAction.findMany({ where: { tableName: 'invoices', action: 'UPDATE' } }); expect(logs.length).toBeGreaterThan(0); expect(logs[0].changedFields).toHaveProperty('status'); }); it('LoggedAction cannot be deleted', async () => { // Attempt to delete a log entry — should fail (policy enforced at app or DB level) await expect(prisma.loggedAction.delete({ where: { eventId: logs[0].eventId } })) .rejects.toThrow(); }); it('locked transaction cannot be updated', async () => { await prisma.transaction.update({ where: { id: txId }, data: { locked: true } }); // Attempt to change amount of locked transaction via API const response = await request(app) .put(`/api/v1/transactions/${txId}`) .set('Authorization', `Bearer ${token}`) .send({ amount: 99999 }); expect(response.status).toBe(400); }); }); 6.5 Record Retention describe('Record retention requirements', () => { it('Deleted invoices remain in LoggedAction with full row data', async () => { const invoice = await createInvoice(orgId) const invoiceId = invoice.id await deleteInvoice(invoiceId) // Invoice deleted from invoices table const inv = await prisma.invoice.findUnique({ where: { id: invoiceId } }) expect(inv).toBeNull() // But audit log captures the full row const log = await prisma.loggedAction.findFirst({ where: { tableName: 'invoices', action: 'DELETE' }, }) expect(log.rowData).toBeTruthy() expect(JSON.parse(log.rowData).id).toBe(invoiceId) }) }) 7. Performance Benchmarks Tool: k6 (load testing), Lighthouse (frontend) 7.1 API Response Time Targets Endpoint Target (P95) Max Acceptable GET /api/v1/health < 10ms < 50ms POST /api/v1/auth/login < 300ms < 1s GET /api/v1/invoices (20 items) < 200ms < 500ms POST /api/v1/invoices < 500ms < 1s GET /api/v1/reports/profit-loss < 500ms < 2s GET /api/v1/reports/trial-balance < 1s < 3s GET /api/v1/reports/general-ledger < 2s < 5s POST /api/v1/bank-accounts/:id/import (100 rows) < 1s < 3s 7.2 Load Test Scenarios // k6 scenario: Normal business day load export const options = { scenarios: { normal_load: { executor: 'constant-vus', vus: 50, duration: '5m', }, spike: { executor: 'ramping-vus', startVUs: 0, stages: [ { duration: '30s', target: 200 }, { duration: '1m', target: 200 }, { duration: '30s', target: 0 }, ], }, }, thresholds: { 'http_req_duration{type:api}': ['p(95)<500'], http_req_failed: ['rate<0.01'], // < 1% error rate }, } 7.3 Database Performance Operation Target Invoice list query (org with 10K invoices) < 100ms Trial balance (org with 1K accounts, 100K transactions) < 2s Exchange rate lookup < 10ms (covered by index) Audit log insert < 5ms 7.4 Frontend Performance (Lighthouse) Metric Target First Contentful Paint (FCP) < 1.5s Largest Contentful Paint (LCP) < 2.5s Time to Interactive (TTI) < 3.5s Cumulative Layout Shift (CLS) < 0.1 Lighthouse Performance Score > 90 8. Security Tests 8.1 Authentication Security describe('Authentication security', () => { it('rejected with 401 for missing Authorization header') it('rejected with 401 for malformed Bearer token') it('rejected with 401 for expired access token') it('rejected with 401 for tampered JWT signature') it('rejected with 401 for wrong JWT_SECRET') it('access token cannot be used as refresh token') it('refresh token cannot be used as access token') it('tokens have correct issuer and audience claims') }) 8.2 RBAC Authorization describe('Role-based access control', () => { it('viewer cannot create invoices (403)') it('viewer cannot create expenses (403)') it('viewer cannot create manual transactions (403)') it('accountant cannot change user roles (403)') it('accountant cannot invite users (403)') it('admin cannot change owner role (403)') it('owner can change any role') it('user cannot change their own role') it('user cannot delete themselves') }) 8.3 SQL Injection describe('SQL injection prevention', () => { it('invoice search with SQL payload returns 400 (Zod validation)', async () => { const res = await request(app) .get("/api/v1/invoices?customerId='; DROP TABLE invoices; --") .set('Authorization', `Bearer ${token}`) expect(res.status).toBe(400) // Zod rejects invalid UUID }) it('Prisma parameterizes all queries (no raw SQL in services)') }) 8.4 Cross-Site Scripting (XSS) describe('XSS prevention', () => { it('contact name with script tag is stored as plain text', async () => { const name = '' const contact = await createContact({ name }) expect(contact.name).toBe(name) // stored as-is // API response should not execute as HTML (verified by Content-Type: application/json) }) it('Content-Security-Policy header blocks inline scripts', async () => { const res = await request(app).get('/api/v1/health') const csp = res.headers['content-security-policy'] expect(csp).toContain("script-src 'self'") expect(csp).not.toContain("'unsafe-eval'") }) }) 8.5 Rate Limiting describe('Rate limiting', () => { it('general API limit: 100 requests per 1 min per IP', async () => { // Make 101 requests from same IP const responses = await makeRequests(101, '/api/v1/health') const lastResponse = responses[100] expect(lastResponse.status).toBe(429) }) it('auth endpoints have stricter rate limit', async () => { // Make rapid login attempts — triggers auth rate limiter before general const responses = await makeLoginAttempts(20) expect(responses.some((r) => r.status === 429)).toBe(true) }) }) 8.6 Data Isolation / Multi-Tenant Security describe('Tenant isolation security', () => { it('cannot access another org invoice by ID (returns 404, not 403)') // Note: returning 404 instead of 403 prevents enumeration attacks it('cannot access another org transactions by reference ID') it('cannot access another org users via /api/v1/users') it('cannot access another org bank accounts') it('PATCH invoice from another org returns 404') it('DELETE invoice from another org returns 404') }) 8.7 CORS describe('CORS policy', () => { it('requests from bilko.io are allowed') it('requests from unknown origin are rejected with CORS error') it('OPTIONS preflight returns correct headers') it('credentials (cookies) allowed with CORS') }) 8.8 Security Headers describe('Security headers', () => { it('X-Frame-Options: deny (clickjacking protection)') it('X-Content-Type-Options: nosniff') it('Strict-Transport-Security: maxAge=31536000; includeSubDomains; preload') it('Content-Security-Policy present') it('X-Powered-By header removed (helmet default)') }) CI/CD Test Pipeline flowchart TD PUSH["git push / PR opened"] --> CI["GitHub Actions triggered"] CI --> J1["Job: unit-tests
ubuntu-latest
No DB required"] CI --> J2["Job: integration-tests
ubuntu-latest
postgres:15 service"] CI --> J3["Job: e2e-tests
ubuntu-latest
Full stack startup"] J1 --> U1["npm ci"] U1 --> U2["cd packages/core && npx vitest run"] U2 --> U3{Coverage >= 80%?} U3 -->|Yes| U_OK["PASS"] U3 -->|No| U_FAIL["FAIL — block merge"] J2 --> I1["npm ci"] I1 --> I2["npx prisma migrate deploy
(TEST_DATABASE_URL)"] I2 --> I3["npm run test:integration
(apps/api)"] I3 --> I4{All assertions pass?} I4 -->|Yes| I_OK["PASS"] I4 -->|No| I_FAIL["FAIL — block merge"] J3 --> E1["npx playwright install --with-deps"] E1 --> E2["npm run dev (staging seed)"] E2 --> E3["npm run test:e2e"] E3 --> E4{All flows pass?} E4 -->|Yes| E_OK["PASS"] E4 -->|No| E_FAIL["FAIL — screenshot + video saved"] U_OK --> MERGE{All jobs passed?} I_OK --> MERGE E_OK --> MERGE MERGE -->|Yes| DEPLOY["Allow merge to main"] MERGE -->|No| BLOCK["Block PR merge"] style PUSH fill:#6c757d,color:#fff style DEPLOY fill:#198754,color:#fff style BLOCK fill:#dc3545,color:#fff style U_FAIL fill:#dc3545,color:#fff style I_FAIL fill:#dc3545,color:#fff style E_FAIL fill:#dc3545,color:#fff 8.9 Accessibility Testing Bilko serves SMB users across the Balkans, including users with disabilities and users on assistive technologies. WCAG 2.1 AA compliance is the target for the Bilko web application. Tools Tool Purpose When Run axe-core (via @axe-core/playwright ) Automated WCAG 2.1 AA audit Every E2E test run; CI on PR Lighthouse Accessibility audit Aggregate accessibility score Weekly + pre-release Manual screen reader test (NVDA/VoiceOver) Verify keyboard navigation, ARIA Pre-launch; after major UI changes Automated Accessibility Tests (Playwright + axe-core) // e2e/tests/accessibility.spec.ts import { test, expect } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' test.describe('WCAG 2.1 AA Compliance', () => { test.beforeEach(async ({ page }) => { // Login await page.goto('/login') await page.fill('[name="email"]', 'demo@bilko.io') await page.fill('[name="password"]', 'Demo123!') await page.click('button[type="submit"]') await page.waitForURL('/dashboard') }) test('Dashboard has no WCAG 2.1 AA violations', async ({ page }) => { await page.goto('/dashboard') const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) .analyze() expect(accessibilityScanResults.violations).toEqual([]) }) test('Invoice create form has no WCAG 2.1 AA violations', async ({ page }) => { await page.goto('/invoices/new') const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa']) .analyze() expect(accessibilityScanResults.violations).toEqual([]) }) test('Invoices list page has no WCAG 2.1 AA violations', async ({ page }) => { await page.goto('/invoices') const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa']) .analyze() expect(accessibilityScanResults.violations).toEqual([]) }) test('Reports page has no WCAG 2.1 AA violations', async ({ page }) => { await page.goto('/reports') const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa']) .analyze() expect(accessibilityScanResults.violations).toEqual([]) }) test('Settings page has no WCAG 2.1 AA violations', async ({ page }) => { await page.goto('/settings') const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa']) .analyze() expect(accessibilityScanResults.violations).toEqual([]) }) }) Key WCAG 2.1 AA Requirements for Bilko Requirement Criterion Bilko Implementation Color contrast 1.4.3 — minimum 4.5:1 for normal text, 3:1 for large text Tailwind colors verified for contrast ratios Keyboard navigation 2.1.1 — all functions accessible by keyboard Focus management in modals, invoice wizard steps Focus visible 2.4.7 — visible focus indicators Tailwind focus:ring classes on all interactive elements Labels on inputs 1.3.1 — form inputs have programmatic labels shadcn/ui