Skip to main content

Testing the CRM service

The CRM repo ships with three test layers, each with a different purpose. When adding a new feature, the rule of thumb is:

  • A change to model invariants → unit test on the model.
  • A change to authorization → policy or evaluator unit test.
  • A change to a controller endpoint → feature test.
  • A change to a Pinia store / composable → frontend unit test.

Don't reach for a feature test when a unit test would do — feature tests boot a full Laravel app and a sqlite in-memory DB, so the slowest of the three.

Backend — tenant/tenant-backend-crm

LayerLocationRunBoots
Unittests/Unit/**php artisan test --testsuite=UnitNo app, no DB (mostly)
Featuretests/Feature/**php artisan test --testsuite=FeatureFull app + RefreshDatabase

What's covered today (batch 23)

Test filePins
tests/Unit/DTO/PolicyDTOTest.phpfromArray defaults, action wildcards, resource scoping, conditions
tests/Unit/Services/PolicyEvaluatorTest.phpDeny-overrides combining algorithm
tests/Unit/Policies/TaskPolicyTest.phpBasePolicy request-context wiring (auth_user + user_policies)
tests/Unit/AuditActorTest.phpImpersonator preservation in audit properties
tests/Unit/Middleware/CheckPermissionScopeGateTest.phpService-account scope-gate
tests/Unit/Services/IdentityServiceTransientFailureTest.php4xx vs 5xx differentiation, transient null cache
tests/Unit/ControllerSplitContractTest.phpAll 12 F3 controller extracts exist with required methods
tests/Unit/ConcernsContractTest.phpEnsuresProjectMembership, EnsuresFolderAccess trait shape
tests/Unit/TaskHistoryTest.phpTaskHistory::ALLOWED_ACTIONS lock-in
tests/Unit/LeaveRequestTransitionTest.phpAll legal + illegal state transitions
tests/Unit/CommunicationLogMorphTest.phpALLOWED_RELATED_TYPES morph allow-list
tests/Unit/ProjectMember{Roles,SoftDelete}Test.phpRole enum + restore-on-readd
tests/Unit/ProjectModelTest.phpModel invariants
tests/Unit/DocumentFolderDepthTest.phpMAX_DEPTH=8 enforcement
tests/Unit/{ProjectStats,ChatReaction,DocumentFolderMember}ControllerContractTest.phpF3 extract method shapes

Running

cd tenant/tenant-backend-crm

# Everything
php artisan test

# One file
php artisan test --filter=PolicyEvaluatorTest

# One method
php artisan test --filter=test_deny_beats_allow

# Only the fast ones
php artisan test --testsuite=Unit

Writing a new unit test

The repo's tests/TestCase already mocks IdentityService and pre-populates the policy cache, so authentication is free. To make the request principal look like a real authenticated user:

$this->fakeRequestContext(
authUser: ['id' => 'user-1', 'tenant_id' => 'tenant-1'],
policyActions: ['tenant.test.crm.tasks.*'],
);

(See TaskPolicyTest::fakeRequestContext() for the helper.) For controllers, prefer a contract test that asserts the class + method shape (tests/Unit/*ControllerContractTest.php) — full HTTP roundtrips belong in feature tests.

Frontend — tenant/tenant-frontend

Vitest with happy-dom, configured in vitest.config.ts. The shared setup file at tests/setup.ts stubs getI18n() (passthrough t) and gives every test a fresh Pinia instance via beforeEach(() => setActivePinia(createPinia())).

What's covered today (batch 23)

Test filePins
src/composables/useApiError.test.tsnotifyError/notifySuccess, fallback to err.message or i18n key, edge cases
src/composables/useDeleteDialog.test.tsOpen/close/confirm lifecycle, multiple targets
src/composables/usePaginationDeepLink.test.tsURL ↔ state sync, debounce, history mode
src/composables/useTypeAhead.test.tsDebounced fetch + AbortController cancellation
src/modules/crm/stores/contact.test.tsContact store CRUD + tag sync + loading flag
src/modules/crm/stores/company.test.tsCompany store CRUD + nested-address splicing
src/modules/tasks/stores/task.test.tsTask store CRUD + board + status/assign + archive

Running

cd tenant/tenant-frontend

# One-shot
npm run test

# Watch mode
npm run test:watch

# One file
npx vitest run src/modules/crm/stores/contact.test.ts

Writing a new store test

Pinia stores under test must mock the per-service axios client at the top of the file (so the defineStore callback resolves the mock):

const get = vi.fn()
const post = vi.fn()
const put = vi.fn()
const del = vi.fn()

vi.mock('@/api/client', () => ({
crmClient: {
get: (...args: any[]) => get(...args),
post: (...args: any[]) => post(...args),
put: (...args: any[]) => put(...args),
delete: (...args: any[]) => del(...args),
},
}))

beforeEach(() => {
get.mockReset()
post.mockReset()
put.mockReset()
del.mockReset()
})

Then drive the store and assert against the mock calls and the public store ref values. Spy on useNotificationStore().success / error when the store dispatches snackbars (via useApiError) — that's the one piece of behavior shared with the rest of the SPA, so it's worth pinning.

CI

Both layers run on every PR. The frontend job also runs:

npm run api:check   # SDK drift gate — generated client vs OpenAPI

A drift here means the backend's OpenAPI spec changed but the generated SDK wasn't regenerated. Fix: npm run api:sync && git add src/api/generated.