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) { const query: Record = {}; [ 'innerJoin', 'innerJoinAndSelect', 'leftJoinAndSelect', 'where', 'andWhere', 'setLock', 'orderBy', ].forEach((method) => (query[method] = jest.fn(() => query))); return Object.assign(query, overrides); } });