diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index 38182d2..bfd1002 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -20,6 +20,9 @@ export type LOGEVENT = | 'admin_user_status_update' | 'admin_player_assign' | 'admin_player_unlink' + | 'penalty_catalog_create' + | 'penalty_catalog_update' + | 'penalty_catalog_delete' | 'team_create'; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE'; diff --git a/myteamwallet_backend/src/penalty/dto/create-penalty.dto.ts b/myteamwallet_backend/src/penalty/dto/create-penalty.dto.ts index cad0955..8f230df 100644 --- a/myteamwallet_backend/src/penalty/dto/create-penalty.dto.ts +++ b/myteamwallet_backend/src/penalty/dto/create-penalty.dto.ts @@ -1,19 +1,31 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; -import { Team } from 'src/teams/entities/team.entity'; +import { Transform } from 'class-transformer'; +import { + IsInt, + IsNumber, + IsString, + Length, + Max, + Min, +} from 'class-validator'; export class CreatePenaltyDTO { @ApiProperty({ example: 2342 }) - @IsNotEmpty() + @IsInt() + @Min(1) teamId: number; @ApiProperty({ example: 1 }) - @IsNotEmpty() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + @Max(10000) amount: number; - team?: Team; - @ApiProperty({ example: 'Zu spät kommen' }) - @IsNotEmpty() + @Transform(({ value }) => + typeof value === 'string' ? value.trim() : value, + ) + @IsString() + @Length(1, 120) description: string; } diff --git a/myteamwallet_backend/src/penalty/dto/penalty-response.dto.ts b/myteamwallet_backend/src/penalty/dto/penalty-response.dto.ts new file mode 100644 index 0000000..618aab5 --- /dev/null +++ b/myteamwallet_backend/src/penalty/dto/penalty-response.dto.ts @@ -0,0 +1,6 @@ +export class PenaltyResponseDTO { + id: number; + description: string; + amount: number; + createdAt: Date; +} diff --git a/myteamwallet_backend/src/penalty/dto/update-penalty.dto.ts b/myteamwallet_backend/src/penalty/dto/update-penalty.dto.ts new file mode 100644 index 0000000..4d096ff --- /dev/null +++ b/myteamwallet_backend/src/penalty/dto/update-penalty.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, IsString, Length, Max, Min } from 'class-validator'; + +export class UpdatePenaltyDTO { + @ApiProperty({ example: 'Zu spät kommen' }) + @Transform(({ value }) => + typeof value === 'string' ? value.trim() : value, + ) + @IsString() + @Length(1, 120) + description: string; + + @ApiProperty({ example: 5 }) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + @Max(10000) + amount: number; +} diff --git a/myteamwallet_backend/src/penalty/penalty.controller.ts b/myteamwallet_backend/src/penalty/penalty.controller.ts index 553bca6..c08c509 100644 --- a/myteamwallet_backend/src/penalty/penalty.controller.ts +++ b/myteamwallet_backend/src/penalty/penalty.controller.ts @@ -1,45 +1,62 @@ import { Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Param, + ParseIntPipe, + Patch, Post, - Req, + Request, UseGuards, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth } from '@nestjs/swagger'; -import { Roles } from 'src/roles/roles.decorator'; -import { RolesGuard } from 'src/roles/roles.guard'; import { CreatePenaltyDTO } from './dto/create-penalty.dto'; +import { UpdatePenaltyDTO } from './dto/update-penalty.dto'; import { PenaltyService } from './penalty.service'; +type AuthenticatedRequest = { user: { id: number } }; + @ApiBearerAuth() -@Controller({ - path: 'penalty', - version: '1', -}) +@UseGuards(AuthGuard('jwt')) +@Controller({ path: 'penalty', version: '1' }) export class PenaltyController { - constructor(private service: PenaltyService) {} + constructor(private readonly service: PenaltyService) {} - @Roles([]) - @Get() - getIt(@Req() req: any) { - const userId = req.user?.id; - return this.service.getAll(userId); + @Get(':teamId') + getTeamPenalties( + @Request() request: AuthenticatedRequest, + @Param('teamId', ParseIntPipe) teamId: number, + ) { + return this.service.getTeamPenalties(request.user.id, teamId); } - @Roles([]) - @UseGuards(AuthGuard('jwt'), RolesGuard) - @Get(':id') - getTeams(@Param('id') teamId: string) { - return this.service.getTeamPenalties(teamId); - } - - @UseGuards(AuthGuard('jwt'), RolesGuard) @Post() - createPenalty(@Req() req: any, @Body() createPenaltyDto: CreatePenaltyDTO) { - const userId = req.user?.id; - return this.service.createPenalty(createPenaltyDto, userId); + createPenalty( + @Request() request: AuthenticatedRequest, + @Body() dto: CreatePenaltyDTO, + ) { + return this.service.createPenalty(dto, request.user.id); + } + + @Patch(':penaltyId') + updatePenalty( + @Request() request: AuthenticatedRequest, + @Param('penaltyId', ParseIntPipe) penaltyId: number, + @Body() dto: UpdatePenaltyDTO, + ) { + return this.service.updatePenalty(penaltyId, dto, request.user.id); + } + + @Delete(':penaltyId') + @HttpCode(HttpStatus.NO_CONTENT) + async deletePenalty( + @Request() request: AuthenticatedRequest, + @Param('penaltyId', ParseIntPipe) penaltyId: number, + ): Promise { + await this.service.deletePenalty(penaltyId, request.user.id); } } diff --git a/myteamwallet_backend/src/penalty/penalty.http.spec.ts b/myteamwallet_backend/src/penalty/penalty.http.spec.ts new file mode 100644 index 0000000..92afa36 --- /dev/null +++ b/myteamwallet_backend/src/penalty/penalty.http.spec.ts @@ -0,0 +1,116 @@ +import { + INestApplication, + UnauthorizedException, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import validationOptions from '../utils/validation-options'; +import { PenaltyController } from './penalty.controller'; +import { PenaltyService } from './penalty.service'; + +describe('penalty catalog HTTP boundary', () => { + let app: INestApplication; + const penalty = { + id: 8, + description: 'Zu spät', + amount: 5, + createdAt: '2026-01-02T00:00:00.000Z', + }; + const service = { + getTeamPenalties: jest.fn(() => [penalty]), + createPenalty: jest.fn(() => penalty), + updatePenalty: jest.fn(() => penalty), + deletePenalty: jest.fn(), + }; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + controllers: [PenaltyController], + providers: [{ provide: PenaltyService, useValue: service }], + }) + .overrideGuard(AuthGuard('jwt')) + .useValue({ + canActivate(context) { + const httpRequest = context.switchToHttp().getRequest(); + if (httpRequest.headers.authorization !== 'Bearer user') { + throw new UnauthorizedException(); + } + httpRequest.user = { id: 42, role: { id: 2 } }; + return true; + }, + }) + .compile(); + app = module.createNestApplication(); + app.setGlobalPrefix('api'); + app.enableVersioning({ type: VersioningType.URI }); + app.useGlobalPipes(new ValidationPipe(validationOptions)); + await app.init(); + }); + + afterAll(() => app.close()); + beforeEach(() => jest.clearAllMocks()); + + it('requires authentication and passes the actor to a team read', async () => { + await request(app.getHttpServer()).get('/api/v1/penalty/5').expect(401); + await request(app.getHttpServer()) + .get('/api/v1/penalty/5') + .set('Authorization', 'Bearer user') + .expect(200, [penalty]); + expect(service.getTeamPenalties).toHaveBeenCalledWith(42, 5); + }); + + it('validates and strips create fields before invoking the service', async () => { + await request(app.getHttpServer()) + .post('/api/v1/penalty') + .set('Authorization', 'Bearer user') + .send({ + teamId: 5, + description: ' Zu spät ', + amount: 5.25, + team: { id: 99 }, + }) + .expect(201, penalty); + expect(service.createPenalty).toHaveBeenCalledWith( + { teamId: 5, description: 'Zu spät', amount: 5.25 }, + 42, + ); + }); + + it.each([ + [{ teamId: 5, description: '', amount: 5 }], + [{ teamId: 5, description: 'x'.repeat(121), amount: 5 }], + [{ teamId: 5, description: 'Test', amount: 0 }], + [{ teamId: 5, description: 'Test', amount: 10000.01 }], + [{ teamId: 5, description: 'Test', amount: 1.001 }], + [{ teamId: true, description: 'Test', amount: 1 }], + ])('rejects invalid create input: %p', async (body) => { + await request(app.getHttpServer()) + .post('/api/v1/penalty') + .set('Authorization', 'Bearer user') + .send(body) + .expect(422); + expect(service.createPenalty).not.toHaveBeenCalled(); + }); + + it('exposes update and delete with numeric IDs and safe payloads', async () => { + await request(app.getHttpServer()) + .patch('/api/v1/penalty/8') + .set('Authorization', 'Bearer user') + .send({ description: ' Neu ', amount: 2 }) + .expect(200, penalty); + expect(service.updatePenalty).toHaveBeenCalledWith( + 8, + { description: 'Neu', amount: 2 }, + 42, + ); + + await request(app.getHttpServer()) + .delete('/api/v1/penalty/8') + .set('Authorization', 'Bearer user') + .expect(204); + expect(service.deletePenalty).toHaveBeenCalledWith(8, 42); + }); +}); diff --git a/myteamwallet_backend/src/penalty/penalty.module.ts b/myteamwallet_backend/src/penalty/penalty.module.ts index a3208f9..847720d 100644 --- a/myteamwallet_backend/src/penalty/penalty.module.ts +++ b/myteamwallet_backend/src/penalty/penalty.module.ts @@ -1,29 +1,18 @@ import { Module } from '@nestjs/common'; -import { PenaltyController } from './penalty.controller'; -import { PenaltyService } from './penalty.service'; -import { LoggingModule } from 'src/database/logging/logging.module'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { TeamWalletTransactionType } from 'src/team-wallet-transactions/entities/team-wallet-transaction-type.entity'; -import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity'; +import { LoggingModule } from 'src/database/logging/logging.module'; import { Team } from 'src/teams/entities/team.entity'; -import { User } from 'src/users/entities/user.entity'; -import { Role } from 'src/roles/entities/role.entity'; +import { TeamsModule } from 'src/teams/teams.module'; +import { PenaltyController } from './penalty.controller'; import { PenaltyEntity } from './entities/penalty.entity'; -import { Player } from 'src/players/entities/player.entity'; +import { PenaltyService } from './penalty.service'; @Module({ controllers: [PenaltyController], providers: [PenaltyService], imports: [ - TypeOrmModule.forFeature([ - User, - Role, - TeamWalletTransaction, - TeamWalletTransactionType, - Team, - PenaltyEntity, - Player, - ]), + TypeOrmModule.forFeature([Team, PenaltyEntity]), + TeamsModule, LoggingModule, ], }) diff --git a/myteamwallet_backend/src/penalty/penalty.service.spec.ts b/myteamwallet_backend/src/penalty/penalty.service.spec.ts new file mode 100644 index 0000000..5e46b28 --- /dev/null +++ b/myteamwallet_backend/src/penalty/penalty.service.spec.ts @@ -0,0 +1,203 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { PenaltyService } from './penalty.service'; + +describe('PenaltyService catalog management', () => { + const readRepository = { find: jest.fn(), findOne: jest.fn() }; + const teamReadRepository = { findOne: jest.fn() }; + const teamQuery = { + where: jest.fn().mockReturnThis(), + setLock: jest.fn().mockReturnThis(), + getOne: jest.fn(), + }; + const duplicateQuery = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn(), + }; + const teamRepository = { createQueryBuilder: jest.fn(() => teamQuery) }; + const writeRepository = { + createQueryBuilder: jest.fn(() => duplicateQuery), + create: jest.fn((value) => value), + save: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + const manager = { + getRepository: jest.fn((entity) => + entity.name === 'Team' ? teamRepository : writeRepository, + ), + }; + const dataSource = { transaction: jest.fn((work) => work(manager)) }; + const access = { assertMember: jest.fn(), assertManager: jest.fn() }; + const logger = { info: jest.fn() }; + let service: PenaltyService; + + beforeEach(() => { + jest.clearAllMocks(); + teamQuery.getOne.mockResolvedValue({ id: 5 }); + duplicateQuery.getCount.mockResolvedValue(0); + teamReadRepository.findOne.mockResolvedValue({ id: 5 }); + service = new PenaltyService( + readRepository as any, + teamReadRepository as any, + dataSource as any, + access as any, + logger as any, + ); + }); + + it('returns not found instead of leaking an unknown team as an empty catalog', async () => { + teamReadRepository.findOne.mockResolvedValue(null); + + await expect(service.getTeamPenalties(42, 999)).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(access.assertMember).not.toHaveBeenCalled(); + }); + + it('authorizes team reads, sorts them, and maps only safe fields', async () => { + readRepository.find.mockResolvedValue([ + { + id: 8, + description: 'Zu spät', + amount: '5.50', + createdAt: new Date('2026-01-02T00:00:00.000Z'), + team: { id: 5, secret: 'hidden' }, + }, + ]); + + await expect(service.getTeamPenalties(42, 5)).resolves.toEqual([ + { + id: 8, + description: 'Zu spät', + amount: 5.5, + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }, + ]); + expect(access.assertMember).toHaveBeenCalledWith(42, 5); + expect(readRepository.find).toHaveBeenCalledWith({ + where: { team: { id: 5 } }, + order: { description: 'ASC' }, + }); + }); + + it('creates a normalized entry under a team lock and audits in the transaction', async () => { + writeRepository.save.mockImplementation(async (value) => ({ + id: 9, + createdAt: new Date('2026-01-03T00:00:00.000Z'), + ...value, + })); + + await expect( + service.createPenalty( + { teamId: 5, description: ' Handy in der Kabine ', amount: 3.25 }, + 42, + ), + ).resolves.toMatchObject({ + id: 9, + description: 'Handy in der Kabine', + amount: 3.25, + }); + + expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write'); + expect(access.assertManager).toHaveBeenCalledWith(42, 5, manager); + expect(logger.info).toHaveBeenCalledWith( + { + event: 'penalty_catalog_create', + details: 'teamId=5 penaltyId=9 action=create', + userId: 42, + }, + manager, + ); + }); + + it('rejects a normalized duplicate before writing or auditing', async () => { + duplicateQuery.getCount.mockResolvedValue(1); + + await expect( + service.createPenalty( + { teamId: 5, description: ' ZU SPÄT ', amount: 5 }, + 42, + ), + ).rejects.toBeInstanceOf(ConflictException); + expect(writeRepository.save).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it('updates an entry in its owning team and excludes itself from duplicate detection', async () => { + readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } }); + writeRepository.findOne.mockResolvedValue({ + id: 8, + team: { id: 5 }, + description: 'Alt', + amount: 1, + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }); + writeRepository.save.mockImplementation(async (value) => value); + + await expect( + service.updatePenalty( + 8, + { description: ' Neu ', amount: 2.5 }, + 42, + ), + ).resolves.toMatchObject({ id: 8, description: 'Neu', amount: 2.5 }); + expect(duplicateQuery.andWhere).toHaveBeenCalledWith( + 'penalty.id != :penaltyId', + { penaltyId: 8 }, + ); + expect(logger.info).toHaveBeenCalledWith( + { + event: 'penalty_catalog_update', + details: 'teamId=5 penaltyId=8 action=update', + userId: 42, + }, + manager, + ); + }); + + it('deletes an entry permanently after manager authorization and audits it', async () => { + readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } }); + const penalty = { id: 8, team: { id: 5 } }; + writeRepository.findOne.mockResolvedValue(penalty); + + await service.deletePenalty(8, 42); + + expect(writeRepository.remove).toHaveBeenCalledWith(penalty); + expect(logger.info).toHaveBeenCalledWith( + { + event: 'penalty_catalog_delete', + details: 'teamId=5 penaltyId=8 action=delete', + userId: 42, + }, + manager, + ); + }); + + it('returns not found when a mutation target does not exist', async () => { + readRepository.findOne.mockResolvedValue(null); + + await expect(service.deletePenalty(999, 42)).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects the mutation when transactional audit persistence fails', async () => { + writeRepository.save.mockResolvedValue({ + id: 9, + team: { id: 5 }, + description: 'Neu', + amount: 2, + createdAt: new Date(), + }); + logger.info.mockRejectedValue(new Error('audit unavailable')); + + await expect( + service.createPenalty( + { teamId: 5, description: 'Neu', amount: 2 }, + 42, + ), + ).rejects.toThrow('audit unavailable'); + }); +}); diff --git a/myteamwallet_backend/src/penalty/penalty.service.ts b/myteamwallet_backend/src/penalty/penalty.service.ts index 1e5ece3..e476bbd 100644 --- a/myteamwallet_backend/src/penalty/penalty.service.ts +++ b/myteamwallet_backend/src/penalty/penalty.service.ts @@ -1,61 +1,190 @@ -import { Injectable } from '@nestjs/common'; -import { CreatePenaltyDTO } from './dto/create-penalty.dto'; +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { LoggingService } from 'src/database/logging/logging.service'; import { Team } from 'src/teams/entities/team.entity'; -import { Repository } from 'typeorm'; +import { TeamAccessService } from 'src/teams/team-access.service'; +import { DataSource, EntityManager, Repository } from 'typeorm'; +import { CreatePenaltyDTO } from './dto/create-penalty.dto'; +import { PenaltyResponseDTO } from './dto/penalty-response.dto'; +import { UpdatePenaltyDTO } from './dto/update-penalty.dto'; import { PenaltyEntity } from './entities/penalty.entity'; -import { Player } from 'src/players/entities/player.entity'; @Injectable() export class PenaltyService { constructor( - @InjectRepository(Team) - private teamRepository: Repository, - @InjectRepository(Player) - private playerRepository: Repository, @InjectRepository(PenaltyEntity) - private repository: Repository, + private readonly repository: Repository, + @InjectRepository(Team) + private readonly teamRepository: Repository, + private readonly dataSource: DataSource, + private readonly access: TeamAccessService, + private readonly logger: LoggingService, ) {} - async createPenalty(dto: CreatePenaltyDTO, userId: string) { - const player = await this.playerRepository.findOne({ - where: { user: { id: Number(userId) }, team: { id: dto.teamId } }, + async getTeamPenalties( + userId: number, + teamId: number, + ): Promise { + const team = await this.teamRepository.findOne({ where: { id: teamId } }); + if (!team) throw new NotFoundException('Team nicht gefunden.'); + await this.access.assertMember(userId, teamId); + const penalties = await this.repository.find({ + where: { team: { id: teamId } }, + order: { description: 'ASC' }, + }); + return penalties.map((penalty) => this.toResponse(penalty)); + } + + createPenalty( + dto: CreatePenaltyDTO, + userId: number, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const team = await this.lockTeam(manager, dto.teamId); + await this.access.assertManager(userId, team.id, manager); + const repository = manager.getRepository(PenaltyEntity); + const description = dto.description.trim(); + await this.assertUniqueDescription(repository, team.id, description); + const saved = await repository.save( + repository.create({ team, description, amount: dto.amount }), + ); + await this.logger.info( + { + event: 'penalty_catalog_create', + details: `teamId=${team.id} penaltyId=${saved.id} action=create`, + userId, + }, + manager, + ); + return this.toResponse(saved); + }); + } + + async updatePenalty( + penaltyId: number, + dto: UpdatePenaltyDTO, + userId: number, + ): Promise { + const owner = await this.findOwner(penaltyId); + return this.dataSource.transaction(async (manager) => { + const team = await this.lockTeam(manager, owner.team.id); + await this.access.assertManager(userId, team.id, manager); + const repository = manager.getRepository(PenaltyEntity); + const penalty = await this.findTransactionalPenalty( + repository, + penaltyId, + team.id, + ); + const description = dto.description.trim(); + await this.assertUniqueDescription( + repository, + team.id, + description, + penaltyId, + ); + penalty.description = description; + penalty.amount = dto.amount; + const saved = await repository.save(penalty); + await this.logger.info( + { + event: 'penalty_catalog_update', + details: `teamId=${team.id} penaltyId=${penaltyId} action=update`, + userId, + }, + manager, + ); + return this.toResponse(saved); + }); + } + + async deletePenalty(penaltyId: number, userId: number): Promise { + const owner = await this.findOwner(penaltyId); + await this.dataSource.transaction(async (manager) => { + const team = await this.lockTeam(manager, owner.team.id); + await this.access.assertManager(userId, team.id, manager); + const repository = manager.getRepository(PenaltyEntity); + const penalty = await this.findTransactionalPenalty( + repository, + penaltyId, + team.id, + ); + await repository.remove(penalty); + await this.logger.info( + { + event: 'penalty_catalog_delete', + details: `teamId=${team.id} penaltyId=${penaltyId} action=delete`, + userId, + }, + manager, + ); + }); + } + + private async findOwner(penaltyId: number): Promise { + const penalty = await this.repository.findOne({ + where: { id: penaltyId }, relations: ['team'], }); + if (!penalty?.team) throw new NotFoundException('Strafe nicht gefunden.'); + return penalty; + } - if (!player || !player.team) { - return; + private async lockTeam(manager: EntityManager, teamId: number): Promise { + const team = await manager + .getRepository(Team) + .createQueryBuilder('team') + .where('team.id = :teamId', { teamId }) + .setLock('pessimistic_write') + .getOne(); + if (!team) throw new NotFoundException('Team nicht gefunden.'); + return team; + } + + private async findTransactionalPenalty( + repository: Repository, + penaltyId: number, + teamId: number, + ): Promise { + const penalty = await repository.findOne({ + where: { id: penaltyId, team: { id: teamId } }, + relations: ['team'], + }); + if (!penalty) throw new NotFoundException('Strafe nicht gefunden.'); + return penalty; + } + + private async assertUniqueDescription( + repository: Repository, + teamId: number, + description: string, + penaltyId?: number, + ): Promise { + const query = repository + .createQueryBuilder('penalty') + .where('penalty.teamId = :teamId', { teamId }) + .andWhere('LOWER(TRIM(penalty.description)) = :description', { + description: description.toLocaleLowerCase('de'), + }); + if (penaltyId !== undefined) { + query.andWhere('penalty.id != :penaltyId', { penaltyId }); + } + if ((await query.getCount()) > 0) { + throw new ConflictException( + 'Ein Eintrag mit dieser Beschreibung existiert bereits.', + ); } - - dto.team = player.team; - - const e = this.repository.create(dto); - - return this.repository.save(e); } - getAll(userId: string) { - const id = Number(userId); - - return this.repository.find({ - where: { - team: { players: { user: { id } } }, - }, - relations: ['team'], - }); - } - - async getTeamPenalties(teamId: string | number) { - teamId = Number(teamId); - - const res = await this.repository.find({ - where: { - team: { id: teamId }, - }, - }); - return res.map((r) => { - r.amount = Number(r.amount); - return r; - }); + private toResponse(penalty: PenaltyEntity): PenaltyResponseDTO { + return { + id: penalty.id, + description: penalty.description, + amount: Number(penalty.amount), + createdAt: penalty.createdAt, + }; } } diff --git a/myteamwallet_backend/src/teams/team-access.service.spec.ts b/myteamwallet_backend/src/teams/team-access.service.spec.ts index 6e39348..b28b422 100644 --- a/myteamwallet_backend/src/teams/team-access.service.spec.ts +++ b/myteamwallet_backend/src/teams/team-access.service.spec.ts @@ -65,6 +65,30 @@ describe('TeamAccessService', () => { await expect(service.assertManager(2, 9)).resolves.toBeUndefined(); }); + it('uses repositories from the supplied transaction manager', async () => { + const transactionalUserRepository = { + findOne: jest.fn().mockResolvedValue({ + id: 2, + role: { id: RoleEnum.user }, + }), + }; + const transactionalPlayerRepository = { + find: jest + .fn() + .mockResolvedValue([{ active: true, teamRole: { id: 3 } }]), + }; + const manager = { + getRepository: jest + .fn() + .mockReturnValueOnce(transactionalUserRepository) + .mockReturnValueOnce(transactionalPlayerRepository), + }; + + await expect(service.assertManager(2, 9, manager as any)).resolves.toBeUndefined(); + expect(userRepository.findOne).not.toHaveBeenCalled(); + expect(playerRepository.find).not.toHaveBeenCalled(); + }); + it.each([ ['a player', [{ active: true, teamRole: { id: 1 } }]], ['a second treasurer', [{ active: true, teamRole: { id: 2 } }]], diff --git a/myteamwallet_backend/src/teams/team-access.service.ts b/myteamwallet_backend/src/teams/team-access.service.ts index b5bcd38..01fa092 100644 --- a/myteamwallet_backend/src/teams/team-access.service.ts +++ b/myteamwallet_backend/src/teams/team-access.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Player } from '../players/entities/player.entity'; import { RoleEnum } from '../roles/roles.enum'; import { User } from '../users/entities/user.entity'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; @Injectable() export class TeamAccessService { @@ -14,23 +14,35 @@ export class TeamAccessService { private readonly playerRepository: Repository, ) {} - async assertMember(userId: number, teamId: number): Promise { - await this.assertMinimumRole(userId, teamId, 1); + async assertMember( + userId: number, + teamId: number, + manager?: EntityManager, + ): Promise { + await this.assertMinimumRole(userId, teamId, 1, manager); } - async assertManager(userId: number, teamId: number): Promise { - await this.assertMinimumRole(userId, teamId, 3); + async assertManager( + userId: number, + teamId: number, + manager?: EntityManager, + ): Promise { + await this.assertMinimumRole(userId, teamId, 3, manager); } private async assertMinimumRole( userId: number, teamId: number, minimumRole: number, + manager?: EntityManager, ): Promise { - const user = await this.userRepository.findOne({ where: { id: userId } }); + const userRepository = manager?.getRepository(User) ?? this.userRepository; + const playerRepository = + manager?.getRepository(Player) ?? this.playerRepository; + const user = await userRepository.findOne({ where: { id: userId } }); if (user?.role?.id === RoleEnum.admin) return; - const players = await this.playerRepository.find({ + const players = await playerRepository.find({ where: { user: { id: userId }, team: { id: teamId } }, }); const highestActiveRole = players diff --git a/myteamwallet_backend/src/teams/teams.module.ts b/myteamwallet_backend/src/teams/teams.module.ts index fb5e832..3f02e5a 100644 --- a/myteamwallet_backend/src/teams/teams.module.ts +++ b/myteamwallet_backend/src/teams/teams.module.ts @@ -31,5 +31,6 @@ import { PenaltyEntity } from '../penalty/entities/penalty.entity'; ], controllers: [TeamsController, PublicTeamsController], providers: [TeamsService, TeamAccessService, PublicTeamAccessService], + exports: [TeamAccessService], }) export class TeamsModule {}