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(); + } + }); }); } }