feat(fp): WP-19 — Playwright e2e smoke against the real FE+backend

Adds a happy-path spec (login → dashboard → registratie wizard, including
a real identity-document upload → real submit) and a degraded-path spec
(?scenario=error → <app-async> error slot → retry), both driving the real
app against the real .NET backend, plus a CI job that boots both.

Writing the retry spec surfaced a real bug: AsyncComponent's retry() only
reloads a [resource]-fed instance, so every real page (all [data]-fed via
a store's RemoteData) had a silently no-op retry button. Added a
retryClicked output and wired it on the dashboard's two async blocks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 10:13:40 +02:00
parent e272869f00
commit 26c2c5acd0
14 changed files with 754 additions and 498 deletions

View File

@@ -72,4 +72,12 @@ export class BigProfileStore {
rollbackHerregistratie() {
this.pending.set(false); // submission failed — undo the optimistic flag
}
// Retry hooks for [data]-fed <app-async> instances (they don't own the resource).
reloadProfile() {
this.viewRes.reload();
}
reloadAantekeningen() {
this.aantekeningenRes.reload();
}
}

View File

@@ -86,7 +86,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
>
}
<app-async [data]="store.profile()">
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
<ng-template appAsyncLoaded>
@if (profile(); as p) {
@let tasks = tasksFor(p.registration);
@@ -153,7 +153,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
>Specialismen en aantekeningen</app-heading
>
<div class="app-section">
<app-async [data]="store.aantekeningen()">
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
<ng-template appAsyncLoaded>
@if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />

View File

@@ -1,4 +1,12 @@
import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';
import {
Component,
Directive,
TemplateRef,
computed,
contentChild,
input,
output,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import type { Resource } from '@angular/core';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
@@ -130,11 +138,16 @@ export class AsyncComponent<T> {
}),
);
// [resource]-fed callers get reload() for free. [data]-fed callers (a store's
// combined RemoteData — the component doesn't own that resource) must reload
// it themselves; retryClicked is how they find out a retry was requested.
retryClicked = output<void>();
retry = () => {
const r = this.resource();
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
(r as { reload: () => void }).reload();
}
this.retryClicked.emit();
};
}