Skip to content

Commit 6f8edf3

Browse files
authored
chore(test): container testing, bound jest workers, null test coverage (#1069)
1 parent bd8d6c6 commit 6f8edf3

8 files changed

Lines changed: 130 additions & 9 deletions

File tree

.github/copilot-instructions.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# GitHub Copilot & AI Coding Instructions for `nr-fom`
2+
3+
## Project Architecture & Monorepo Overview
4+
`nr-fom` (Forest Operations Map) is an npm workspaces monorepo:
5+
* **`admin/`**: Angular 22 admin portal for Forest Clients and Ministry staff to create, edit, submit, and review FOMs.
6+
* **`public/`**: Angular 22 public-facing portal for citizens to discover FOMs and submit comments.
7+
* **`api/`**: NestJS 11 backend service utilizing TypeORM 1.0, PostgreSQL 18 with PostGIS spatial extensions, and Pino logging.
8+
* **`libs/client/typescript-ng/`**: Auto-generated Angular API client from OpenAPI (`@api-client`).
9+
* **`libs/utility/`**: Shared security, authentication, and common TypeScript helpers.
10+
11+
---
12+
13+
## 1. Containerized Execution Invariants (Podman / Docker)
14+
15+
### Never Run Heavy Workloads on Bare Metal
16+
* **Rule**: Never run test runners (`jest`, `npm run test-unit`), compilations (`ng build`, `nest build`), or migrations directly on the host machine.
17+
* **Execution**: Always dispatch commands inside Podman containers:
18+
```bash
19+
# Run unit tests inside containers
20+
podman compose exec admin npm run test:admin
21+
podman compose exec api npm run test:api
22+
podman compose exec public npm run test:public
23+
24+
# Database migrations
25+
podman compose exec api npm run db:migrate-main --workspace=api
26+
```
27+
* **Worker Concurrency**: Always bound test runner concurrency with `--maxWorkers=2` or `--runInBand` on all Jest scripts (`test-unit`, `test-unit-watch`, `test-e2e`, `test:cov`) to prevent host CPU and memory starvation.
28+
29+
---
30+
31+
## 2. Frontend Reactive & Null Safety Standards (Angular 22)
32+
33+
### Reactive Resource & Signal Patterns
34+
* **`rxResource` Resolution**: When consuming an Angular `rxResource`, check `resource.hasValue()` rather than evaluating truthiness (`if (!resource.value())`).
35+
* A resolved `null` payload (e.g., when a forest client has no historical public notice) is a **resolved valid state**, not an uncompleted loading state.
36+
* **Signal Un-tracking**: Inside `effect()` blocks, wrap downstream initialization calls (such as `buildForm()`) in `untracked()` if only the primary resource signal should trigger re-computation.
37+
38+
### Form Building & Null Safety
39+
* **Form Initialization**: Always guard against `null` responses when constructing `@rxweb/reactive-form-validators` models:
40+
```typescript
41+
const formModel = new PublicNoticeForm(this.response ?? undefined);
42+
this.formGroup = this.formBuilder.formGroup(formModel) as IFormGroup<PublicNoticeForm>;
43+
```
44+
* **Prohibit Unchecked Type Casting**: Avoid blindly casting to `as Partial<T>` or `as any` to silence TypeScript compiler diagnostics. Check and guard property existence explicitly.
45+
46+
### Authorization & State Gating in UI
47+
* Always verify project workflow state (`project.workflowState.code === WorkflowStateEnum.INITIAL`) and client permissions (`user.isForestClient && user.isAuthorizedForClientId(...)`) before exposing destructive actions (delete, submit).
48+
* Distinguish between `isNewForm` (project has no associated record) and `editMode` (route state).
49+
50+
---
51+
52+
## 3. Backend API & TypeORM Standards (NestJS 11)
53+
54+
### OpenAPI & DTO Contracts
55+
* Every nullable or optional field in a DTO must be explicitly annotated with `@ApiPropertyOptional()` so that auto-generated Angular clients accurately reflect the nullable contract.
56+
* Avoid returning untyped object literals; map entities directly to declared response DTOs.
57+
58+
### Multi-Tenancy & Client Scoping
59+
* All mutating endpoints must validate the caller's JWT claims against the target entity's `forestClient.id` using `user.isAuthorizedForClientId(clientId)`.
60+
* Ministry users (`user.isMinistry`) have cross-client read and administrative review capabilities.
61+
62+
---
63+
64+
## 4. Test Suite Requirements
65+
66+
* **Unit Tests**: Test suites must cover the full lifecycle matrix:
67+
1. Unresolved / loading state.
68+
2. Resolved `null` / empty state (zero-state scenarios).
69+
3. Resolved valid entity state.
70+
4. Error states (403, 404, 500).
71+
* **Mock Realism**: Do not mock services to return only happy-path truthy data. Write explicit regression tests for empty and boundary return values.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ tmp*.sh
108108
.kilo/
109109
.tmp/
110110
.github/**-instructions.md
111+
!.github/copilot-instructions.md
111112
**.instructions.md
112113
.github/agents/**
113114
.github/skills/**

admin/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"scripts": {
66
"start:admin": "ng serve --configuration development --host=0.0.0.0",
77
"build:admin": "ng build --configuration production",
8-
"test-unit": "jest --coverage",
9-
"test-unit-watch": "jest --watch=true"
8+
"test-unit": "jest --coverage --maxWorkers=2",
9+
"test-unit-watch": "jest --watch=true --maxWorkers=2"
1010
},
1111
"private": true,
1212
"engines": {

admin/src/app/foms/fom-detail/fom-detail.component.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,5 +266,37 @@ describe('FomDetailComponent', () => {
266266
await createComponent(project({ workflowState: { code: 'INITIAL' } }));
267267
expect(component.canAccessComments()).toBe(false);
268268
});
269+
270+
it('allows editing public notice only when FOM is INITIAL and user is authorized', async () => {
271+
await createComponent(project({ workflowState: { code: 'INITIAL' } }));
272+
expect(component.canEditPublicNotice()).toBe(true);
273+
274+
component.user.isAuthorizedForClientId = () => false;
275+
expect(component.canEditPublicNotice()).toBe(false);
276+
277+
component.user.isAuthorizedForClientId = () => true;
278+
await createComponent(project({ workflowState: { code: 'PUBLISHED' } }));
279+
expect(component.canEditPublicNotice()).toBe(false);
280+
});
281+
282+
it('allows viewing public notice for authorized client or ministry user regardless of workflow state', async () => {
283+
await createComponent(project({ workflowState: { code: 'INITIAL', publicNoticeId: undefined } }));
284+
expect(component.canViewPublicNotice()).toBe(true);
285+
286+
await createComponent(project({ workflowState: { code: 'PUBLISHED', publicNoticeId: 55 } }));
287+
expect(component.canViewPublicNotice()).toBe(true);
288+
289+
component.user.isAuthorizedForClientId = () => false;
290+
component.user.isMinistry = false;
291+
expect(component.canViewPublicNotice()).toBe(false);
292+
});
293+
});
294+
295+
describe('zero-state FOM without public notice', () => {
296+
it('renders project detail safely when publicNoticeId is undefined', async () => {
297+
await createComponent(project({ publicNoticeId: undefined, workflowState: { code: 'INITIAL' } }));
298+
expect(component.project().publicNoticeId).toBeUndefined();
299+
expect(fixture.nativeElement.textContent).toContain('Test FOM Holder');
300+
});
269301
});
270302
});

api/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
"db:migrate-test:revert": "npm run typeorm migration:revert -- -f ./src/migrations/ormconfig-migration-test.ts",
1717
"db:migrate-test:show": "npm run typeorm migration:show -- -f ./src/migrations/ormconfig-migration-test.ts",
1818
"perf-test:gen-data": "node src/perftest/generate-data.js",
19-
"test-unit": "set LOG_LEVEL=warn && jest --coverage --testPathIgnorePatterns='e2e'",
20-
"test-unit-watch": "set LOG_LEVEL=warn && jest --testPathIgnorePatterns='e2e' --watch=true",
21-
"test-e2e": "set LOG_LEVEL=warn && jest --testNamePattern='e2e'",
22-
"test:cov": "jest --coverage"
19+
"test-unit": "LOG_LEVEL=warn jest --coverage --maxWorkers=2 --testPathIgnorePatterns='e2e'",
20+
"test-unit-watch": "LOG_LEVEL=warn jest --testPathIgnorePatterns='e2e' --watch=true --maxWorkers=2",
21+
"test-e2e": "LOG_LEVEL=warn jest --testNamePattern='e2e' --maxWorkers=2",
22+
"test:cov": "LOG_LEVEL=warn jest --coverage --maxWorkers=2"
2323
},
2424
"private": true,
2525
"engines": {

api/src/app/modules/project/project.service.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,20 @@ describe('ProjectService', () => {
206206
expect(districtSpy).toHaveBeenCalledWith(entity.districtId);
207207
expect(postdateOnOrBeforeCommentingOpenDateSpy).not.toHaveBeenCalled();
208208
});
209+
210+
it('with empty array publicNotices pass', async () => {
211+
entity.commentingOpenDate = dayjs.tz(DateTimeUtil.nowBC().add(1, 'day'), DateTimeUtil.TIMEZONE_VANCOUVER).format(DateTimeUtil.DATE_FORMAT);
212+
entity.commentingClosedDate = dayjs.tz(entity.commentingOpenDate, DateTimeUtil.TIMEZONE_VANCOUVER)
213+
.add(closeDateAfterOpeningDateDays, 'day')
214+
.format(DateTimeUtil.DATE_FORMAT);
215+
entity.publicNotices = []; // empty array public-notice.
216+
217+
await service.validateWorkflowTransitionRules(entity as Project, stateTransition, user);
218+
219+
expect(districtSpy).toHaveBeenCalled();
220+
expect(districtSpy).toHaveBeenCalledWith(entity.districtId);
221+
expect(postdateOnOrBeforeCommentingOpenDateSpy).not.toHaveBeenCalled();
222+
});
209223

210224
it('with public-notice and no post_date pass', async () => {
211225
entity.commentingOpenDate = dayjs.tz(DateTimeUtil.nowBC().add(1, 'day'), DateTimeUtil.TIMEZONE_VANCOUVER).format(DateTimeUtil.DATE_FORMAT);

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
"build:api": "npm run build:api --workspace=api",
1919
"start:public": "npm run start:public --workspace=public",
2020
"start:admin": "npm run start:admin --workspace=admin",
21-
"start:api": "npm run start:api --workspace=api"
21+
"start:api": "npm run start:api --workspace=api",
22+
"test:public": "npm run test-unit --workspace=public",
23+
"test:admin": "npm run test-unit --workspace=admin",
24+
"test:api": "npm run test-unit --workspace=api"
2225
},
2326
"overrides": {
2427
"ngx-bootstrap": {

public/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"scripts": {
66
"start:public": "ng serve --configuration development --host=0.0.0.0",
77
"build:public": "ng build --configuration production",
8-
"test-unit": "jest --coverage",
9-
"test-unit-watch": "jest --watch=true"
8+
"test-unit": "jest --coverage --maxWorkers=2",
9+
"test-unit-watch": "jest --watch=true --maxWorkers=2"
1010
},
1111
"private": true,
1212
"engines": {

0 commit comments

Comments
 (0)