feat: build admin user management UI
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { AuthStore } from '../../core/auth/auth-store';
|
||||
import { AdminPlayerPage, AdminUserDirectorySummary, UserDirectoryPage } from '../../models/user-directory.model';
|
||||
import { Users } from './users';
|
||||
|
||||
const api = `${environment.apiUrl}users/directory`;
|
||||
const adminApi = `${environment.apiUrl}admin/users`;
|
||||
|
||||
const ada: AdminUserDirectorySummary = {
|
||||
id: 7,
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
email: 'ada@example.test',
|
||||
role: { id: 2, name: 'User' },
|
||||
status: { id: 1, name: 'Active' },
|
||||
assignments: [
|
||||
{
|
||||
id: 101,
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
active: true,
|
||||
team: { id: 5, name: 'First Team', alias: 'first' },
|
||||
teamRole: { id: 1, name: 'player' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const admin: AdminUserDirectorySummary = {
|
||||
id: 1,
|
||||
firstName: 'Grace',
|
||||
lastName: 'Admin',
|
||||
email: 'grace@example.test',
|
||||
role: { id: 1, name: 'Admin' },
|
||||
status: { id: 1, name: 'Active' },
|
||||
assignments: [],
|
||||
};
|
||||
|
||||
function directoryPage(data = [ada], page = 1, total = data.length, hasNextPage = false): UserDirectoryPage {
|
||||
return { data, page, limit: 20, total, hasNextPage };
|
||||
}
|
||||
|
||||
function playersPage(overrides: Partial<AdminPlayerPage> = {}): AdminPlayerPage {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: 202,
|
||||
firstName: 'Linus',
|
||||
lastName: 'Player',
|
||||
active: true,
|
||||
team: { id: 6, name: 'Second Team', alias: 'second' },
|
||||
currentUser: null,
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
hasNextPage: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Users directory', () => {
|
||||
let fixture: ComponentFixture<Users>;
|
||||
let http: HttpTestingController;
|
||||
let isAdmin: ReturnType<typeof signal<boolean>>;
|
||||
let currentUser: ReturnType<typeof signal<{ id: number; firstName: string; lastName: string; role: { id: number } }>>;
|
||||
let closeDialog: Subject<boolean>;
|
||||
let dialog: { open: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
isAdmin = signal(false);
|
||||
currentUser = signal({ id: 99, firstName: 'Nora', lastName: 'Viewer', role: { id: 2 } });
|
||||
closeDialog = new Subject<boolean>();
|
||||
dialog = { open: vi.fn(() => ({ afterClosed: () => closeDialog.asObservable() })) };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Users],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: AuthStore, useValue: { isGlobalAdmin: isAdmin, currentUser } },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => http.verify());
|
||||
|
||||
function create(): void {
|
||||
fixture = TestBed.createComponent(Users);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function text(): string {
|
||||
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
}
|
||||
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const match = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((element) =>
|
||||
element.textContent?.includes(label),
|
||||
);
|
||||
if (!match) throw new Error(`Missing button: ${label}`);
|
||||
return match as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function flushDirectory(page = directoryPage()): void {
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(page);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('loads the first directory page, searches from page one, and pages forward', () => {
|
||||
create();
|
||||
flushDirectory(directoryPage([ada], 1, 12, true));
|
||||
|
||||
expect(text()).toContain('Ada Lovelace');
|
||||
expect(text()).toContain('First Team');
|
||||
expect(text()).toContain('Aktiv');
|
||||
|
||||
const search = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[type="search"]')!;
|
||||
search.value = ' Linus ';
|
||||
search.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
search.closest('form')!.dispatchEvent(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
http.expectOne(`${api}?page=1&limit=20&search=Linus`).flush(directoryPage([], 1));
|
||||
fixture.detectChanges();
|
||||
|
||||
search.value = '';
|
||||
search.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
search.closest('form')!.dispatchEvent(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 12, true));
|
||||
fixture.detectChanges();
|
||||
button('Weiter').click();
|
||||
fixture.detectChanges();
|
||||
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([admin], 2, 12, false));
|
||||
});
|
||||
|
||||
it('redacts admin-only data and controls for a non-admin even if extra fields arrive', () => {
|
||||
create();
|
||||
flushDirectory(directoryPage([ada]));
|
||||
|
||||
expect(text()).toContain('Ada Lovelace');
|
||||
expect(text()).not.toContain('ada@example.test');
|
||||
expect(text()).not.toContain('Globale Rolle');
|
||||
expect(text()).not.toContain('Bearbeiten');
|
||||
expect(text()).not.toContain('Zuordnungen verwalten');
|
||||
});
|
||||
|
||||
it('shows admin fields and actions while disabling self-demotion and self-deactivation', () => {
|
||||
isAdmin.set(true);
|
||||
currentUser.set({ id: 1, firstName: 'Grace', lastName: 'Admin', role: { id: 1 } });
|
||||
create();
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([admin, ada]));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(text()).toContain('ada@example.test');
|
||||
expect(text()).toContain('Administrator');
|
||||
expect(text()).toContain('Bearbeiten');
|
||||
expect(text()).toContain('Zuordnungen verwalten');
|
||||
const selfRow = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('[data-user-id="1"]')!;
|
||||
const selfButtons = [...selfRow.querySelectorAll('button')];
|
||||
expect(selfButtons.find((item) => item.textContent?.includes('Deaktivieren'))?.disabled).toBe(true);
|
||||
selfButtons.find((item) => item.textContent?.includes('Bearbeiten'))?.click();
|
||||
fixture.detectChanges();
|
||||
expect(selfRow.querySelector<HTMLSelectElement>('select[name="role"]')?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('edits a profile and role pessimistically, then reloads the directory', () => {
|
||||
isAdmin.set(true);
|
||||
create();
|
||||
flushDirectory();
|
||||
button('Bearbeiten').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const firstName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!;
|
||||
const lastName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="lastName"]')!;
|
||||
const role = (fixture.nativeElement as HTMLElement).querySelector<HTMLSelectElement>('select[name="role"]')!;
|
||||
firstName.value = 'Augusta';
|
||||
firstName.dispatchEvent(new Event('input'));
|
||||
lastName.value = 'King';
|
||||
lastName.dispatchEvent(new Event('input'));
|
||||
role.value = '1';
|
||||
role.dispatchEvent(new Event('change'));
|
||||
firstName.closest('form')!.dispatchEvent(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
|
||||
const profile = http.expectOne(`${adminApi}/7/profile`);
|
||||
expect(profile.request.body).toEqual({ firstName: 'Augusta', lastName: 'King' });
|
||||
expect(text()).toContain('Wird gespeichert');
|
||||
profile.flush(ada);
|
||||
const roleRequest = http.expectOne(`${adminApi}/7/role`);
|
||||
expect(roleRequest.request.body).toEqual({ role: 1 });
|
||||
roleRequest.flush(ada);
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, firstName: 'Augusta', lastName: 'King' }]));
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('Augusta King');
|
||||
});
|
||||
|
||||
it('reloads authoritative directory state when a role change fails after the profile was saved', () => {
|
||||
isAdmin.set(true);
|
||||
create();
|
||||
flushDirectory();
|
||||
button('Bearbeiten').click();
|
||||
fixture.detectChanges();
|
||||
const role = (fixture.nativeElement as HTMLElement).querySelector<HTMLSelectElement>('select[name="role"]')!;
|
||||
role.value = '1';
|
||||
role.dispatchEvent(new Event('change'));
|
||||
role.closest('form')!.dispatchEvent(new Event('submit'));
|
||||
|
||||
http.expectOne(`${adminApi}/7/profile`).flush(ada);
|
||||
http.expectOne(`${adminApi}/7/role`).flush(
|
||||
{ message: 'At least one active admin must remain' },
|
||||
{ status: 409, statusText: 'Conflict' },
|
||||
);
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('At least one active admin must remain');
|
||||
});
|
||||
|
||||
it('confirms status changes before mutating and refreshes after success', () => {
|
||||
isAdmin.set(true);
|
||||
create();
|
||||
flushDirectory();
|
||||
button('Deaktivieren').click();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalled();
|
||||
expect(dialog.open.mock.calls[0][1].data.message).toContain('Ada Lovelace');
|
||||
http.expectNone(`${adminApi}/7/status`);
|
||||
closeDialog.next(true);
|
||||
const request = http.expectOne(`${adminApi}/7/status`);
|
||||
expect(request.request.body).toEqual({ status: 2 });
|
||||
request.flush(ada);
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, status: { id: 2, name: 'Inactive' } }]));
|
||||
});
|
||||
|
||||
it('loads searchable player results and assigns an unlinked player before refreshing both lists', () => {
|
||||
isAdmin.set(true);
|
||||
create();
|
||||
flushDirectory();
|
||||
button('Zuordnungen verwalten').click();
|
||||
fixture.detectChanges();
|
||||
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('Linus Player');
|
||||
expect(text()).toContain('Second Team');
|
||||
|
||||
button('Zuordnen').click();
|
||||
const assign = http.expectOne(`${adminApi}/7/players/202`);
|
||||
expect(assign.request.method).toBe('PUT');
|
||||
assign.flush(ada);
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
|
||||
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
|
||||
});
|
||||
|
||||
it('confirms unlinking and reassignment with the affected user names', () => {
|
||||
isAdmin.set(true);
|
||||
create();
|
||||
flushDirectory();
|
||||
button('Zuordnungen verwalten').click();
|
||||
fixture.detectChanges();
|
||||
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(
|
||||
playersPage({
|
||||
data: [
|
||||
{
|
||||
id: 101,
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
active: true,
|
||||
team: { id: 5, name: 'First Team', alias: 'first' },
|
||||
currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } },
|
||||
},
|
||||
{
|
||||
id: 203,
|
||||
firstName: 'Other',
|
||||
lastName: 'Player',
|
||||
active: true,
|
||||
team: { id: 6, name: 'Second Team', alias: 'second' },
|
||||
currentUser: { id: 8, firstName: 'Alan', lastName: 'Turing', status: { id: 1, name: 'Active' } },
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
}),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
button('Verknüpfung lösen').click();
|
||||
expect(dialog.open.mock.calls[0][1].data.message).toContain('Ada Lovelace');
|
||||
closeDialog.next(false);
|
||||
http.expectNone(`${adminApi}/7/players/101`);
|
||||
|
||||
closeDialog = new Subject<boolean>();
|
||||
dialog.open.mockReturnValue({ afterClosed: () => closeDialog.asObservable() });
|
||||
button('Neu zuordnen').click();
|
||||
const message = dialog.open.mock.calls[1][1].data.message;
|
||||
expect(message).toContain('Alan Turing');
|
||||
expect(message).toContain('Ada Lovelace');
|
||||
closeDialog.next(true);
|
||||
http.expectOne(`${adminApi}/7/players/203`).flush(ada);
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
|
||||
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
|
||||
});
|
||||
|
||||
it('keeps an unlink pending until success and then reloads directory and player results', () => {
|
||||
isAdmin.set(true);
|
||||
create();
|
||||
flushDirectory();
|
||||
button('Zuordnungen verwalten').click();
|
||||
fixture.detectChanges();
|
||||
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(
|
||||
playersPage({
|
||||
data: [
|
||||
{
|
||||
id: 101,
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lovelace',
|
||||
active: true,
|
||||
team: { id: 5, name: 'First Team', alias: 'first' },
|
||||
currentUser: { id: 7, firstName: 'Ada', lastName: 'Lovelace', status: { id: 1, name: 'Active' } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
button('Verknüpfung lösen').click();
|
||||
http.expectNone(`${adminApi}/7/players/101`);
|
||||
closeDialog.next(true);
|
||||
fixture.detectChanges();
|
||||
const unlink = http.expectOne(`${adminApi}/7/players/101`);
|
||||
expect(unlink.request.method).toBe('DELETE');
|
||||
expect(text()).toContain('Wird gelöst');
|
||||
unlink.flush(ada);
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
|
||||
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
|
||||
});
|
||||
|
||||
it('renders loading, empty, general error, and retry states', () => {
|
||||
create();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('[role="progressbar"]')).not.toBeNull();
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush('broken', { status: 500, statusText: 'Server Error' });
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('Benutzer konnten nicht geladen werden');
|
||||
button('Erneut versuchen').click();
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([]));
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('Keine Benutzer gefunden');
|
||||
});
|
||||
|
||||
it('surfaces directory and mutation authorization errors clearly', () => {
|
||||
create();
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush({ message: 'Forbidden' }, { status: 403, statusText: 'Forbidden' });
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('Keine Berechtigung');
|
||||
|
||||
isAdmin.set(true);
|
||||
button('Erneut versuchen').click();
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
|
||||
fixture.detectChanges();
|
||||
button('Deaktivieren').click();
|
||||
closeDialog.next(true);
|
||||
http.expectOne(`${adminApi}/7/status`).flush(
|
||||
{ message: 'At least one active admin must remain' },
|
||||
{ status: 403, statusText: 'Forbidden' },
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('Keine Berechtigung');
|
||||
expect(text()).toContain('At least one active admin must remain');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user