1
0
Fork 0
mirror of https://codeberg.org/beerbrawl/beerbrawl.git synced 2024-09-23 05:40:51 +02:00

feat(#13): validation of too low max participants - UpdateTournamentDTO without FUTURE cosntraint

This commit is contained in:
rafael 2024-05-29 20:42:05 +02:00
parent f6980af7a0
commit 30b1c41ac2
13 changed files with 271 additions and 36 deletions

View file

@ -15,6 +15,8 @@ import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentListDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentOverviewDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentQualificationMatchDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentSignupTeamResponseDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentUpdateDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentUpdateTeamDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.mapper.QualificationTeamScoreMapper;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.mapper.TeamMapper;
import at.ac.tuwien.sepr.groupphase.backend.exception.BadTournamentSignupTokenException;
@ -182,9 +184,9 @@ public class TournamentEndpoint {
@Secured("ROLE_USER")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PutMapping("/{tournamentId}")
@Operation(summary = "Delete a tournament", security = @SecurityRequirement(name = "apiKey"))
@Operation(summary = "Update a tournament", security = @SecurityRequirement(name = "apiKey"))
public ResponseEntity<Void> updateTournament(@PathVariable("tournamentId") long tournamentId,
@Valid @RequestBody CreateTournamentDto updates,
@Valid @RequestBody TournamentUpdateDto updates,
Authentication authentication) {
LOG.info("UPDATE {}/{}", BASE_ENDPOINT, tournamentId);
@ -192,7 +194,7 @@ public class TournamentEndpoint {
throw new AccessDeniedException("Current user isn't organizer of tournament.");
}
tournamentService.updateTournament(tournamentId, tournamentMapper.createDtoToEntity(updates));
tournamentService.updateTournament(tournamentId, tournamentMapper.updateDtoToEntity(updates));
return ResponseEntity.noContent().build();
}

View file

@ -4,6 +4,7 @@ import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@ -23,6 +24,7 @@ public class CreateTournamentDto {
private LocalDateTime registrationEnd;
@Min(value = 16, message = "Max participants needs to be a number larger than 16.")
@Max(value = 64, message = "No more than 64 teams are allowed.")
private Long maxParticipants;
private String description;

View file

@ -0,0 +1,63 @@
package at.ac.tuwien.sepr.groupphase.backend.endpoint.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
public class TournamentUpdateDto {
@NotNull(message = "Name can't be null.")
@Size(max = 200, message = "Name can't be more than 200 characters long.")
@NotBlank(message = "Name can't be empty.")
@Pattern(regexp = "[\\w\\s\\.äÄöÖüÜ\\-,]*", message = "Name contains not allowed characters.")
private String name;
@NotNull(message = "Registration end can't be null.")
private LocalDateTime registrationEnd;
@Min(value = 16, message = "Max participants needs to be a number larger than 16.")
@Max(value = 64, message = "No more than 64 teams are allowed.")
private Long maxParticipants;
private String description;
public String getName() {
return name;
}
public TournamentUpdateDto setName(String name) {
this.name = name;
return this;
}
public LocalDateTime getRegistrationEnd() {
return registrationEnd;
}
public TournamentUpdateDto setRegistrationEnd(LocalDateTime registrationEnd) {
this.registrationEnd = registrationEnd;
return this;
}
public Long getMaxParticipants() {
return maxParticipants;
}
public TournamentUpdateDto setMaxParticipants(Long maxParticipants) {
this.maxParticipants = maxParticipants;
return this;
}
public String getDescription() {
return description;
}
public TournamentUpdateDto setDescription(String description) {
this.description = description;
return this;
}
}

View file

@ -7,6 +7,7 @@ import at.ac.tuwien.sepr.groupphase.backend.exception.PreconditionFailedExceptio
import at.ac.tuwien.sepr.groupphase.backend.exception.TournamentAlreadyStartedException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.ValidationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
@ -122,4 +123,16 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
HttpStatus.UNAUTHORIZED,
request);
}
@ExceptionHandler({ValidationException.class})
protected ResponseEntity<Object> handleValidationException(RuntimeException ex, WebRequest request) {
LOGGER.debug(ex.getMessage());
return handleExceptionInternal(
ex,
ex.getMessage(),
new HttpHeaders(),
HttpStatus.UNPROCESSABLE_ENTITY,
request);
}
}

View file

