fix: enable strict mode, honest HTTP boundary, and add routing

Three fixes to the parts of the template that contradicted its own
"illegal states unrepresentable" claim:

- tsconfig: turn on strict + strictTemplates (measured zero fallout —
  the codebase already typechecked cleanly, it just wasn't enforced).
- RemoteData<T> drops its unused error type parameter (Resource.error
  is always Error) and Failure now carries a real Error. Pages read the
  union with @let instead of re-deriving from the resource, which
  deletes the non-null assertion strictNullChecks would otherwise flag.
- users.adapter.ts never checked response.ok, so an HTTP error resolved
  as a garbage Success and crashed instead of reaching RemoteData's
  Failure branch. New shared/infrastructure/http.ts adds the status
  check plus hand-written parse guards and abortSignal forwarding; the
  six fetch stubs across the test suite (which encoded the missing
  check) and adapter spec now cover the Failure and Empty paths.

Also adds real routing (@angular/router was a dependency with zero
imports and a fake "Back" button): /users and /users/:id are now
deep-linkable via withComponentInputBinding(), tested with
RouterTestingHarness driving real navigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-01 10:29:47 +02:00
co-authored by Claude Sonnet 5
parent 6ad5b65f68
commit c895929f58
22 changed files with 301 additions and 90 deletions
+6 -1
View File
@@ -1,5 +1,10 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners()], providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
],
}; };
+1 -1
View File
@@ -1 +1 @@
<app-users-page /> <router-outlet />
+9
View File
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'users',
loadChildren: () => import('@users/ui/users.routes').then((m) => m.usersRoutes),
},
{ path: '', redirectTo: 'users', pathMatch: 'full' },
];
+21 -10
View File
@@ -1,14 +1,25 @@
import { describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { App } from './app'; import { provideRouter, withComponentInputBinding } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from './app.routes';
describe('App', () => { afterEach(() => {
it('renders the users page heading', async () => { vi.unstubAllGlobals();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve([]) })); });
await TestBed.configureTestingModule({ imports: [App] }).compileComponents();
const fixture = TestBed.createComponent(App); describe('App routes', () => {
fixture.detectChanges(); it('redirects to /users and renders the page heading', async () => {
expect(fixture.nativeElement.querySelector('h1')?.textContent).toBe('Users'); vi.stubGlobal(
vi.unstubAllGlobals(); 'fetch',
vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve([]) }),
);
TestBed.configureTestingModule({
providers: [provideRouter(routes, withComponentInputBinding())],
});
const harness = await RouterTestingHarness.create('/');
await harness.fixture.whenStable();
harness.detectChanges();
expect(harness.routeNativeElement?.querySelector('h1')?.textContent).toBe('Users');
}); });
}); });
+2 -3
View File
@@ -1,10 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { UsersPage } from '@users/ui/users.page'; import { RouterOutlet } from '@angular/router';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [UsersPage], imports: [RouterOutlet],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css',
}) })
export class App {} export class App {}
+5 -4
View File
@@ -4,18 +4,19 @@ import type { Resource } from '@angular/core';
* Four mutually exclusive async states — the data lives ON the state, so e.g. * Four mutually exclusive async states — the data lives ON the state, so e.g.
* "success with no value" or "error with a stale value" is unrepresentable. * "success with no value" or "error with a stale value" is unrepresentable.
*/ */
export type RemoteData<E, T> = export type RemoteData<T> =
| { tag: 'Loading' } | { tag: 'Loading' }
| { tag: 'Empty' } | { tag: 'Empty' }
| { tag: 'Failure'; error: E } | { tag: 'Failure'; error: Error }
| { tag: 'Success'; value: T }; | { tag: 'Success'; value: T };
/** Project Angular's own resource() into a RemoteData value. */ /** Project Angular's own resource() into a RemoteData value. */
export function fromResource<T>( export function fromResource<T>(
r: Resource<T>, r: Resource<T>,
isEmpty: (v: T) => boolean = () => false, isEmpty: (v: T) => boolean = () => false,
): RemoteData<unknown, T> { ): RemoteData<T> {
if (r.status() === 'error') return { tag: 'Failure', error: r.error() }; const error = r.error();
if (error) return { tag: 'Failure', error };
if (r.hasValue()) { if (r.hasValue()) {
const v = r.value(); const v = r.value();
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v }; return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
+34
View File
@@ -0,0 +1,34 @@
export const API_BASE_URL = 'https://jsonplaceholder.typicode.com';
/** Server answered, but not with a 2xx. */
export class HttpError extends Error {
constructor(
readonly status: number,
path: string,
) {
super(`HTTP ${status} for ${path}`);
this.name = 'HttpError';
}
}
/** Server answered 2xx, but the body isn't the shape we asked for. */
export class ParseError extends Error {
constructor(what: string) {
super(`Malformed response: expected ${what}`);
this.name = 'ParseError';
}
}
/**
* GET + status check + parse, in that order. Throws HttpError or ParseError —
* resource() turns a thrown error into RemoteData's Failure branch.
*/
export async function getJson<T>(
path: string,
parse: (value: unknown) => T,
abortSignal?: AbortSignal,
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { signal: abortSignal });
if (!response.ok) throw new HttpError(response.status, path);
return parse(await response.json());
}
@@ -13,11 +13,11 @@ import type { RemoteData } from '@shared/application/remote-data';
`, `,
}) })
class HostComponent { class HostComponent {
data: RemoteData<unknown, string> = { tag: 'Loading' }; data: RemoteData<string> = { tag: 'Loading' };
retried = false; retried = false;
} }
function render(data: RemoteData<unknown, string>) { function render(data: RemoteData<string>) {
const fixture = TestBed.createComponent(HostComponent); const fixture = TestBed.createComponent(HostComponent);
fixture.componentInstance.data = data; fixture.componentInstance.data = data;
fixture.detectChanges(); fixture.detectChanges();
@@ -24,7 +24,7 @@ import type { RemoteData } from '@shared/application/remote-data';
`, `,
}) })
export class AsyncComponent<T> { export class AsyncComponent<T> {
data = input.required<RemoteData<unknown, T>>(); data = input.required<RemoteData<T>>();
emptyText = input('No data.'); emptyText = input('No data.');
errorText = input('Something went wrong.'); errorText = input('Something went wrong.');
retryText = input('Retry'); retryText = input('Retry');
@@ -2,4 +2,7 @@ import { resource } from '@angular/core';
import { fetchUserById } from '@users/infrastructure/users.adapter'; import { fetchUserById } from '@users/infrastructure/users.adapter';
export const userDetailResource = (userId: () => number) => export const userDetailResource = (userId: () => number) =>
resource({ params: userId, loader: ({ params }) => fetchUserById(params) }); resource({
params: userId,
loader: ({ params, abortSignal }) => fetchUserById(params, abortSignal),
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { resource } from '@angular/core'; import { resource } from '@angular/core';
import { fetchUsers } from '@users/infrastructure/users.adapter'; import { fetchUsers } from '@users/infrastructure/users.adapter';
export const usersResource = () => resource({ loader: fetchUsers }); export const usersResource = () =>
resource({ loader: ({ abortSignal }) => fetchUsers(abortSignal) });
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpError, ParseError } from '@shared/infrastructure/http';
import { fetchUserById, fetchUsers, parseUser, parseUserDetail, parseUsers } from './users.adapter';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('fetchUsers', () => {
it('resolves with the parsed list on a 200 response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([{ id: 1, name: 'Ada' }]),
}),
);
await expect(fetchUsers()).resolves.toEqual([{ id: 1, name: 'Ada' }]);
});
it('rejects with HttpError when the server answers with a non-2xx status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
await expect(fetchUsers()).rejects.toBeInstanceOf(HttpError);
});
});
describe('fetchUserById', () => {
it('resolves with the parsed user on a 200 response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
}),
);
await expect(fetchUserById(1)).resolves.toEqual({
id: 1,
name: 'Ada',
email: 'ada@example.com',
});
});
it('rejects with HttpError when the server answers with a non-2xx status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
await expect(fetchUserById(1)).rejects.toBeInstanceOf(HttpError);
});
});
describe('parseUsers', () => {
it('throws ParseError when the response is not an array', () => {
expect(() => parseUsers({})).toThrow(ParseError);
});
it('throws ParseError when a list item is missing a name', () => {
expect(() => parseUsers([{ id: 1 }])).toThrow(ParseError);
});
});
describe('parseUser', () => {
it('throws ParseError when id is not a number', () => {
expect(() => parseUser({ id: '1', name: 'Ada' })).toThrow(ParseError);
});
});
describe('parseUserDetail', () => {
it('throws ParseError when email is missing', () => {
expect(() => parseUserDetail({ id: 1, name: 'Ada' })).toThrow(ParseError);
});
});
+29 -4
View File
@@ -1,7 +1,32 @@
import { getJson, ParseError } from '@shared/infrastructure/http';
import type { User, UserDetail } from '@users/domain/user'; import type { User, UserDetail } from '@users/domain/user';
export const fetchUsers = (): Promise<User[]> => const isRecord = (value: unknown): value is Record<string, unknown> =>
fetch('https://jsonplaceholder.typicode.com/users').then((r) => r.json()); typeof value === 'object' && value !== null;
export const fetchUserById = (id: number): Promise<UserDetail> => export function parseUser(value: unknown): User {
fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((r) => r.json()); if (!isRecord(value)) throw new ParseError('a user');
const { id, name } = value;
if (typeof id !== 'number' || typeof name !== 'string') throw new ParseError('a user');
return { id, name };
}
export function parseUserDetail(value: unknown): UserDetail {
if (!isRecord(value)) throw new ParseError('a user detail');
const { id, name, email } = value;
if (typeof id !== 'number' || typeof name !== 'string' || typeof email !== 'string') {
throw new ParseError('a user detail');
}
return { id, name, email };
}
export function parseUsers(value: unknown): User[] {
if (!Array.isArray(value)) throw new ParseError('a user list');
return value.map(parseUser);
}
export const fetchUsers = (abortSignal?: AbortSignal): Promise<User[]> =>
getJson('/users', parseUsers, abortSignal);
export const fetchUserById = (id: number, abortSignal?: AbortSignal): Promise<UserDetail> =>
getJson(`/users/${id}`, parseUserDetail, abortSignal);
@@ -11,6 +11,8 @@ describe('UserDetailComponent', () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }), json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
}), }),
); );
@@ -27,6 +29,8 @@ describe('UserDetailComponent', () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'a@x.com' }), json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'a@x.com' }),
}), }),
); );
@@ -7,9 +7,10 @@ import { userDetailResource } from '@users/application/user-detail.resource';
selector: 'app-user-detail', selector: 'app-user-detail',
imports: [AsyncComponent], imports: [AsyncComponent],
template: ` template: `
<app-async [data]="data()" (retry)="detailResource.reload()"> @let detail = data();
@if (detailResource.value(); as u) { <app-async [data]="detail" (retry)="detailResource.reload()">
<p>{{ u.name }} — {{ u.email }}</p> @if (detail.tag === 'Success' && detail.value) {
<p>{{ detail.value.name }} — {{ detail.value.email }}</p>
} }
</app-async> </app-async>
<button type="button" (click)="close.emit()">Back</button> <button type="button" (click)="close.emit()">Back</button>
+70 -30
View File
@@ -1,21 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { UsersPage } from './users.page'; import { provideRouter, withComponentInputBinding } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { usersRoutes } from './users.routes';
function stubFetch() { function stubFetch() {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn((url: string) => { vi.fn((url: string) => {
if (url.endsWith('/users')) { if (url.endsWith('/users')) {
return Promise.resolve({ json: () => Promise.resolve([{ id: 1, name: 'Ada' }]) }); return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve([{ id: 1, name: 'Ada' }]),
});
} }
return Promise.resolve({ return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }), json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
}); });
}), }),
); );
} }
function stubFetchFailure() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 500, json: () => Promise.resolve({}) }),
);
}
function stubFetchEmpty() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve([]) }),
);
}
async function renderUsersAt(url: string) {
TestBed.configureTestingModule({
providers: [
provideRouter([{ path: 'users', children: usersRoutes }], withComponentInputBinding()),
],
});
const harness = await RouterTestingHarness.create(url);
await harness.fixture.whenStable();
harness.detectChanges();
return harness;
}
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -23,44 +57,50 @@ afterEach(() => {
describe('UsersPage', () => { describe('UsersPage', () => {
it('renders the fetched users as a list', async () => { it('renders the fetched users as a list', async () => {
stubFetch(); stubFetch();
await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents(); const harness = await renderUsersAt('/users');
const fixture = TestBed.createComponent(UsersPage); expect(harness.routeNativeElement?.textContent).toContain('Ada');
fixture.detectChanges(); });
await fixture.whenStable();
fixture.detectChanges(); it('shows the failure state when the request fails', async () => {
expect(fixture.nativeElement.textContent).toContain('Ada'); stubFetchFailure();
const harness = await renderUsersAt('/users');
expect(harness.routeNativeElement?.querySelector('[role="alert"]')).toBeTruthy();
});
it('shows the empty state when there are no users', async () => {
stubFetchEmpty();
const harness = await renderUsersAt('/users');
expect(harness.routeNativeElement?.textContent).toContain('No data.');
}); });
it("shows a user's details after clicking their name", async () => { it("shows a user's details after clicking their name", async () => {
stubFetch(); stubFetch();
await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents(); const harness = await renderUsersAt('/users');
const fixture = TestBed.createComponent(UsersPage); harness.routeNativeElement!.querySelector('button')!.click();
fixture.detectChanges(); await harness.fixture.whenStable();
await fixture.whenStable(); harness.detectChanges();
fixture.detectChanges(); expect(harness.routeNativeElement?.textContent).toContain('ada@example.com');
fixture.nativeElement.querySelector('button').click();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('ada@example.com');
}); });
it('returns to the list when the detail view is closed', async () => { it('returns to the list when the detail view is closed', async () => {
stubFetch(); stubFetch();
await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents(); const harness = await renderUsersAt('/users');
const fixture = TestBed.createComponent(UsersPage); harness.routeNativeElement!.querySelector('button')!.click();
fixture.detectChanges(); await harness.fixture.whenStable();
await fixture.whenStable(); harness.detectChanges();
fixture.detectChanges(); const backButton = Array.from(harness.routeNativeElement!.querySelectorAll('button')).find(
fixture.nativeElement.querySelector('button').click();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const backButton = Array.from(fixture.nativeElement.querySelectorAll('button')).find(
(b) => (b as HTMLElement).textContent === 'Back', (b) => (b as HTMLElement).textContent === 'Back',
) as HTMLElement; ) as HTMLElement;
backButton.click(); backButton.click();
fixture.detectChanges(); await harness.fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-user-detail')).toBeFalsy(); harness.detectChanges();
expect(harness.routeNativeElement?.querySelector('app-user-detail')).toBeFalsy();
expect(harness.routeNativeElement?.textContent).toContain('Ada');
});
it('deep-links directly to a user detail view', async () => {
stubFetch();
const harness = await renderUsersAt('/users/1');
expect(harness.routeNativeElement?.textContent).toContain('ada@example.com');
}); });
}); });
+13 -6
View File
@@ -1,4 +1,5 @@
import { Component, computed, signal } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/ui/templates/page-shell.component'; import { PageShellComponent } from '@shared/ui/templates/page-shell.component';
import { AsyncComponent } from '@shared/ui/molecules/async.component'; import { AsyncComponent } from '@shared/ui/molecules/async.component';
import { fromResource } from '@shared/application/remote-data'; import { fromResource } from '@shared/application/remote-data';
@@ -13,11 +14,12 @@ import { isEmptyUserList } from '@users/domain/user';
template: ` template: `
<app-page-shell heading="Users"> <app-page-shell heading="Users">
@if (selectedUserId(); as id) { @if (selectedUserId(); as id) {
<app-user-detail [userId]="id" (close)="selectedUserId.set(null)" /> <app-user-detail [userId]="id" (close)="router.navigate(['/users'])" />
} @else { } @else {
<app-async [data]="listData()" (retry)="usersResource.reload()"> @let list = listData();
@if (usersResource.hasValue()) { <app-async [data]="list" (retry)="usersResource.reload()">
<app-user-list [users]="usersResource.value()!" (select)="selectedUserId.set($event)" /> @if (list.tag === 'Success' && list.value) {
<app-user-list [users]="list.value" (select)="router.navigate(['/users', $event])" />
} }
</app-async> </app-async>
} }
@@ -25,7 +27,12 @@ import { isEmptyUserList } from '@users/domain/user';
`, `,
}) })
export class UsersPage { export class UsersPage {
protected selectedUserId = signal<number | null>(null); protected router = inject(Router);
userId = input<string>();
protected selectedUserId = computed(() => {
const id = this.userId();
return id ? Number(id) : null;
});
protected usersResource = usersResource(); protected usersResource = usersResource();
protected listData = computed(() => fromResource(this.usersResource, isEmptyUserList)); protected listData = computed(() => fromResource(this.usersResource, isEmptyUserList));
} }
+7
View File
@@ -0,0 +1,7 @@
import { Routes } from '@angular/router';
import { UsersPage } from './users.page';
export const usersRoutes: Routes = [
{ path: '', component: UsersPage },
{ path: ':userId', component: UsersPage },
];
+10 -10
View File
@@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<title>NgSignalsTemplate</title> <title>NgSignalsTemplate</title>
<base href="/"> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico" />
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>
+2 -6
View File
@@ -5,10 +5,6 @@
"compilerOptions": { "compilerOptions": {
"types": [] "types": []
}, },
"include": [ "include": ["src/**/*.ts"],
"src/**/*.ts" "exclude": ["src/**/*.spec.ts"]
],
"exclude": [
"src/**/*.spec.ts"
]
} }
+2
View File
@@ -3,6 +3,7 @@
{ {
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"strict": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true, "noImplicitReturns": true,
@@ -21,6 +22,7 @@
} }
}, },
"angularCompilerOptions": { "angularCompilerOptions": {
"strictTemplates": true,
"enableI18nLegacyMessageIdFormat": false, "enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true, "strictInjectionParameters": true,
"strictInputAccessModifiers": true "strictInputAccessModifiers": true
+2 -7
View File
@@ -3,12 +3,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"types": [ "types": ["vitest/globals"]
"vitest/globals"
]
}, },
"include": [ "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
} }