fix: address penalty catalog review findings

This commit is contained in:
Bastian Wagner
2026-08-01 14:46:58 +02:00
parent fa05e55d43
commit c7dfdd7498
7 changed files with 149 additions and 23 deletions

View File

@@ -26,6 +26,11 @@ type AuthenticatedRequest = { user: { id: number } };
export class PenaltyController {
constructor(private readonly service: PenaltyService) {}
@Get()
getUserPenalties(@Request() request: AuthenticatedRequest) {
return this.service.getUserPenalties(request.user.id);
}
@Get(':teamId')
getTeamPenalties(
@Request() request: AuthenticatedRequest,

View File

@@ -20,6 +20,7 @@ describe('penalty catalog HTTP boundary', () => {
createdAt: '2026-01-02T00:00:00.000Z',
};
const service = {
getUserPenalties: jest.fn(() => [penalty]),
getTeamPenalties: jest.fn(() => [penalty]),
createPenalty: jest.fn(() => penalty),
updatePenalty: jest.fn(() => penalty),
@@ -62,6 +63,15 @@ describe('penalty catalog HTTP boundary', () => {
expect(service.getTeamPenalties).toHaveBeenCalledWith(42, 5);
});
it('keeps the authenticated cross-team catalog route available', async () => {
await request(app.getHttpServer()).get('/api/v1/penalty').expect(401);
await request(app.getHttpServer())
.get('/api/v1/penalty')
.set('Authorization', 'Bearer user')
.expect(200, [penalty]);
expect(service.getUserPenalties).toHaveBeenCalledWith(42);
});
it('validates and strips create fields before invoking the service', async () => {
await request(app.getHttpServer())
.post('/api/v1/penalty')

View File

@@ -55,6 +55,33 @@ describe('PenaltyService catalog management', () => {
expect(access.assertMember).not.toHaveBeenCalled();
});
it('keeps the authenticated cross-team catalog route compatible and safely mapped', async () => {
readRepository.find.mockResolvedValue([
{
id: 8,
description: 'Zu spät',
amount: '5.50',
createdAt: new Date('2026-01-02T00:00:00.000Z'),
team: { id: 5, secret: 'hidden' },
},
]);
await expect(service.getUserPenalties(42)).resolves.toEqual([
{
id: 8,
description: 'Zu spät',
amount: 5.5,
createdAt: new Date('2026-01-02T00:00:00.000Z'),
},
]);
expect(readRepository.find).toHaveBeenCalledWith({
where: {
team: { players: { user: { id: 42 }, active: true } },
},
order: { description: 'ASC' },
});
});
it('authorizes team reads, sorts them, and maps only safe fields', async () => {
readRepository.find.mockResolvedValue([
{
@@ -136,11 +163,7 @@ describe('PenaltyService catalog management', () => {
writeRepository.save.mockImplementation(async (value) => value);
await expect(
service.updatePenalty(
8,
{ description: ' Neu ', amount: 2.5 },
42,
),
service.updatePenalty(8, { description: ' Neu ', amount: 2.5 }, 42),
).resolves.toMatchObject({ id: 8, description: 'Neu', amount: 2.5 });
expect(duplicateQuery.andWhere).toHaveBeenCalledWith(
'penalty.id != :penaltyId',
@@ -194,10 +217,7 @@ describe('PenaltyService catalog management', () => {
logger.info.mockRejectedValue(new Error('audit unavailable'));
await expect(
service.createPenalty(
{ teamId: 5, description: 'Neu', amount: 2 },
42,
),
service.createPenalty({ teamId: 5, description: 'Neu', amount: 2 }, 42),
).rejects.toThrow('audit unavailable');
});
});

View File

@@ -25,6 +25,16 @@ export class PenaltyService {
private readonly logger: LoggingService,
) {}
async getUserPenalties(userId: number): Promise<PenaltyResponseDTO[]> {
const penalties = await this.repository.find({
where: {
team: { players: { user: { id: userId }, active: true } },
},
order: { description: 'ASC' },
});
return penalties.map((penalty) => this.toResponse(penalty));
}
async getTeamPenalties(
userId: number,
teamId: number,
@@ -133,7 +143,10 @@ export class PenaltyService {
return penalty;
}
private async lockTeam(manager: EntityManager, teamId: number): Promise<Team> {
private async lockTeam(
manager: EntityManager,
teamId: number,
): Promise<Team> {
const team = await manager
.getRepository(Team)
.createQueryBuilder('team')

View File

@@ -17,7 +17,7 @@
<input matInput type="number" min="0.01" max="10000" step="0.01" formControlName="amount" />
<span matTextSuffix></span>
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
<button mat-flat-button type="submit" [disabled]="form.invalid || mutationPending()">
@if (saving()) {
<mat-spinner diameter="18" />
} @else {
@@ -128,7 +128,7 @@
mat-button
type="button"
(click)="startEdit(penalty)"
[disabled]="pendingPenaltyId() !== null"
[disabled]="mutationPending()"
[attr.aria-label]="penalty.description + ' bearbeiten'"
>
<mat-icon>edit</mat-icon>Bearbeiten
@@ -137,7 +137,7 @@
mat-button
type="button"
(click)="confirmDelete(penalty)"
[disabled]="pendingPenaltyId() !== null"
[disabled]="mutationPending()"
[attr.aria-label]="penalty.description + ' löschen'"
>
<mat-icon>delete</mat-icon>Löschen

View File

@@ -190,6 +190,70 @@ describe('Penalties', () => {
expect(text()).toContain('existiert bereits');
});
it('distinguishes a successful update from a failed authoritative reload', () => {
loadPenalties
.mockReturnValueOnce(of([first]))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })));
create();
button('Bearbeiten').click();
fixture.componentInstance['editForm'].setValue({ description: 'Neu', amount: 6 });
fixture.componentInstance['savePenalty'](first);
fixture.detectChanges();
expect(fixture.componentInstance['editingPenaltyId']()).toBeNull();
expect(fixture.componentInstance['mutationError']()).toBeNull();
expect(text()).toContain('Änderung wurde gespeichert');
expect(text()).toContain('Erneut versuchen');
});
it('clears the create form after success even when the reload fails', () => {
loadPenalties
.mockReturnValueOnce(of([first]))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })));
create();
fixture.componentInstance['form'].setValue({ description: 'Handy', amount: 3 });
fixture.componentInstance['createPenalty']();
fixture.detectChanges();
expect(fixture.componentInstance['form'].getRawValue()).toEqual({
description: '',
amount: 0,
});
expect(fixture.componentInstance['mutationError']()).toBeNull();
expect(text()).toContain('Änderung wurde gespeichert');
});
it('reports a reload problem instead of a delete failure after successful deletion', () => {
loadPenalties
.mockReturnValueOnce(of([first]))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })));
create();
button('Löschen').click();
dialogClosed.next(true);
fixture.detectChanges();
expect(deletePenalty).toHaveBeenCalledWith(first.id);
expect(fixture.componentInstance['mutationError']()).toBeNull();
expect(text()).toContain('Änderung wurde gespeichert');
});
it('prevents overlapping catalog mutations', () => {
const creation = new Subject<Penalty>();
createPenalty.mockReturnValue(creation);
create();
fixture.componentInstance['form'].setValue({ description: 'Handy', amount: 3 });
fixture.componentInstance['createPenalty']();
fixture.componentInstance['startEdit'](first);
fixture.componentInstance['confirmDelete'](first);
expect(fixture.componentInstance['editingPenaltyId']()).toBeNull();
expect(dialog.open).not.toHaveBeenCalled();
});
it('shows a load error, retries, and distinguishes an empty search result', () => {
loadPenalties
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })))

View File

@@ -12,7 +12,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { RouterLink } from '@angular/router';
import { finalize, switchMap, take } from 'rxjs';
import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs';
import { AuthStore } from '../../../../core/auth/auth-store';
import { PenaltyApi } from '../../../../core/team/penalty-api';
import { TeamStore } from '../../../../core/team/team-store';
@@ -55,6 +55,9 @@ export class Penalties {
protected readonly editingPenaltyId = signal<number | null>(null);
protected readonly loadError = signal<string | null>(null);
protected readonly mutationError = signal<string | null>(null);
protected readonly mutationPending = computed(
() => this.saving() || this.pendingPenaltyId() !== null,
);
protected readonly search = signal('');
protected readonly form = this.formBuilder.nonNullable.group({
description: ['', [Validators.required, Validators.maxLength(120), Validators.pattern(/\S/)]],
@@ -109,20 +112,20 @@ export class Penalties {
protected createPenalty(): void {
const team = this.team();
if (!this.canManage() || !team || this.form.invalid || this.saving()) return;
if (!this.canManage() || !team || this.form.invalid || this.mutationPending()) return;
this.saving.set(true);
this.mutationError.set(null);
this.penaltyApi
.createPenalty({ teamId: team.id, ...this.form.getRawValue() })
.pipe(
switchMap(() => this.penaltyApi.loadPenalties(team.id)),
tap(() => this.form.reset({ description: '', amount: 0 })),
switchMap(() => this.reloadAfterMutation(team.id)),
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (penalties) => {
this.penalties.set(penalties);
this.form.reset({ description: '', amount: 0 });
},
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht angelegt werden.')),
@@ -130,7 +133,7 @@ export class Penalties {
}
protected startEdit(penalty: Penalty): void {
if (!this.canManage() || this.pendingPenaltyId() !== null) return;
if (!this.canManage() || this.mutationPending()) return;
this.editingPenaltyId.set(penalty.id);
this.editForm.setValue({ description: penalty.description, amount: penalty.amount });
this.mutationError.set(null);
@@ -149,7 +152,7 @@ export class Penalties {
!teamId ||
this.editingPenaltyId() !== penalty.id ||
this.editForm.invalid ||
this.pendingPenaltyId() !== null
this.mutationPending()
) {
return;
}
@@ -158,14 +161,14 @@ export class Penalties {
this.penaltyApi
.updatePenalty(penalty.id, this.editForm.getRawValue())
.pipe(
switchMap(() => this.penaltyApi.loadPenalties(teamId)),
tap(() => this.editingPenaltyId.set(null)),
switchMap(() => this.reloadAfterMutation(teamId)),
finalize(() => this.pendingPenaltyId.set(null)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (penalties) => {
this.penalties.set(penalties);
this.editingPenaltyId.set(null);
},
error: (error: HttpErrorResponse) =>
this.mutationError.set(
@@ -175,7 +178,7 @@ export class Penalties {
}
protected confirmDelete(penalty: Penalty): void {
if (!this.canManage() || this.pendingPenaltyId() !== null) return;
if (!this.canManage() || this.mutationPending()) return;
this.dialog
.open(ConfirmDialog, {
data: {
@@ -205,7 +208,7 @@ export class Penalties {
this.penaltyApi
.deletePenalty(penalty.id)
.pipe(
switchMap(() => this.penaltyApi.loadPenalties(teamId)),
switchMap(() => this.reloadAfterMutation(teamId)),
finalize(() => this.pendingPenaltyId.set(null)),
takeUntilDestroyed(this.destroyRef),
)
@@ -219,6 +222,17 @@ export class Penalties {
});
}
private reloadAfterMutation(teamId: number): Observable<Penalty[]> {
return this.penaltyApi.loadPenalties(teamId).pipe(
catchError(() => {
this.loadError.set(
'Änderung wurde gespeichert, aber der Katalog konnte nicht aktualisiert werden.',
);
return EMPTY;
}),
);
}
private load(teamId: number): void {
this.loading.set(true);
this.loadError.set(null);