Files
teamwallet/myteamwallet_backend/src/teams/team-members.service.spec.ts

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);
}
});