diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/TournamentEndpoint.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/TournamentEndpoint.java index 00c8573..afe79f2 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/TournamentEndpoint.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/TournamentEndpoint.java @@ -16,13 +16,7 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.annotation.Secured; import org.springframework.security.core.Authentication; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import at.ac.tuwien.sepr.groupphase.backend.endpoint.mapper.TournamentMapper; import at.ac.tuwien.sepr.groupphase.backend.entity.Tournament.SignupTeamResult; @@ -110,6 +104,16 @@ public class TournamentEndpoint { return tournamentService.getTournamentTeams(tournamentId).stream().map(teamMapper::entityToDto).toList(); } + @Secured("ROLE_USER") + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{tournamentId}") + @Operation(summary = "Delete a tournament", security = @SecurityRequirement(name = "apiKey")) + public ResponseEntity deleteTournament(@PathVariable("tournamentId") long tournamentId, Authentication authentication) { + LOG.info("DELETE {}/{}", BASE_ENDPOINT, tournamentId); + tournamentService.deleteTournament(tournamentId, authentication.getName()); + return ResponseEntity.noContent().build(); + } + // as of now, information is accessible to everyone @PermitAll @GetMapping("{tournamentId}/public") diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/repository/TournamentRepository.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/repository/TournamentRepository.java index f47c1c3..e3f5539 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/repository/TournamentRepository.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/repository/TournamentRepository.java @@ -31,4 +31,11 @@ public interface TournamentRepository extends JpaRepository { * @return wether the tournament with the given name exists */ Boolean existsByName(String name); + + /** + * Delete a tournament by its ID. + * + * @param id The ID of the tournament to delete + */ + void deleteById(Long id); } diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/TournamentService.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/TournamentService.java index cf5cfef..b3c0e74 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/TournamentService.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/TournamentService.java @@ -75,4 +75,13 @@ public interface TournamentService { * @return the tournament entity */ Tournament findOne(long tournamentId); -} + + /** + * Delete a single tournament entity by id. + * + * @param tournamentId the id of the tournament entity + * @throws NotFoundException if the tournament is not found + * @throws AccessDeniedException if the current user does not have permission to delete the tournament + */ + void deleteTournament(long tournamentId, String currentUserName) throws NotFoundException, AccessDeniedException; +} \ No newline at end of file diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/impl/TournamentServiceImpl.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/impl/TournamentServiceImpl.java index 3141bfa..e8aeefb 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/impl/TournamentServiceImpl.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/impl/TournamentServiceImpl.java @@ -175,4 +175,18 @@ public class TournamentServiceImpl implements TournamentService { return tournament.getOrganizer().getUsername().equals(username); } + @Transactional + public void deleteTournament(long tournamentId, String currentUserName) throws NotFoundException, AccessDeniedException { + LOGGER.debug("Deleting tournament with id {}", tournamentId); + + Tournament tournament = tournamentRepository.findById(tournamentId) + .orElseThrow(() -> new NotFoundException("Tournament not found")); + + if (!tournament.getOrganizer().getUsername().equals(currentUserName)) { + throw new AccessDeniedException("You do not have permission to delete this tournament"); + } + + tournamentRepository.deleteById(tournamentId); + LOGGER.debug("Tournament with id {} deleted successfully", tournamentId); + } } diff --git a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/TournamentEndpointTest.java b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/TournamentEndpointTest.java index eb4b823..aa436f5 100644 --- a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/TournamentEndpointTest.java +++ b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/TournamentEndpointTest.java @@ -21,8 +21,9 @@ import at.ac.tuwien.sepr.groupphase.backend.security.JwtTokenizer; import com.fasterxml.jackson.databind.ObjectMapper; import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDateTime; import java.util.List; @@ -40,13 +41,7 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDateTime; -import java.util.List; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import org.springframework.transaction.annotation.Transactional; @@ -310,4 +305,63 @@ public class TournamentEndpointTest extends TestUserData implements TestData { assertEquals(HttpStatus.NOT_FOUND.value(), response.getStatus()); } + + @Test + public void successfullyDeleteTournament() throws Exception { + // Setup: Create a tournament to delete + var tournament = new Tournament(); + tournament.setName("TOURNAMENT_TO_DELETE"); + tournament.setMaxParticipants(64L); + tournament.setOrganizer(userRepository.findByUsername(TestDataGenerator.TEST_USER)); + tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); + + tournamentRepository.saveAndFlush(tournament); + + // Perform delete request + var mvcResult = this.mockMvc.perform(delete(String.format("%s/%d", TOURNAMENT_BASE_URI, tournament.getId())) + .header(securityProperties.getAuthHeader(), jwtTokenizer.getAuthToken(TEST_USER, TEST_USER_ROLES)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNoContent()) + .andReturn(); + + // Verify the tournament has been deleted + var deletedTournament = tournamentRepository.findById(tournament.getId()); + assertTrue(deletedTournament.isEmpty(), "Expected the tournament to be deleted, but it still exists"); + } + + @Test + public void deleteTournamentUnauthorized() throws Exception { + // Setup: Create a tournament to delete + var tournament = new Tournament(); + tournament.setName("TOURNAMENT_TO_DELETE"); + tournament.setMaxParticipants(64L); + tournament.setOrganizer(userRepository.findByUsername(TestDataGenerator.TEST_USER)); + tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); + + tournamentRepository.saveAndFlush(tournament); + + // Perform delete request without authorization header + var mvcResult = this.mockMvc.perform(delete(String.format("%s/%d", TOURNAMENT_BASE_URI, tournament.getId())) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andReturn(); + } + @Test + public void deleteNonExistingTournament() throws Exception { + // Attempt to delete a non-existing tournament + long nonExistingTournamentId = -1L; + + var mvcResult = this.mockMvc.perform(delete(String.format("%s/%d", TOURNAMENT_BASE_URI, nonExistingTournamentId)) + .header(securityProperties.getAuthHeader(), jwtTokenizer.getAuthToken(TEST_USER, TEST_USER_ROLES)) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andReturn(); + + MockHttpServletResponse response = mvcResult.getResponse(); + assertEquals(HttpStatus.NOT_FOUND.value(), response.getStatus(), "Expected status 404 for non-existing tournament"); + } + } diff --git a/e2e/cypress/e2e/tournaments.cy.js b/e2e/cypress/e2e/tournaments.cy.js index 8e6e994..7a02493 100644 --- a/e2e/cypress/e2e/tournaments.cy.js +++ b/e2e/cypress/e2e/tournaments.cy.js @@ -14,3 +14,47 @@ context('Get Tournaments', () => { cy.get('[data-cy="tournaments-list-item"]').should('have.length.at.least', 1); }); }); + +context('Delete Tournament', () => { + beforeEach(() => { + cy.loginTestUser(); + cy.wait(4000); + }); + + it('successfully deletes a tournament', () => { + cy.intercept('GET', '/api/v1/tournaments', { fixture: 'tournaments.json' }).as( + 'getTournaments', + ); + cy.visit('/#/tournaments'); + cy.wait('@getTournaments'); + cy.get('[data-cy="tournaments-list"]').should('exist'); + cy.get('[data-cy="tournaments-list-item"]').should('have.length.at.least', 1); + + cy.intercept('DELETE', '/api/v1/tournaments/*', { statusCode: 200 }).as('deleteTournament'); + + cy.get('[data-cy="tournaments-list-item"]').first().as('firstTournament'); + cy.get('@firstTournament').find('[data-cy="delete-tournament-btn"]').click(); + + cy.get('[data-cy="confirm-delete-btn"]').click(); + + cy.wait('@deleteTournament'); + + cy.get('@firstTournament').should('not.exist'); + }); + it('cancels tournament deletion', () => { + cy.intercept('GET', '/api/v1/tournaments', { fixture: 'tournaments.json' }).as( + 'getTournaments', + ); + cy.visit('/#/tournaments'); + cy.wait('@getTournaments'); + cy.get('[data-cy="tournaments-list"]').should('exist'); + cy.get('[data-cy="tournaments-list-item"]').should('have.length.at.least', 1); + + cy.get('[data-cy="tournaments-list-item"]').first().as('firstTournament'); + cy.get('@firstTournament').find('[data-cy="delete-tournament-btn"]').click(); + + cy.get('[data-cy="cancel-delete-btn"]').click(); + + cy.get('@firstTournament').should('exist'); + }); +}); diff --git a/frontend/src/app/components/confirm-dialog/confirm-dialog.component.html b/frontend/src/app/components/confirm-dialog/confirm-dialog.component.html index bcc6529..452c4b2 100644 --- a/frontend/src/app/components/confirm-dialog/confirm-dialog.component.html +++ b/frontend/src/app/components/confirm-dialog/confirm-dialog.component.html @@ -3,6 +3,7 @@
-
diff --git a/frontend/src/app/components/tournament/tournament-card/tournament-card.component.ts b/frontend/src/app/components/tournament/tournament-card/tournament-card.component.ts index c8b84f7..d81bdd9 100644 --- a/frontend/src/app/components/tournament/tournament-card/tournament-card.component.ts +++ b/frontend/src/app/components/tournament/tournament-card/tournament-card.component.ts @@ -1,11 +1,52 @@ -import { Component, Input } from '@angular/core'; -import { TournamentListDto } from '@api'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { TournamentEndpointService, TournamentListDto } from '@api'; +import { ConfirmDialogComponent } from '../../confirm-dialog/confirm-dialog.component'; +import { MatSnackBar } from '@angular/material/snack-bar'; @Component({ selector: 'app-tournament-card', templateUrl: './tournament-card.component.html', - styleUrl: './tournament-card.component.scss', + styleUrls: ['./tournament-card.component.scss'], }) export class TournamentCardComponent { @Input() tournament: TournamentListDto | undefined; + @Output() tournamentDeleted = new EventEmitter(); + + constructor( + private dialog: MatDialog, + private tournamentService: TournamentEndpointService, + private snackBar: MatSnackBar, + ) {} + + openConfirmDialog(): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + data: 'delete tournament', + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.deleteTournament(); + } + }); + } + + deleteTournament(): void { + if (this.tournament?.id) { + this.tournamentService.deleteTournament(this.tournament.id).subscribe({ + next: () => { + this.snackBar.open('Tournament deleted successfully', 'Close', { + duration: 3000, + }); + this.tournamentDeleted.emit(); + }, + error: err => { + console.error('Error deleting tournament', err); + this.snackBar.open('Failed to delete tournament', 'Close', { + duration: 3000, + }); + }, + }); + } + } } diff --git a/frontend/src/app/components/tournament/tournaments/tournaments.component.html b/frontend/src/app/components/tournament/tournaments/tournaments.component.html index af80021..3b14f92 100644 --- a/frontend/src/app/components/tournament/tournaments/tournaments.component.html +++ b/frontend/src/app/components/tournament/tournaments/tournaments.component.html @@ -10,5 +10,5 @@ @for (tournament of getTournaments(); track tournament.name) { - + } diff --git a/frontend/src/app/components/tournament/tournaments/tournaments.component.ts b/frontend/src/app/components/tournament/tournaments/tournaments.component.ts index a9441bf..257a929 100644 --- a/frontend/src/app/components/tournament/tournaments/tournaments.component.ts +++ b/frontend/src/app/components/tournament/tournaments/tournaments.component.ts @@ -1,12 +1,12 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { TournamentEndpointService, TournamentListDto } from '@api'; @Component({ selector: 'app-tournaments', templateUrl: './tournaments.component.html', - styleUrl: './tournaments.component.scss', + styleUrls: ['./tournaments.component.scss'], }) -export class TournamentsComponent { +export class TournamentsComponent implements OnInit { tournaments: TournamentListDto[] = []; constructor(private tournamentService: TournamentEndpointService) {} @@ -22,8 +22,6 @@ export class TournamentsComponent { private loadTournaments() { this.tournamentService.tournaments().subscribe({ next: data => { - console.log('data'); - console.log(data); this.tournaments = data; }, error: err => { @@ -31,4 +29,8 @@ export class TournamentsComponent { }, }); } + + onTournamentDeleted() { + this.loadTournaments(); + } }