feat(teams): manage player active status and team-role with treasurer safeguard
Team managers (captain and above) can now deactivate/reactivate a player and change their team-role from the player detail page. Deactivation zeroes the open balance via an auditable adjustment transaction instead of overwriting the balance field, and both actions are blocked if they would leave a team without an active treasurer. Also hardens the existing PUT teams/:id/players endpoint down to profile-only fields, fixing a typo bug and closing a gap where any authenticated user could mutate a player's active/role/balance in any team. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
221
myteamwallet_backend/src/teams/team-members.service.spec.ts
Normal file
221
myteamwallet_backend/src/teams/team-members.service.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
|
||||
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
|
||||
import {
|
||||
DEACTIVATION_ADJUSTMENT_NOTE_PREFIX,
|
||||
TeamMembersService,
|
||||
} from './team-members.service';
|
||||
|
||||
describe('TeamMembersService', () => {
|
||||
const teamId = 10;
|
||||
let player: any;
|
||||
let treasurers: any[];
|
||||
let treasurerLockQuery: any;
|
||||
let lockedPlayerQuery: any;
|
||||
let playerRepository: any;
|
||||
let transactionRepository: any;
|
||||
let manager: any;
|
||||
let dataSource: any;
|
||||
let logger: any;
|
||||
let access: any;
|
||||
let service: TeamMembersService;
|
||||
|
||||
beforeEach(() => {
|
||||
player = makePlayer(101, true, TeamRolesEnum.treasurer, 0);
|
||||
treasurers = [player];
|
||||
treasurerLockQuery = chain({ getMany: jest.fn(() => treasurers) });
|
||||
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
|
||||
playerRepository = {
|
||||
createQueryBuilder: jest.fn((alias: string) =>
|
||||
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
|
||||
),
|
||||
save: jest.fn((value) => Promise.resolve(value)),
|
||||
findOne: jest.fn(() => player),
|
||||
};
|
||||
transactionRepository = {
|
||||
insert: jest.fn(() => Promise.resolve({ identifiers: [{ id: 999 }] })),
|
||||
find: jest.fn(() => Promise.resolve([])),
|
||||
};
|
||||
manager = {
|
||||
getRepository: jest.fn((entity) =>
|
||||
entity.name === 'Player' ? playerRepository : transactionRepository,
|
||||
),
|
||||
};
|
||||
dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||
logger = { info: jest.fn() };
|
||||
access = { assertManager: jest.fn(() => Promise.resolve()) };
|
||||
service = new TeamMembersService(dataSource, logger, access as any);
|
||||
});
|
||||
|
||||
it('checks the team-manager permission before touching the database', async () => {
|
||||
access.assertManager.mockRejectedValue(new Error('forbidden'));
|
||||
await expect(service.setActive(5, teamId, player.id, false)).rejects.toThrow(
|
||||
'forbidden',
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deactivating the last active treasurer and writes nothing', async () => {
|
||||
player.balance = 42;
|
||||
treasurers = [player];
|
||||
|
||||
await expect(
|
||||
service.setActive(5, teamId, player.id, false),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
|
||||
expect(treasurerLockQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(transactionRepository.insert).not.toHaveBeenCalled();
|
||||
expect(playerRepository.save).not.toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows deactivation when another active treasurer remains, and zeroes the balance via a credit transaction', async () => {
|
||||
player.balance = 42;
|
||||
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
|
||||
|
||||
const result = await service.setActive(5, teamId, player.id, false);
|
||||
|
||||
expect(transactionRepository.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
amount: -42,
|
||||
note: expect.stringContaining(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
|
||||
type: { id: TransactionTypeEnum.credit },
|
||||
}),
|
||||
);
|
||||
expect(playerRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ active: false, balance: 0 }),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'player_active_update',
|
||||
details: `teamId=${teamId} playerId=${player.id} active=false`,
|
||||
userId: 5,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
|
||||
it('deactivating a non-treasurer with zero balance never inserts an adjustment transaction', async () => {
|
||||
player = makePlayer(101, true, TeamRolesEnum.player, 0);
|
||||
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
|
||||
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
|
||||
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
|
||||
);
|
||||
treasurers = [];
|
||||
|
||||
await expect(
|
||||
service.setActive(5, teamId, player.id, false),
|
||||
).resolves.toBeDefined();
|
||||
expect(transactionRepository.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recomputes balance on reactivation, excluding deactivation adjustment rows across multiple cycles', async () => {
|
||||
player = makePlayer(101, false, TeamRolesEnum.player, 0);
|
||||
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
|
||||
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
|
||||
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
|
||||
);
|
||||
transactionRepository.find.mockResolvedValue([
|
||||
{ amount: 50, type: { id: TransactionTypeEnum.credit }, note: 'Zahlung' },
|
||||
{ amount: -20, note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #101` },
|
||||
{ amount: 10, type: { id: TransactionTypeEnum.credit }, note: 'Zahlung 2' },
|
||||
{ amount: -40, note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #101` },
|
||||
{ amount: 15, type: { id: 11 }, note: 'Strafe' },
|
||||
]);
|
||||
|
||||
const result = await service.setActive(5, teamId, player.id, true);
|
||||
|
||||
// erwarteter Saldo: 50 + 10 - 15 = 45 (beide Ausgleichsbuchungen ausgeschlossen)
|
||||
expect(playerRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ active: true, balance: 45 }),
|
||||
);
|
||||
expect(result.balance).toBe(45);
|
||||
});
|
||||
|
||||
it('no-ops when the requested active state already matches (idempotent, no writes)', async () => {
|
||||
player = makePlayer(101, true, TeamRolesEnum.player, 0);
|
||||
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
|
||||
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
|
||||
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
|
||||
);
|
||||
|
||||
await service.setActive(5, teamId, player.id, true);
|
||||
|
||||
expect(playerRepository.save).not.toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects changing team-role away from treasurer for the last active treasurer', async () => {
|
||||
treasurers = [player];
|
||||
|
||||
await expect(
|
||||
service.setTeamRole(5, teamId, player.id, TeamRolesEnum.captain),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(playerRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a team-role change when another active treasurer remains, and never touches balance/transactions', async () => {
|
||||
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
|
||||
|
||||
const result = await service.setTeamRole(
|
||||
5,
|
||||
teamId,
|
||||
player.id,
|
||||
TeamRolesEnum.captain,
|
||||
);
|
||||
|
||||
expect(transactionRepository.insert).not.toHaveBeenCalled();
|
||||
expect(playerRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ teamRole: { id: TeamRolesEnum.captain } }),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'player_team_role_update',
|
||||
details: `teamId=${teamId} playerId=${player.id} teamRoleId=${TeamRolesEnum.captain}`,
|
||||
userId: 5,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
expect(result.teamRole).toEqual({ id: TeamRolesEnum.captain });
|
||||
});
|
||||
|
||||
it('throws NotFoundException when the locked player does not belong to the given team', async () => {
|
||||
player.team = { id: 999 };
|
||||
|
||||
await expect(
|
||||
service.setActive(5, teamId, player.id, false),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
function makePlayer(
|
||||
id: number,
|
||||
active: boolean,
|
||||
teamRoleId: number,
|
||||
balance: number,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
active,
|
||||
balance,
|
||||
team: { id: teamId },
|
||||
teamRole: { id: teamRoleId },
|
||||
};
|
||||
}
|
||||
|
||||
function chain(overrides: Record<string, jest.Mock>) {
|
||||
const query: Record<string, jest.Mock> = {};
|
||||
[
|
||||
'innerJoin',
|
||||
'innerJoinAndSelect',
|
||||
'leftJoinAndSelect',
|
||||
'where',
|
||||
'andWhere',
|
||||
'setLock',
|
||||
'orderBy',
|
||||
].forEach((method) => (query[method] = jest.fn(() => query)));
|
||||
return Object.assign(query, overrides);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user