diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.html b/portal-frontend/src/app/case-detail/case-detail/case-detail.html index 4add799..b0eaec3 100644 --- a/portal-frontend/src/app/case-detail/case-detail/case-detail.html +++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.html @@ -73,7 +73,15 @@

Acties

- + @if (banner(); as message) { + + } +
} @else {

Laden...

diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.spec.ts b/portal-frontend/src/app/case-detail/case-detail/case-detail.spec.ts index 659a26e..162ea27 100644 --- a/portal-frontend/src/app/case-detail/case-detail/case-detail.spec.ts +++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.spec.ts @@ -1,7 +1,8 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap } from '@angular/router'; -import { of } from 'rxjs'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CaseDetailService } from '../case-detail.service'; import { CaseDetail } from '../case-detail.types'; @@ -9,7 +10,7 @@ import { CaseDetailPage } from './case-detail'; const legacyDetail: CaseDetail = { origin: 'Legacy', - legacyAanvraagId: 1001, + legacyAanvraagId: 1002, registrationApplicationId: null, surname: 'de Vries', initials: 'A.', @@ -26,54 +27,162 @@ const legacyDetail: CaseDetail = { processStatus: null, lastModifiedAt: null, actions: { - editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' }, - recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1001/beoordeling' }, - takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1001/take-ownership' }, + editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1002/details' }, + recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1002/beoordeling' }, + takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1002/take-ownership' }, }, seams: { aanvrager: 'legacy-backend', procestijdlijn: null }, }; -function configure(routeConfigPath: string, paramMap: Record, getLegacyDetail: unknown, getOwnedDetail: unknown) { - return TestBed.configureTestingModule({ - imports: [CaseDetailPage], - providers: [ - { provide: CaseDetailService, useValue: { getLegacyDetail, getOwnedDetail } }, - { - provide: ActivatedRoute, - useValue: { - paramMap: of(convertToParamMap(paramMap)), - snapshot: { routeConfig: { path: routeConfigPath } }, +const ownedDetail: CaseDetail = { + ...legacyDetail, + origin: 'Owned', + legacyAanvraagId: null, + registrationApplicationId: '00000000-0000-0000-0000-000000000002', + actions: { + editApplicantDetails: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/details' }, + recordAssessment: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' }, + releaseOwnership: { mode: 'transition', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership' }, + }, + seams: { aanvrager: 'owned', procestijdlijn: 'case-framework-timeline' }, +}; + +function configure(routeConfigPath: string, paramMap: Record, serviceOverrides: Record = {}) { + const service = { + getLegacyDetail: vi.fn().mockReturnValue(of(legacyDetail)), + getOwnedDetail: vi.fn().mockReturnValue(of(ownedDetail)), + updateDetails: vi.fn(), + recordAssessment: vi.fn(), + takeOwnership: vi.fn(), + releaseOwnership: vi.fn(), + ...serviceOverrides, + }; + return { + service, + ready: TestBed.configureTestingModule({ + imports: [CaseDetailPage], + providers: [ + provideRouter([]), + { provide: CaseDetailService, useValue: service }, + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap(paramMap)), + snapshot: { routeConfig: { path: routeConfigPath } }, + }, }, - }, - ], - }).compileComponents(); + ], + }).compileComponents(), + }; } describe('CaseDetailPage', () => { let fixture: ComponentFixture; it('given a legacy/:id route, fetches via getLegacyDetail and renders the case', async () => { - const getLegacyDetail = vi.fn().mockReturnValue(of(legacyDetail)); - const getOwnedDetail = vi.fn(); - await configure('legacy/:id', { id: '1001' }, getLegacyDetail, getOwnedDetail); + const { service, ready } = configure('legacy/:id', { id: '1002' }); + await ready; fixture = TestBed.createComponent(CaseDetailPage); fixture.detectChanges(); - expect(getLegacyDetail).toHaveBeenCalledWith('1001'); - expect(getOwnedDetail).not.toHaveBeenCalled(); + expect(service.getLegacyDetail).toHaveBeenCalledWith('1002'); + expect(service.getOwnedDetail).not.toHaveBeenCalled(); expect(fixture.nativeElement.textContent).toContain('de Vries'); }); it('given an owned/:id route, fetches via getOwnedDetail', async () => { - const getLegacyDetail = vi.fn(); - const getOwnedDetail = vi.fn().mockReturnValue(of({ ...legacyDetail, origin: 'Owned', legacyAanvraagId: null, registrationApplicationId: 'abc' })); - await configure('owned/:id', { id: 'abc' }, getLegacyDetail, getOwnedDetail); + const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }); + await ready; fixture = TestBed.createComponent(CaseDetailPage); fixture.detectChanges(); - expect(getOwnedDetail).toHaveBeenCalledWith('abc'); - expect(getLegacyDetail).not.toHaveBeenCalled(); + expect(service.getOwnedDetail).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000002'); + expect(service.getLegacyDetail).not.toHaveBeenCalled(); + }); + + describe('take ownership', () => { + afterEach(() => vi.restoreAllMocks()); + + it('does nothing when the user declines the confirm dialog', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + const { service, ready } = configure('legacy/:id', { id: '1002' }); + await ready; + fixture = TestBed.createComponent(CaseDetailPage); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click(); + + expect(service.takeOwnership).not.toHaveBeenCalled(); + }); + + it('on confirm, calls takeOwnership and navigates to the new owned detail', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + const { service, ready } = configure('legacy/:id', { id: '1002' }, { + takeOwnership: vi.fn().mockReturnValue(of({ registrationApplicationId: '00000000-0000-0000-0000-000000000002' })), + }); + await ready; + fixture = TestBed.createComponent(CaseDetailPage); + fixture.detectChanges(); + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate'); + + fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click(); + + expect(service.takeOwnership).toHaveBeenCalledWith('/api/worklist/legacy/1002/take-ownership'); + expect(navigate).toHaveBeenCalledWith(['owned', '00000000-0000-0000-0000-000000000002']); + }); + + it('on a 422 InvariantViolationResponse, shows a banner instead of navigating', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + const { ready } = configure('legacy/:id', { id: '1002' }, { + takeOwnership: vi + .fn() + .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Bsn.ElevenProof', message: 'BSN fails the eleven-proof.' } }))), + }); + await ready; + fixture = TestBed.createComponent(CaseDetailPage); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click(); + fixture.detectChanges(); + + const banner = fixture.nativeElement.querySelector('[data-testid="ownership-banner"]'); + expect(banner?.textContent).toContain('Bsn.ElevenProof'); + expect(banner?.textContent).toContain('BSN fails the eleven-proof.'); + }); + }); + + describe('release ownership', () => { + it('calls releaseOwnership and navigates to the worklist on success', async () => { + const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }, { + releaseOwnership: vi.fn().mockReturnValue(of(undefined)), + }); + await ready; + fixture = TestBed.createComponent(CaseDetailPage); + fixture.detectChanges(); + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate'); + + fixture.nativeElement.querySelector('[data-testid="release-ownership-button"]').click(); + + expect(service.releaseOwnership).toHaveBeenCalledWith('/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership'); + expect(navigate).toHaveBeenCalledWith(['/']); + }); + + it('on a 409 MessageResponse, shows the server message as a banner', async () => { + const { ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }, { + releaseOwnership: vi + .fn() + .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409, error: { message: 'Domain writes exist since adoption.' } }))), + }); + await ready; + fixture = TestBed.createComponent(CaseDetailPage); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('[data-testid="release-ownership-button"]').click(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="ownership-banner"]')?.textContent).toContain('Domain writes exist since adoption.'); + }); }); }); diff --git a/portal-frontend/src/app/case-detail/case-detail/case-detail.ts b/portal-frontend/src/app/case-detail/case-detail/case-detail.ts index a1c532d..cf09f5d 100644 --- a/portal-frontend/src/app/case-detail/case-detail/case-detail.ts +++ b/portal-frontend/src/app/case-detail/case-detail/case-detail.ts @@ -1,10 +1,12 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { KeyValuePipe } from '@angular/common'; import { Component, inject, signal } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types'; import { CaseActions } from '../case-actions/case-actions'; import { CaseDetailService } from '../case-detail.service'; -import { CaseDetail, toApplicantDetailsRequest } from '../case-detail.types'; +import { ActionLink, CaseDetail, toApplicantDetailsRequest } from '../case-detail.types'; import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details'; @Component({ @@ -15,9 +17,11 @@ import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-d }) export class CaseDetailPage { private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly caseDetailService = inject(CaseDetailService); readonly detail = signal(null); + readonly banner = signal(null); readonly toApplicantDetailsRequest = toApplicantDetailsRequest; constructor() { @@ -38,4 +42,35 @@ export class CaseDetailPage { : this.caseDetailService.getOwnedDetail(current.registrationApplicationId!); source$.subscribe((updated) => this.detail.set(updated)); } + + // Matches the placeholder's own confirm() dialog before this one-way, + // per-case migration step (ADR-003). + onTakeOwnership(link: ActionLink): void { + if (!confirm('Dit dossier in eigen beheer nemen?')) return; + this.banner.set(null); + this.caseDetailService.takeOwnership(link.href).subscribe({ + next: (result) => this.router.navigate(['owned', result.registrationApplicationId]), + error: (error: HttpErrorResponse) => this.banner.set(this.describeError(error)), + }); + } + + onReleaseOwnership(link: ActionLink): void { + this.banner.set(null); + this.caseDetailService.releaseOwnership(link.href).subscribe({ + next: () => this.router.navigate(['/']), + error: (error: HttpErrorResponse) => this.banner.set(this.describeError(error)), + }); + } + + private describeError(error: HttpErrorResponse): string { + if (error.status === 422) { + const body = error.error as InvariantViolationResponse; + return `${body.invariant}: ${body.message}`; + } + if (error.status === 409) { + const body = error.error as MessageResponse; + return body.message; + } + return 'Onbekende fout.'; + } }