Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
23 changes: 23 additions & 0 deletions libs/frontend/shared/data-access/src/lib/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
Expand Down Expand Up @@ -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<User> {
return this.getUserProfile(user).pipe(
timeout({ each: PROFILE_LOOKUP_TIMEOUT_MS, with: () => of(null) }),
last(),
Comment thread
jdwlabs-agent-bot[bot] marked this conversation as resolved.
Resolved
map((profile) => ({ ...user, profile })),
);
}

getUserWithProfile(userId: string | number): Observable<User> {
return this.getUser(userId).pipe(
switchMap((user) => this.withProfile(user)),
);
}

deleteUser(userId: number): Observable<object> {
const token = this.authService.getToken();
const headers = new HttpHeaders({ Authorization: `Bearer ${token}` });
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const mockRole: Role = {
};

const mockUsersService = {
getUser: vi.fn(() => of(mockUser)),
getUserWithProfile: vi.fn(() => of(mockUser)),
};

const mockRolesService = {
Expand Down Expand Up @@ -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',
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,6 +19,7 @@ describe('ActionsButtonCellRendererComponent', () => {
await TestBed.configureTestingModule({
imports: [UsersActionsButtonCellRendererComponent],
providers: [
{ provide: MatDialog, useValue: { open: dialogOpen } },
{
provide: ActivatedRoute,
useValue: ActivatedRoute,
Expand All @@ -21,7 +28,7 @@ describe('ActionsButtonCellRendererComponent', () => {
provideHttpClientTesting(),
{
provide: ENVIRONMENT,
useValue: {},
useValue: { AUTH_BASE_URL: 'http://localhost:8080' },
},
],
}).compileComponents();
Expand All @@ -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' }),
}),
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
}
}
Loading