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 { 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; let http: HttpTestingController; let isAdmin: ReturnType>; let currentUser: ReturnType>; let closeDialog: Subject; let dialog: { open: ReturnType }; beforeEach(async () => { isAdmin = signal(false); currentUser = signal({ id: 99, firstName: 'Nora', lastName: 'Viewer', role: { id: 2 } }); closeDialog = new Subject(); 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({ ignoreCancelled: true })); 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 childButton(host: HTMLElement, label: string): HTMLButtonElement { const match = [...host.querySelectorAll('button')].find((element) => element.textContent?.includes(label)); if (!match) throw new Error(`Missing child 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('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('cancels an older directory load when a newer search starts', () => { create(); const older = http.expectOne(`${api}?page=1&limit=20`); const search = (fixture.nativeElement as HTMLElement).querySelector('input[type="search"]')!; search.value = 'Grace'; search.dispatchEvent(new Event('input')); search.closest('form')!.dispatchEvent(new Event('submit')); const newer = http.expectOne(`${api}?page=1&limit=20&search=Grace`); const olderWasCancelled = older.cancelled; newer.flush(directoryPage([admin])); if (!olderWasCancelled) older.flush(directoryPage([ada])); fixture.detectChanges(); expect(olderWasCancelled).toBe(true); expect(text()).toContain('Grace Admin'); expect(text()).not.toContain('Ada Lovelace'); }); 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('[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('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('input[name="firstName"]')!; const lastName = (fixture.nativeElement as HTMLElement).querySelector('input[name="lastName"]')!; const role = (fixture.nativeElement as HTMLElement).querySelector('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('keeps the editor draft while saving input changes and on an initial profile failure', () => { isAdmin.set(true); create(); flushDirectory(); button('Bearbeiten').click(); fixture.detectChanges(); const firstName = (fixture.nativeElement as HTMLElement).querySelector('input[name="firstName"]')!; firstName.value = 'Operator draft'; firstName.dispatchEvent(new Event('input')); firstName.closest('form')!.dispatchEvent(new Event('submit')); fixture.detectChanges(); expect(firstName.value).toBe('Operator draft'); http.expectOne(`${adminApi}/7/profile`).flush( { message: 'Profile rejected' }, { status: 400, statusText: 'Bad Request' }, ); http.expectNone(`${api}?page=1&limit=20`); fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelector('input[name="firstName"]')!.value).toBe( 'Operator draft', ); expect(text()).toContain('Profile rejected'); }); 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('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 }); fixture.detectChanges(); expect(text()).toContain('Status wird geändert'); expect((fixture.nativeElement as HTMLElement).querySelector('[data-user-id="7"]')?.getAttribute('aria-busy')).toBe('true'); request.flush(ada); http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, status: { id: 2, name: 'Inactive' } }])); }); it('searches players from page one after paging and assigns before refreshing both lists', () => { isAdmin.set(true); create(); flushDirectory(); button('Zuordnungen verwalten').click(); fixture.detectChanges(); const panel = (fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')!; expect(panel.textContent).toContain('Spieler werden geladen'); http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage({ hasNextPage: true, total: 21 })); fixture.detectChanges(); expect(panel.querySelector('nav[aria-label="Spielerseiten"]')).not.toBeNull(); childButton(panel, 'Weiter').click(); http.expectOne(`${adminApi}/players?assignment=all&page=2&limit=20`).flush(playersPage({ page: 2 })); fixture.detectChanges(); const playerSearch = panel.querySelector('input[type="search"]')!; playerSearch.value = 'Linus'; playerSearch.dispatchEvent(new Event('input')); playerSearch.closest('form')!.dispatchEvent(new Event('submit')); http.expectOne(`${adminApi}/players?search=Linus&assignment=all&page=1&limit=20`).flush(playersPage()); fixture.detectChanges(); expect(text()).toContain('Linus Player'); expect(text()).toContain('Second Team'); childButton(panel, '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?search=Linus&assignment=all&page=1&limit=20`).flush(playersPage()); }); it('cancels an older player load when a newer player search starts', () => { isAdmin.set(true); create(); flushDirectory(); button('Zuordnungen verwalten').click(); fixture.detectChanges(); const older = http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`); const panel = (fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')!; const playerSearch = panel.querySelector('input[type="search"]')!; playerSearch.value = 'New'; playerSearch.dispatchEvent(new Event('input')); playerSearch.closest('form')!.dispatchEvent(new Event('submit')); const newer = http.expectOne(`${adminApi}/players?search=New&assignment=all&page=1&limit=20`); const olderWasCancelled = older.cancelled; newer.flush(playersPage({ data: [{ ...playersPage().data[0], firstName: 'New' }] })); if (!olderWasCancelled) older.flush(playersPage()); fixture.detectChanges(); expect(olderWasCancelled).toBe(true); expect(panel.textContent).toContain('New Player'); expect(panel.textContent).not.toContain('Linus Player'); }); it('clears stale player rows on load errors and retries with load-specific copy', () => { 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(); const panel = (fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')!; expect(panel.textContent).toContain('Linus Player'); const playerSearch = panel.querySelector('input[type="search"]')!; playerSearch.value = 'broken'; playerSearch.dispatchEvent(new Event('input')); playerSearch.closest('form')!.dispatchEvent(new Event('submit')); http.expectOne(`${adminApi}/players?search=broken&assignment=all&page=1&limit=20`).flush( { message: 'Search unavailable' }, { status: 500, statusText: 'Server Error' }, ); fixture.detectChanges(); expect(panel.textContent).not.toContain('Linus Player'); expect(panel.textContent).toContain('Spieler konnten nicht geladen werden'); expect(panel.textContent).toContain('Search unavailable'); childButton(panel, 'Erneut versuchen').click(); http.expectOne(`${adminApi}/players?search=broken&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(); 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'); const panel = (fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')!; expect(panel.querySelector('section')?.getAttribute('aria-busy')).toBe('true'); expect(childButton(panel, 'Schließen').disabled).toBe(true); expect(childButton(panel, 'Suchen').disabled).toBe(true); const mainToggle = [...(fixture.nativeElement as HTMLElement).querySelectorAll('[data-user-id="7"] > .user-row__actions button')].find( (item) => item.textContent?.includes('Zuordnungen verwalten'), )!; expect(mainToggle.disabled).toBe(true); childButton(panel, 'Schließen').click(); fixture.detectChanges(); expect((fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')).not.toBeNull(); 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('blocks directory search and paging while an assignment mutation is pending, then restores them', () => { isAdmin.set(true); create(); http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([ada], 1, 60, true)); fixture.detectChanges(); button('Weiter').click(); http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([ada], 2, 60, true)); fixture.detectChanges(); 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(); childButton( (fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')!, 'Verknüpfung lösen', ).click(); closeDialog.next(true); fixture.detectChanges(); const unlink = http.expectOne(`${adminApi}/7/players/101`); const directoryForm = (fixture.nativeElement as HTMLElement).querySelector('.directory-search')!; const directorySearch = directoryForm.querySelector('input[type="search"]')!; const directorySearchButton = directoryForm.querySelector('button[type="submit"]')!; const paging = (fixture.nativeElement as HTMLElement).querySelector('nav.pagination')!; const [previous, next] = [...paging.querySelectorAll('button')]; const controlsWereBlocked = directorySearch.disabled && directorySearchButton.disabled && previous.disabled && next.disabled; directorySearch.value = 'Other'; directorySearch.dispatchEvent(new Event('input')); directoryForm.dispatchEvent(new Event('submit')); previous.click(); next.click(); fixture.detectChanges(); const unexpectedQueries = http.match((request) => request.url.startsWith(api)); const mutationStayedActive = !unlink.cancelled; if (controlsWereBlocked && unexpectedQueries.length === 0 && mutationStayedActive) { unlink.flush(ada); http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([ada], 2, 60, true)); http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage()); fixture.detectChanges(); expect(directorySearch.disabled).toBe(false); expect(directorySearchButton.disabled).toBe(false); expect(previous.disabled).toBe(false); expect(next.disabled).toBe(false); directoryForm.dispatchEvent(new Event('submit')); http.expectOne(`${api}?page=1&limit=20&search=Other`).flush(directoryPage([])); } else { for (const query of unexpectedQueries) query.flush(directoryPage([ada], 1, 60, true)); fixture.detectChanges(); if (!unlink.cancelled) unlink.flush(ada); for (const playerLoad of http.match((request) => request.url.startsWith(`${adminApi}/players`))) { playerLoad.flush(playersPage()); } } expect(controlsWereBlocked).toBe(true); expect(unexpectedQueries).toHaveLength(0); expect(mutationStayedActive).toBe(true); }); it('shows a persistent explanation for disabled 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])); fixture.detectChanges(); const row = (fixture.nativeElement as HTMLElement).querySelector('[data-user-id="1"]')!; const statusButton = [...row.querySelectorAll('button')].find((item) => item.textContent?.includes('Deaktivieren'))!; const descriptionId = statusButton.getAttribute('aria-describedby'); expect(descriptionId).toBeTruthy(); expect((fixture.nativeElement as HTMLElement).querySelector(`#${descriptionId}`)?.textContent).toContain( 'eigene Konto kann nicht deaktiviert werden', ); }); 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'); }); });