271 lines
9.3 KiB
TypeScript
271 lines
9.3 KiB
TypeScript
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 eventEmitter: 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 = { assertAtLeast: jest.fn(() => Promise.resolve()) };
|
|
eventEmitter = { emit: jest.fn() };
|
|
service = new TeamMembersService(dataSource, logger, access as any, eventEmitter as any);
|
|
});
|
|
|
|
it('checks the team-manager permission before touching the database', async () => {
|
|
access.assertAtLeast.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);
|
|
});
|
|
|
|
it('emits a player-active-changed event after a real deactivation', async () => {
|
|
player.balance = 42;
|
|
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
|
|
|
|
await service.setActive(5, teamId, player.id, false);
|
|
|
|
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
|
'notifications.player.active_changed',
|
|
expect.objectContaining({
|
|
teamId,
|
|
actorUserId: 5,
|
|
playerId: player.id,
|
|
playerName: 'Pat Player',
|
|
active: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not emit when the active state is unchanged (idempotent)', 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(eventEmitter.emit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('emits a player-role-changed event after a real role change', async () => {
|
|
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
|
|
|
|
await service.setTeamRole(5, teamId, player.id, TeamRolesEnum.captain);
|
|
|
|
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
|
'notifications.player.role_changed',
|
|
expect.objectContaining({
|
|
teamId,
|
|
actorUserId: 5,
|
|
playerId: player.id,
|
|
playerName: 'Pat Player',
|
|
teamRoleId: TeamRolesEnum.captain,
|
|
}),
|
|
);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|