Adds a catalog picker to the member-booking form in the cashbox (prefills amount/note/type, stays editable) and a "Buchen" button on each penalty catalog entry that jumps to the cashbox with that entry preselected via a penaltyId query param. No backend changes — reuses the existing POST /transactions flow, the catalog only supplies starting values. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
218 lines
6.5 KiB
TypeScript
218 lines
6.5 KiB
TypeScript
import { signal } from '@angular/core';
|
|
import { TestBed } from '@angular/core/testing';
|
|
import { of } from 'rxjs';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
|
import { AuthStore } from '../../../core/auth/auth-store';
|
|
import { PenaltyApi } from '../../../core/team/penalty-api';
|
|
import { TeamStore } from '../../../core/team/team-store';
|
|
import { TransactionsApi } from '../../../core/team/transactions-api';
|
|
import { Cashbox } from './cashbox';
|
|
|
|
describe('Cashbox', () => {
|
|
const team = {
|
|
id: 5,
|
|
name: 'Team A',
|
|
alias: 'team-a',
|
|
balance: 120,
|
|
players: [
|
|
{
|
|
id: 1,
|
|
firstName: 'Alex',
|
|
lastName: 'Muster',
|
|
balance: -12,
|
|
active: true,
|
|
teamRole: { id: 2, name: 'scnd_treasurer' },
|
|
user: { id: 42, email: 'alex@example.com', firstName: 'Alex', lastName: 'Muster' },
|
|
},
|
|
{
|
|
id: 2,
|
|
firstName: 'Bea',
|
|
lastName: 'Test',
|
|
balance: 5,
|
|
active: true,
|
|
teamRole: { id: 1, name: 'player' },
|
|
},
|
|
{
|
|
id: 3,
|
|
firstName: 'Chris',
|
|
lastName: 'Drittel',
|
|
balance: 0,
|
|
active: true,
|
|
teamRole: { id: 1, name: 'player' },
|
|
},
|
|
],
|
|
};
|
|
|
|
const activities = [
|
|
{
|
|
id: 9,
|
|
date: '2026-07-31T10:00:00.000Z',
|
|
amount: 12,
|
|
type: 'fine',
|
|
note: 'Training',
|
|
playerName: 'Bea Test',
|
|
isTeamWalletTransaction: false,
|
|
},
|
|
];
|
|
|
|
const penalties = [
|
|
{ id: 101, description: 'Zu spät zum Training', amount: 5 },
|
|
{ id: 102, description: 'Handy vergessen', amount: 2.5 },
|
|
];
|
|
|
|
async function setup(roleId = 2, confirm = true, penaltyIdParam: string | null = null) {
|
|
const createPlayerTransactions = vi.fn(() => of([]));
|
|
const createTeamWalletTransaction = vi.fn(() => of(activities[0]));
|
|
const reverseTransaction = vi.fn(() => of(activities[0]));
|
|
const loadTeamTransactions = vi.fn(() => of(activities));
|
|
const loadPenalties = vi.fn(() => of(penalties));
|
|
const refreshTeam = vi.fn();
|
|
const dialog = {
|
|
open: vi.fn(() => ({ afterClosed: () => of(confirm) })),
|
|
};
|
|
|
|
await TestBed.configureTestingModule({
|
|
imports: [Cashbox],
|
|
providers: [
|
|
{
|
|
provide: AuthStore,
|
|
useValue: {
|
|
currentUser: signal({
|
|
id: 42,
|
|
email: 'alex@example.com',
|
|
firstName: 'Alex',
|
|
lastName: 'Muster',
|
|
role: { id: roleId === 99 ? 1 : 2 },
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
provide: TeamStore,
|
|
useValue: { team: signal(team), loading: signal(false), refreshTeam },
|
|
},
|
|
{
|
|
provide: TransactionsApi,
|
|
useValue: {
|
|
loadTeamTransactions,
|
|
createPlayerTransactions,
|
|
createTeamWalletTransaction,
|
|
reverseTransaction,
|
|
},
|
|
},
|
|
{ provide: PenaltyApi, useValue: { loadPenalties } },
|
|
{
|
|
provide: ActivatedRoute,
|
|
useValue: {
|
|
snapshot: {
|
|
queryParamMap: convertToParamMap(
|
|
penaltyIdParam ? { penaltyId: penaltyIdParam } : {},
|
|
),
|
|
},
|
|
},
|
|
},
|
|
{ provide: MatDialog, useValue: dialog },
|
|
],
|
|
}).compileComponents();
|
|
|
|
if (roleId === 1) {
|
|
team.players[0].teamRole.id = 1;
|
|
} else {
|
|
team.players[0].teamRole.id = 2;
|
|
}
|
|
|
|
const fixture = TestBed.createComponent(Cashbox);
|
|
fixture.detectChanges();
|
|
return {
|
|
fixture,
|
|
component: fixture.componentInstance,
|
|
createPlayerTransactions,
|
|
reverseTransaction,
|
|
dialog,
|
|
refreshTeam,
|
|
};
|
|
}
|
|
|
|
it('shows the activity feed and booking controls to a treasurer', async () => {
|
|
const { fixture } = await setup();
|
|
|
|
expect(fixture.nativeElement.textContent).toContain('Bea Test');
|
|
expect(fixture.nativeElement.textContent).toContain('-12,00');
|
|
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).not.toBeNull();
|
|
});
|
|
|
|
it('submits cent-preserving split transactions for selected players', async () => {
|
|
const { component, createPlayerTransactions, refreshTeam } = await setup();
|
|
component['playerForm'].setValue({
|
|
playerIds: [1, 2, 3],
|
|
amount: 10,
|
|
type: 11,
|
|
note: 'Training',
|
|
date: '2026-07-31',
|
|
total: true,
|
|
});
|
|
|
|
component['submitPlayerBooking']();
|
|
|
|
expect(createPlayerTransactions).toHaveBeenCalledWith([
|
|
expect.objectContaining({ playerId: 1, amount: 3.34, type: 11 }),
|
|
expect.objectContaining({ playerId: 2, amount: 3.33, type: 11 }),
|
|
expect.objectContaining({ playerId: 3, amount: 3.33, type: 11 }),
|
|
]);
|
|
expect(refreshTeam).toHaveBeenCalled();
|
|
});
|
|
|
|
it('asks for confirmation before booking a high amount', async () => {
|
|
const { component, createPlayerTransactions, dialog } = await setup(2, false);
|
|
component['playerForm'].setValue({
|
|
playerIds: [1],
|
|
amount: 300,
|
|
type: 11,
|
|
note: '',
|
|
date: '2026-07-31',
|
|
total: false,
|
|
});
|
|
|
|
component['submitPlayerBooking']();
|
|
|
|
expect(dialog.open).toHaveBeenCalled();
|
|
expect(createPlayerTransactions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not show mutation controls to a regular member', async () => {
|
|
const { fixture } = await setup(1);
|
|
|
|
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).toBeNull();
|
|
expect(fixture.nativeElement.querySelector('[data-testid="reverse-booking"]')).toBeNull();
|
|
});
|
|
|
|
it('prefills amount, note and type from a manually selected catalog entry, and stays editable', async () => {
|
|
const { component } = await setup();
|
|
|
|
component['onPenaltySelect'](102);
|
|
|
|
expect(component['playerForm'].getRawValue()).toEqual(
|
|
expect.objectContaining({ amount: 2.5, note: 'Handy vergessen', type: 11 }),
|
|
);
|
|
|
|
component['playerForm'].patchValue({ amount: 7 });
|
|
expect(component['playerForm'].getRawValue().amount).toBe(7);
|
|
});
|
|
|
|
it('prefills the booking form automatically from a penaltyId query param', async () => {
|
|
const { component } = await setup(2, true, '101');
|
|
|
|
expect(component['playerForm'].getRawValue()).toEqual(
|
|
expect.objectContaining({ amount: 5, note: 'Zu spät zum Training', type: 11 }),
|
|
);
|
|
});
|
|
|
|
it('ignores an unknown penaltyId query param without error', async () => {
|
|
const { component } = await setup(2, true, '999');
|
|
|
|
expect(component['playerForm'].getRawValue()).toEqual(
|
|
expect.objectContaining({ amount: 0, note: '', type: 11 }),
|
|
);
|
|
});
|
|
});
|