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
| Layer | Location | Run | Boots |
|---|---|---|---|
| Unit | tests/Unit/** | php artisan test --testsuite=Unit | No app, no DB (mostly) |
| Feature | tests/Feature/** | php artisan test --testsuite=Feature | Full app + RefreshDatabase |
What's covered today (batch 23)
| Test file | Pins |
|---|---|
tests/Unit/DTO/PolicyDTOTest.php | fromArray defaults, action wildcards, resource scoping, conditions |
tests/Unit/Services/PolicyEvaluatorTest.php | Deny-overrides combining algorithm |
tests/Unit/Policies/TaskPolicyTest.php | BasePolicy request-context wiring (auth_user + user_policies) |
tests/Unit/AuditActorTest.php | Impersonator preservation in audit properties |
tests/Unit/Middleware/CheckPermissionScopeGateTest.php | Service-account scope-gate |
tests/Unit/Services/IdentityServiceTransientFailureTest.php | 4xx vs 5xx differentiation, transient null cache |
tests/Unit/ControllerSplitContractTest.php | All 12 F3 controller extracts exist with required methods |
tests/Unit/ConcernsContractTest.php | EnsuresProjectMembership, EnsuresFolderAccess trait shape |
tests/Unit/TaskHistoryTest.php | TaskHistory::ALLOWED_ACTIONS lock-in |
tests/Unit/LeaveRequestTransitionTest.php | All legal + illegal state transitions |
tests/Unit/CommunicationLogMorphTest.php | ALLOWED_RELATED_TYPES morph allow-list |
tests/Unit/ProjectMember{Roles,SoftDelete}Test.php | Role enum + restore-on-readd |
tests/Unit/ProjectModelTest.php | Model invariants |
tests/Unit/DocumentFolderDepthTest.php | MAX_DEPTH=8 enforcement |
tests/Unit/{ProjectStats,ChatReaction,DocumentFolderMember}ControllerContractTest.php | F3 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 file | Pins |
|---|---|
src/composables/useApiError.test.ts | notifyError/notifySuccess, fallback to err.message or i18n key, edge cases |
src/composables/useDeleteDialog.test.ts | Open/close/confirm lifecycle, multiple targets |
src/composables/usePaginationDeepLink.test.ts | URL ↔ state sync, debounce, history mode |
src/composables/useTypeAhead.test.ts | Debounced fetch + AbortController cancellation |
src/modules/crm/stores/contact.test.ts | Contact store CRUD + tag sync + loading flag |
src/modules/crm/stores/company.test.ts | Company store CRUD + nested-address splicing |
src/modules/tasks/stores/task.test.ts | Task 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.