@ -1,8 +1,11 @@
package at.ac.tuwien.sepr.groupphase.backend.endpoint.mapper;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.CreateTournamentDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentListDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentListDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentQualificationMatchDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentQualificationMatchParticipantDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentUpdateDto;
import at.ac.tuwien.sepr.groupphase.backend.entity.Tournament;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@ -10,8 +13,6 @@ import org.mapstruct.Mapping;
import java.util.List;
import java.util.Objects;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentQualificationMatchDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentQualificationMatchParticipantDto;
import at.ac.tuwien.sepr.groupphase.backend.entity.QualificationMatch;
@Mapper
@ -24,6 +25,10 @@ public interface TournamentMapper {
@Mapping(target = "organizer", ignore = true)
Tournament createDtoToEntity(CreateTournamentDto tournamentDto);
@Mapping(target = "id", ignore = true)
@Mapping(target = "organizer", ignore = true)
Tournament updateDtoToEntity(TournamentUpdateDto tournamentDto);
TournamentDto entityToDto(Tournament entity);
default TournamentQualificationMatchDto qualificationMatchEntityToDto(QualificationMatch entity) {

View file

@ -68,7 +68,10 @@ public class Tournament {
public Tournament(String name, LocalDateTime registrationEnd, Long maxParticipants, String description,
ApplicationUser organizer) {
this.name = name;
if (registrationEnd.isBefore(LocalDateTime.now())) {
// Minus 1 min as .now() is to strict
// Makes testing for update unfeasible
// Tournament RegisEnd is set to .now() if started early (Latency frontend to backend)
if (registrationEnd.isBefore(LocalDateTime.now().minusMinutes(1))) {
throw new IllegalArgumentException("Registration end must be in the future");
}
this.registrationEnd = registrationEnd;

View file

@ -9,6 +9,7 @@ import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentUpdateQualifi
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentUpdateTeamDto;
import at.ac.tuwien.sepr.groupphase.backend.entity.Team;
import at.ac.tuwien.sepr.groupphase.backend.service.models.QualificationTeamScoreModel;
import jakarta.validation.ValidationException;
import org.springframework.security.access.AccessDeniedException;
import at.ac.tuwien.sepr.groupphase.backend.entity.QualificationMatch;
@ -180,5 +181,5 @@ public interface TournamentService {
* @param tournamentId to identify target
* @param updates contains the updated values
*/
void updateTournament(long tournamentId, Tournament updates);
void updateTournament(long tournamentId, Tournament updates) throws NotFoundException, ValidationException;
}

View file

@ -27,6 +27,7 @@ import at.ac.tuwien.sepr.groupphase.backend.repository.TeamRepository;
import at.ac.tuwien.sepr.groupphase.backend.repository.TournamentRepository;
import at.ac.tuwien.sepr.groupphase.backend.repository.UserRepository;
import at.ac.tuwien.sepr.groupphase.backend.service.models.QualificationTeamScoreModel;
import jakarta.validation.ValidationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
@ -498,7 +499,7 @@ public class TournamentServiceImpl implements TournamentService {
}
@Override
public void updateTournament(long tournamentId, Tournament updates) {
public void updateTournament(long tournamentId, Tournament updates) throws NotFoundException, ValidationException {
LOGGER.debug("Update tournament with id {} to {}", tournamentId, updates);
var target = tournamentRepository.findById(tournamentId);
@ -512,13 +513,17 @@ public class TournamentServiceImpl implements TournamentService {
existing.setName(updates.getName());
// All Team related information can only be set before the start.
if (existing.getRegistrationEnd().isBefore(LocalDateTime.now())) {
// All Team related information can only be set before the tournament has started.
if (LocalDateTime.now().isBefore(existing.getRegistrationEnd())) {
//ToDo: Validate too low(actual teams higher) and too high(?)
if (teamRepository.findAllByTournamentId(tournamentId).size() > updates.getMaxParticipants()) {
throw new ValidationException("New max participating teams is lower than already registered teams.");
}
existing.setMaxParticipants(updates.getMaxParticipants());
existing.setRegistrationEnd(updates.getRegistrationEnd());
}
tournamentRepository.saveAndFlush(existing);
}
private List<QualificationTeamScoreModel> calculateScores(List<Team> teams, List<QualificationMatch> matches) {

View file

@ -10,6 +10,7 @@ import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentUpdateTeamDto
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentListDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentOverviewDto;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.*;
import at.ac.tuwien.sepr.groupphase.backend.entity.ApplicationUser;
import at.ac.tuwien.sepr.groupphase.backend.entity.Tournament;
import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.ValidationErrorDto;
@ -819,6 +820,25 @@ public class TournamentEndpointTest extends TestTournamentData implements TestDa
.andExpect(status().isNotFound());
}
@Test
public void updateTournament_MaxParticipationLessThanActual_ThrowsValidationException() throws Exception{
var userid = userRepository.findByUsername(TEST_USER).getId();
var tournament = tournamentRepository.findAllByOrganizerIdOrderByNameAsc(userid).getFirst();
var updatesDto = new TournamentUpdateDto();
updatesDto.setMaxParticipants(22L);
updatesDto.setName("Updated");
updatesDto.setRegistrationEnd(LocalDateTime.now().plusSeconds(50));
updatesDto.setDescription("Updated Description");
var mvcResult = this.mockMvc.perform(
put(TOURNAMENT_BASE_URI + "/{tournamentId}", tournament.getId())
.header(securityProperties.getAuthHeader(), jwtTokenizer.getAuthToken(TEST_USER, TEST_USER_ROLES))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updatesDto)))
.andDo(print())
.andExpect(status().isUnprocessableEntity());
}
@Test
public void canUpdateExistingTeamInTournamentSuccessfully() throws Exception {
final var tournament = tournamentRepository

View file

@ -6,8 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.ZoneOffset;
import java.util.HashMap;
@ -36,6 +34,12 @@ import at.ac.tuwien.sepr.groupphase.backend.repository.TournamentRepository;
import at.ac.tuwien.sepr.groupphase.backend.repository.UserRepository;
import at.ac.tuwien.sepr.groupphase.backend.service.TournamentService;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@ -277,4 +281,58 @@ public class TournamentServiceTest extends TestUserData implements TestData {
private static LocalDateTime currentUtcTime() {
return LocalDateTime.now(ZoneOffset.UTC);
}
@Test
public void editTournament_ValidUpdate() {
var tournament = new Tournament(
"testname", LocalDateTime.now().plusMinutes(1), 32L, "testdescription",
userRepository.findByUsername(TEST_USER));
tournamentRepository.saveAndFlush(tournament);
var testUser = userRepository.findByUsername(TEST_USER);
var existing = tournamentRepository.findAllByOrganizerIdOrderByNameAsc(testUser.getId()).getFirst();
assertNotNull(existing);
var updates = new Tournament("Updated",
existing.getRegistrationEnd(),
64L,
"Updated description",
existing.getOrganizer());
assertNotEquals(existing.getName(), updates.getName());
tournamentService.updateTournament(1L, updates);
var existingUpdated = tournamentRepository.findAllByOrganizerIdOrderByNameAsc(testUser.getId()).getFirst();
assertNotNull(existingUpdated);
assertEquals(existingUpdated.getMaxParticipants(), updates.getMaxParticipants());
assertEquals(existingUpdated.getRegistrationEnd(), updates.getRegistrationEnd());
assertEquals(existingUpdated.getName(), updates.getName());
}
@Test
public void editTournament_ValidUpdate_StartedTournament_NoChangesToMaxPartAndRegisEnd() {
var tournament = new Tournament(
"testname", LocalDateTime.now(), 32L, "testdescription",
userRepository.findByUsername(TEST_USER));
tournamentRepository.saveAndFlush(tournament);
var testUser = userRepository.findByUsername(TEST_USER);
var existing = tournamentRepository.findAllByOrganizerIdOrderByNameAsc(testUser.getId()).getFirst();
assertNotNull(existing);
var updates = new Tournament("Updated",
existing.getRegistrationEnd(),
64L,
"Updated description",
existing.getOrganizer());
assertNotEquals(existing.getName(), updates.getName());
tournamentService.updateTournament(1L, updates);
var existingUpdated = tournamentRepository.findAllByOrganizerIdOrderByNameAsc(testUser.getId()).getFirst();
assertNotNull(existingUpdated);
assertEquals(existingUpdated.getMaxParticipants(), tournament.getMaxParticipants());
assertEquals(existingUpdated.getRegistrationEnd().getMinute(), tournament.getRegistrationEnd().getMinute());
assertEquals(existingUpdated.getRegistrationEnd().getHour(), tournament.getRegistrationEnd().getHour());
assertEquals(existingUpdated.getRegistrationEnd().getSecond(), tournament.getRegistrationEnd().getSecond());
assertEquals(existingUpdated.getName(), updates.getName());
}
}

View file

@ -0,0 +1,20 @@
/**
* OpenAPI definition
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: v0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface TournamentUpdateDto {
name: string;
registrationEnd: string;
maxParticipants?: number;
description?: string;
}

View file

@ -108,13 +108,7 @@ export class TournamentCreateComponent {
this.router.navigate(['/tournaments']);
},
error: async (er: HttpErrorResponse) => {
this.snackBar.open(
`There was a problem creating the tournament: ${await er.error.text()}`,
'Close',
{
duration: 3000,
},
);
this.defaultServiceErrorHandling(er);
this.tournamentForm.enable();
},
});
@ -130,4 +124,17 @@ export class TournamentCreateComponent {
isValidDateString(value: string) {
return isValidDate(this.dateAdapter.parse(value));
}
private defaultServiceErrorHandling(error: HttpErrorResponse) {
let errorMessage = '';
console.log(JSON.stringify(error));
if (typeof error.error === 'object') {
errorMessage = error.error.error;
} else {
errorMessage = error.error;
}
this.snackBar.open('Error: ' + errorMessage, 'OK', {
duration: 5000,
});
}
}

View file

@ -1,6 +1,6 @@
import { Location, NgIf } from '@angular/common';
import { DatePipe, Location, NgIf } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, input } from '@angular/core';
import { Component, OnInit, input } from '@angular/core';
import {
FormControl,
FormGroup,
@ -18,7 +18,12 @@ import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterLink } from '@angular/router';
import { CreateTournamentDto, TournamentEndpointService } from '@api';
import {
CreateTournamentDto,
TournamentEndpointService,
TournamentOverviewDto,
TournamentUpdateDto,
} from '@api';
import { NgxMatTimepickerComponent, NgxMatTimepickerDirective } from 'ngx-mat-timepicker';
import { isValidDate } from 'rxjs/internal/util/isDate';
import { CustomDateAdapter } from 'src/app/adapters/date-adapter';
@ -48,7 +53,7 @@ import { CustomDateAdapter } from 'src/app/adapters/date-adapter';
templateUrl: './tournament-edit.component.html',
styleUrl: './tournament-edit.component.scss',
})
export class TournamentEditComponent {
export class TournamentEditComponent implements OnInit {
tournamentId = input.required<number>();
tournamentForm = new FormGroup({
@ -72,13 +77,36 @@ export class TournamentEditComponent {
};
constructor(
private client: TournamentEndpointService,
private service: TournamentEndpointService,
private router: Router,
private snackBar: MatSnackBar,
protected dateAdapter: CustomDateAdapter,
private location: Location,
) {}
ngOnInit(): void {
this.service.getTournamentOverview(this.tournamentId()).subscribe({
next: (data: TournamentOverviewDto) => {
const date = new Date(data.registrationEnd);
this.tournamentForm.setValue({
name: data.name,
registrationEndDate: date,
registrationEndTime: `${date.getUTCHours()}:${date.getUTCMinutes()}`,
maxParticipants: data.maxParticipants,
description: data.description!,
});
if (Date.now() > date.getTime()) {
this.tournamentForm.get('maxParticipants')?.disable();
this.tournamentForm.get('registrationEndDate')?.disable();
this.tournamentForm.get('registrationEndTime')?.disable();
}
},
error: async (err: HttpErrorResponse) => {
this.defaultServiceErrorHandling(err);
},
});
}
get today() {
return new Date();
}
@ -96,13 +124,13 @@ export class TournamentEditComponent {
date.setMinutes(
parseInt(this.tournamentForm.controls.registrationEndTime.value!!.split(':')[1]),
);
this.client
this.service
.updateTournament(this.tournamentId(), {
name: this.tournamentForm.controls.name.value!!,
registrationEnd: date.toISOString()!!,
maxParticipants: this.tournamentForm.controls.maxParticipants.value!!,
description: this.tournamentForm.controls.description.value,
} as CreateTournamentDto)
} as TournamentUpdateDto)
.subscribe({
next: () => {
this.snackBar.open('Successfully updated new tournament', 'Close', {
@ -111,14 +139,9 @@ export class TournamentEditComponent {
this.router.navigate(['/tournaments']);
},
error: async (er: HttpErrorResponse) => {
this.snackBar.open(
`There was a problem creating the tournament: ${await er.error.text()}`,
'Close',
{
duration: 3000,
},
);
this.tournamentForm.enable();
this.defaultServiceErrorHandling(er);
this.tournamentForm.get('name')?.enable();
this.tournamentForm.get('description')?.enable();
},
});
}
@ -130,4 +153,17 @@ export class TournamentEditComponent {
back() {
this.location.back();
}
private defaultServiceErrorHandling(error: HttpErrorResponse) {
let errorMessage = '';
console.log(JSON.stringify(error));
if (typeof error.error === 'object') {
errorMessage = error.error.error;
} else {
errorMessage = error.error;
}
this.snackBar.open('Error: ' + errorMessage, 'OK', {
duration: 5000,
});
}
}