From 71668529460a2e99dbdc9aa47108ef94414c51b4 Mon Sep 17 00:00:00 2001 From: "jdwlabs-agent-bot[bot]" <316811914+jdwlabs-agent-bot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:24:53 +0000 Subject: [PATCH] fix(usersui): read user profiles from the profile service The admin user page and the delete-user dialog read the embedded user.profile for the profile form, the addresses and the address/icon action targets. identity-service returns profileId instead, so on that backend the page offered Add Profile for users that have one and the action targets were undefined. They now load the profile through UsersService.withProfile, which keeps using an embedded profile and skips the lookup when profileId is null. Admins may read any user's profile through the admin-or-self rule. A lookup left pending is abandoned after 10s so the page still renders. Refs: JDWLABS-484 Co-Authored-By: Claude Sonnet 5.5 Assisted-by: Claude Code:claude-sonnet-5-5 --- .../src/lib/users/users.service.spec.ts | 46 +++++++ .../src/lib/users/users.service.ts | 23 ++++ .../lib/user/user.component.go-shape.spec.ts | 128 ++++++++++++++++++ .../core/src/lib/user/user.component.spec.ts | 6 +- .../core/src/lib/user/user.component.ts | 2 +- ...ons-button-cell-renderer.component.spec.ts | 30 +++- ...-actions-button-cell-renderer.component.ts | 29 ++-- 7 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 libs/frontend/usersui/feature/core/src/lib/user/user.component.go-shape.spec.ts diff --git a/libs/frontend/shared/data-access/src/lib/users/users.service.spec.ts b/libs/frontend/shared/data-access/src/lib/users/users.service.spec.ts index 392e993ae..9725ab614 100644 --- a/libs/frontend/shared/data-access/src/lib/users/users.service.spec.ts +++ b/libs/frontend/shared/data-access/src/lib/users/users.service.spec.ts @@ -734,4 +734,50 @@ describe('UsersService', () => { expect(mockSnackbarService.error).not.toHaveBeenCalled(); }); }); + + describe('withProfile', () => { + const profile = { id: 7, firstName: 'Jake' } as unknown as Profile; + + it('settles on the fetched profile without the provisional null', () => { + const outcome = record( + service.withProfile({ id: 42, profileId: 7 } as User), + ); + httpTesting + .expectOne(`${environmentMock.AUTH_BASE_URL}/api/profiles/by-user/42`) + .flush(profile); + + expect(outcome.values).toEqual([{ id: 42, profileId: 7, profile }]); + }); + + it('gives up on a hung lookup and settles with a null profile', () => { + vi.useFakeTimers(); + try { + const outcome = record( + service.withProfile({ id: 42, profileId: 7 } as User), + ); + httpTesting.expectOne( + `${environmentMock.AUTH_BASE_URL}/api/profiles/by-user/42`, + ); + + vi.advanceTimersByTime(10_000); + + expect(outcome.values).toEqual([ + { id: 42, profileId: 7, profile: null }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('sets profile to null when the lookup fails', () => { + const outcome = record( + service.withProfile({ id: 42, profileId: 7 } as User), + ); + httpTesting + .expectOne(`${environmentMock.AUTH_BASE_URL}/api/profiles/by-user/42`) + .flush('x', { status: 404, statusText: 'Not Found' }); + + expect(outcome.values[0].profile).toBeNull(); + }); + }); }); diff --git a/libs/frontend/shared/data-access/src/lib/users/users.service.ts b/libs/frontend/shared/data-access/src/lib/users/users.service.ts index 1138cc098..1b3e03394 100644 --- a/libs/frontend/shared/data-access/src/lib/users/users.service.ts +++ b/libs/frontend/shared/data-access/src/lib/users/users.service.ts @@ -14,16 +14,23 @@ import { distinctUntilChanged, EMPTY, forkJoin, + last, map, Observable, of, startWith, + switchMap, tap, + timeout, } from 'rxjs'; import { AddUser, EditUser, Profile, User } from '@jdw/frontend-shared-util'; import { AuthService } from '../auth/auth.service'; import { SnackbarService } from '../snackbar/snackbar.service'; +// Pages that wait for the settled profile would otherwise stay blank for as +// long as profile-service holds a request open. +const PROFILE_LOOKUP_TIMEOUT_MS = 10_000; + @Injectable({ providedIn: 'root', }) @@ -84,6 +91,22 @@ export class UsersService { ); } + // For pages that need the settled profile on the user object (forms, action + // targets), where getUserProfile's provisional null would be wrong to render. + withProfile(user: User): Observable { + return this.getUserProfile(user).pipe( + timeout({ each: PROFILE_LOOKUP_TIMEOUT_MS, with: () => of(null) }), + last(), + map((profile) => ({ ...user, profile })), + ); + } + + getUserWithProfile(userId: string | number): Observable { + return this.getUser(userId).pipe( + switchMap((user) => this.withProfile(user)), + ); + } + deleteUser(userId: number): Observable { const token = this.authService.getToken(); const headers = new HttpHeaders({ Authorization: `Bearer ${token}` }); diff --git a/libs/frontend/usersui/feature/core/src/lib/user/user.component.go-shape.spec.ts b/libs/frontend/usersui/feature/core/src/lib/user/user.component.go-shape.spec.ts new file mode 100644 index 000000000..0b86c6e8c --- /dev/null +++ b/libs/frontend/usersui/feature/core/src/lib/user/user.component.go-shape.spec.ts @@ -0,0 +1,128 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ENVIRONMENT } from '@jdw/frontend-shared-util'; +import { UserComponent } from './user.component'; + +const API = 'http://localhost:8080'; + +const profile = { + id: 7, + firstName: 'Jane', + middleName: null, + lastName: 'Roe', + birthdate: '1990-01-01', + userId: 5, + icon: null, + addresses: [ + { + id: 11, + addressLine1: '1 Main St', + addressLine2: null, + city: 'Metropolis', + stateProvince: 'NY', + postalCode: '12345', + country: 'USA', + }, + ], +}; + +// identity-service serves profileId where the JVM service embedded the profile. +const goUser = { + id: 5, + emailAddress: 'jane@example.com', + status: 'ACTIVE', + roles: [], + profileId: 7, +}; + +describe('UserComponent against a user that only carries profileId', () => { + let httpTesting: HttpTestingController; + const navigate = vi.fn(); + + function create(user: object, profileReply?: () => void) { + TestBed.configureTestingModule({ + imports: [UserComponent, NoopAnimationsModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: Router, useValue: { navigate } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: { get: () => '5' } } }, + }, + { provide: ENVIRONMENT, useValue: { AUTH_BASE_URL: API } }, + ], + }); + httpTesting = TestBed.inject(HttpTestingController); + const fixture = TestBed.createComponent(UserComponent); + fixture.detectChanges(); + httpTesting.expectOne(`${API}/api/users/5`).flush(user); + profileReply?.(); + fixture.detectChanges(); + return fixture; + } + + const hasDataCy = (root: HTMLElement, name: string) => + !!root.querySelector(`[data-cy="${name}"]`); + + it('renders the fetched profile and addresses', () => { + const fixture = create(goUser, () => + httpTesting.expectOne(`${API}/api/profiles/by-user/5`).flush(profile), + ); + const root = fixture.nativeElement as HTMLElement; + const component = fixture.componentInstance; + + expect(component.userForm.get('profile.firstName')?.value).toBe('Jane'); + expect(component.addresses.length).toBe(1); + expect(hasDataCy(root, 'edit-profile-button')).toBe(true); + expect(hasDataCy(root, 'add-profile-button')).toBe(false); + }); + + it('points address and icon actions at the fetched profile id', () => { + const fixture = create(goUser, () => + httpTesting.expectOne(`${API}/api/profiles/by-user/5`).flush(profile), + ); + + fixture.componentInstance.editAddress(0); + fixture.componentInstance.addIcon(); + + expect(navigate).toHaveBeenCalledWith(['../../profile/7/address/11'], { + relativeTo: expect.anything(), + }); + expect(navigate).toHaveBeenCalledWith(['../../profile/7/icon'], { + relativeTo: expect.anything(), + }); + }); + + it('offers Add Profile when the profile does not exist', () => { + const fixture = create(goUser, () => + httpTesting + .expectOne(`${API}/api/profiles/by-user/5`) + .flush('not found', { status: 404, statusText: 'Not Found' }), + ); + + expect( + hasDataCy(fixture.nativeElement as HTMLElement, 'add-profile-button'), + ).toBe(true); + }); + + it('asks nothing of the profile service for an embedded profile', () => { + const fixture = create({ ...goUser, profileId: undefined, profile }); + + httpTesting.expectNone(`${API}/api/profiles/by-user/5`); + expect( + fixture.componentInstance.userForm.get('profile.firstName')?.value, + ).toBe('Jane'); + }); + + afterEach(() => { + navigate.mockClear(); + httpTesting.verify(); + }); +}); diff --git a/libs/frontend/usersui/feature/core/src/lib/user/user.component.spec.ts b/libs/frontend/usersui/feature/core/src/lib/user/user.component.spec.ts index a2479ad22..eb2c6e911 100644 --- a/libs/frontend/usersui/feature/core/src/lib/user/user.component.spec.ts +++ b/libs/frontend/usersui/feature/core/src/lib/user/user.component.spec.ts @@ -83,7 +83,7 @@ const mockRole: Role = { }; const mockUsersService = { - getUser: vi.fn(() => of(mockUser)), + getUserWithProfile: vi.fn(() => of(mockUser)), }; const mockRolesService = { @@ -148,8 +148,8 @@ describe('UserComponent', () => { expect(component.userId).toBe('1'); }); - it('should call getUser and populate the form', () => { - expect(mockUsersService.getUser).toHaveBeenCalledWith('1'); + it('should call getUserWithProfile and populate the form', () => { + expect(mockUsersService.getUserWithProfile).toHaveBeenCalledWith('1'); expect(component.userForm.get('emailAddress')?.value).toBe( 'user@jdwkube.com', ); diff --git a/libs/frontend/usersui/feature/core/src/lib/user/user.component.ts b/libs/frontend/usersui/feature/core/src/lib/user/user.component.ts index 339a85291..9666d6efe 100644 --- a/libs/frontend/usersui/feature/core/src/lib/user/user.component.ts +++ b/libs/frontend/usersui/feature/core/src/lib/user/user.component.ts @@ -59,7 +59,7 @@ export class UserComponent implements OnInit { this.userId = this.route.snapshot.paramMap.get('userId'); if (this.userId) { - this.usersService.getUser(this.userId).subscribe({ + this.usersService.getUserWithProfile(this.userId).subscribe({ next: (response) => { this.user = response; this.populateForm(this.user); diff --git a/libs/frontend/usersui/feature/core/src/lib/users-actions-button-cell-renderer/users-actions-button-cell-renderer.component.spec.ts b/libs/frontend/usersui/feature/core/src/lib/users-actions-button-cell-renderer/users-actions-button-cell-renderer.component.spec.ts index c8d50837d..70d947e6f 100644 --- a/libs/frontend/usersui/feature/core/src/lib/users-actions-button-cell-renderer/users-actions-button-cell-renderer.component.spec.ts +++ b/libs/frontend/usersui/feature/core/src/lib/users-actions-button-cell-renderer/users-actions-button-cell-renderer.component.spec.ts @@ -4,6 +4,12 @@ import { ActivatedRoute } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ENVIRONMENT } from '@jdw/frontend-shared-util'; +import { MatDialog } from '@angular/material/dialog'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { ICellRendererParams } from 'ag-grid-community'; + +const dialogOpen = vi.fn(() => ({ afterClosed: () => of(undefined) })); describe('ActionsButtonCellRendererComponent', () => { let component: UsersActionsButtonCellRendererComponent; @@ -13,6 +19,7 @@ describe('ActionsButtonCellRendererComponent', () => { await TestBed.configureTestingModule({ imports: [UsersActionsButtonCellRendererComponent], providers: [ + { provide: MatDialog, useValue: { open: dialogOpen } }, { provide: ActivatedRoute, useValue: ActivatedRoute, @@ -21,7 +28,7 @@ describe('ActionsButtonCellRendererComponent', () => { provideHttpClientTesting(), { provide: ENVIRONMENT, - useValue: {}, + useValue: { AUTH_BASE_URL: 'http://localhost:8080' }, }, ], }).compileComponents(); @@ -34,4 +41,25 @@ describe('ActionsButtonCellRendererComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('loads the profile for the delete dialog when the row only has profileId', () => { + const httpTesting = TestBed.inject(HttpTestingController); + component.agInit({ + data: { id: 5, emailAddress: 'jane@example.com', profileId: 7 }, + } as unknown as ICellRendererParams); + + component.deleteUser(); + httpTesting + .expectOne('http://localhost:8080/api/profiles/by-user/5') + .flush({ id: 7, firstName: 'Jane', addresses: [] }); + + expect(dialogOpen).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + profile: expect.objectContaining({ firstName: 'Jane' }), + }), + }), + ); + }); }); diff --git a/libs/frontend/usersui/feature/core/src/lib/users-actions-button-cell-renderer/users-actions-button-cell-renderer.component.ts b/libs/frontend/usersui/feature/core/src/lib/users-actions-button-cell-renderer/users-actions-button-cell-renderer.component.ts index 06a66fd7d..501d4e811 100644 --- a/libs/frontend/usersui/feature/core/src/lib/users-actions-button-cell-renderer/users-actions-button-cell-renderer.component.ts +++ b/libs/frontend/usersui/feature/core/src/lib/users-actions-button-cell-renderer/users-actions-button-cell-renderer.component.ts @@ -44,19 +44,22 @@ export class UsersActionsButtonCellRendererComponent implements ICellRendererAng } deleteUser(): void { - const dialogRef = this.dialog.open(UserDialogComponent, { - data: { - title: 'Delete User', - type: 'readonly', - action: 'delete', - ...this.params.data, - }, - minWidth: '50%', - }); - dialogRef.afterClosed().subscribe((result: User) => { - if (result) { - this.usersService.deleteUser(result.id).subscribe(); - } + // The grid row comes from the list endpoint, which may carry only profileId. + this.usersService.withProfile(this.params.data).subscribe((user) => { + const dialogRef = this.dialog.open(UserDialogComponent, { + data: { + title: 'Delete User', + type: 'readonly', + action: 'delete', + ...user, + }, + minWidth: '50%', + }); + dialogRef.afterClosed().subscribe((result: User) => { + if (result) { + this.usersService.deleteUser(result.id).subscribe(); + } + }); }); } }