From eabbf90b188c7bad7b5e4c370ad9b2c4fabf1329 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 17 Sep 2026 14:06:25 +0200 Subject: [PATCH 1/3] fix: update delete behavior for key entity relationships to prevent throwing exception when soft deleting --- TournamentAPI/Data/ApplicationDbContext.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TournamentAPI/Data/ApplicationDbContext.cs b/TournamentAPI/Data/ApplicationDbContext.cs index 0d180ab..af104a0 100644 --- a/TournamentAPI/Data/ApplicationDbContext.cs +++ b/TournamentAPI/Data/ApplicationDbContext.cs @@ -56,7 +56,7 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(b => b.Tournament) .WithOne(t => t.Bracket) .HasForeignKey(b => b.TournamentId) - .OnDelete(DeleteBehavior.Restrict); + .OnDelete(DeleteBehavior.ClientNoAction); builder.Entity().HasQueryFilter(t => !t.IsDeleted); @@ -81,7 +81,7 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(tp => tp.Tournament) .WithMany(t => t.Participants) .HasForeignKey(tp => tp.TournamentId) - .OnDelete(DeleteBehavior.Restrict); + .OnDelete(DeleteBehavior.ClientNoAction); builder.Entity() .HasOne(tp => tp.Participant) @@ -107,7 +107,7 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(m => m.Bracket) .WithMany(b => b.Matches) .HasForeignKey(m => m.BracketId) - .OnDelete(DeleteBehavior.Restrict); + .OnDelete(DeleteBehavior.ClientNoAction); builder.Entity() .HasIndex(m => new { m.BracketId, m.Round, m.Player1Id, m.Player2Id }) From 40a004e447a496e5fb300dfd105d64f06b8d5103 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 17 Sep 2026 14:07:24 +0200 Subject: [PATCH 2/3] test: add test for soft-deleting tournaments with participants Added DeleteTournament_SoftDeletesTournamentAndParticipants test to TournamentMutationTests. Verifies that deleting a tournament with participants soft-deletes both the tournament and its participants, ensuring they are marked as deleted but not removed from the database. --- .../Tournaments/TournamentMutationTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs index 9bee63d..69dc2fd 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentMutationTests.cs @@ -280,6 +280,74 @@ public async Task DeleteTournament_DeletesTournamentSuccessfully() Assert.Null(tournamentInDb); } + [Fact] + public async Task DeleteTournament_SoftDeletesTournamentAndParticipants_WhenTournamentHasParticipants() + { + // Arrange + var email = "bob@example.com"; + var password = "Password123!"; + var tournamentToDeleteId = 8; // closed, owned by bob, has participants, no bracket + using var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new + { + input = new + { + email = email, + password = password + } + }); + client.SetAuthToken(tokenResponse.Data.LoginUser.String); + + var participantsBeforeDelete = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournamentToDeleteId) + .ToListAsync(); + Assert.NotEmpty(participantsBeforeDelete); + + var variables = new + { + input = new + { + tournamentId = tournamentToDeleteId + } + }; + + // Act + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.DeleteTournament, + variables); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data); + Assert.NotNull(response.Data.DeleteTournament); + Assert.NotNull(response.Data.DeleteTournament.Boolean); + Assert.True(response.Data.DeleteTournament.Boolean); + + var tournamentViaFilteredQuery = await DbContext.Tournaments + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == tournamentToDeleteId); + Assert.Null(tournamentViaFilteredQuery); + + var tournamentInDb = await DbContext.Tournaments + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == tournamentToDeleteId); + Assert.NotNull(tournamentInDb); + Assert.True(tournamentInDb.IsDeleted); + + var participantsAfterDelete = await DbContext.TournamentParticipants + .IgnoreQueryFilters() + .AsNoTracking() + .Where(tp => tp.TournamentId == tournamentToDeleteId) + .ToListAsync(); + Assert.Equal(participantsBeforeDelete.Count, participantsAfterDelete.Count); + Assert.All(participantsAfterDelete, tp => Assert.True(tp.IsDeleted)); + } + [Fact] public async Task DeleteTournament_ReturnsNotFoundError_WhenTournamentDoesNotExist() { From 1dc938e7af5a33da92592b3d0a20a8793b65d25f Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Thu, 17 Sep 2026 14:08:39 +0200 Subject: [PATCH 3/3] docs: replace inline API documentation with the link to published postman collection --- README.md | 662 +----------------------------------------------------- 1 file changed, 1 insertion(+), 661 deletions(-) diff --git a/README.md b/README.md index e4d7709..55b897d 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,6 @@ - [Technologies](#technologies) - [Running the API](#running-the-api) - [Detailed API Documentation](#detailed-api-documentation) - - [Authentication](#authentication) - - [Tournament Management](#tournament-management) - - [Participant Management](#participant-management) - - [Bracket Management](#bracket-management) - - [Queries](#queries) - [Things Learned](#things-learned) - [Used Resources](#used-resources) --- @@ -85,663 +80,8 @@ Open `http://localhost:3000`, log in with the default credentials (`admin` / `ad ## Detailed API Documentation +You can find the detailed API documentation in the published postman collection [here](https://documenter.getpostman.com/view/43726594/2sBYB1N89c). -### Authentication - -#### Register User - -- **Mutation:** `registerUser(input: RegisterUserInput!): Boolean` -- **Request Example:** -```json -{ - "query": "mutation RegisterUser($input: RegisterUserInput!) { registerUser(input: $input) { boolean } }", - "variables": { - "input": { - "userName": "alice", - "email": "alice@example.com", - "password": "Password123!" - } - } -} -``` -- **Response Example:** -```json -{ - "data": { - "registerUser": { - "boolean": true - } - } -} -``` -- **Notes:** Returns `true` on success. Duplicate emails are rejected. - -#### Login User - -- **Mutation:** `loginUser(input: LoginUserInput!): String` -- **Request Example:** -```json -{ - "query": "mutation LoginUser($input: LoginUserInput!) { loginUser(input: $input) { string } }", - "variables": { - "input": { - "email": "alice@example.com", - "password": "Password123!" - } - } -} -``` -- **Response Example:** -```json -{ - "data": { - "loginUser": { - "string": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - } - } -} -``` -- **Returns:** JWT token as a string. - -#### Get Current User - -- **Query:** `me: User` -- **Requires:** JWT -- **Request Example:** -```json -{ - "query": "query { me { id firstName lastName email } }", - "variables": {} -} -``` -- **Response Example:** -```json -{ - "data": { - "me": { - "id": 1, - "firstName": "Alice", - "lastName": "Smith", - "email": "alice@example.com" - } - } -} -``` - ---- - -### Tournament Management - -#### Create Tournament - -- **Mutation:** `createTournament(input: CreateTournamentInput!): CreateTournamentPayload` -- **Requires:** JWT (owner) -- **Request Example:** -```json -{ - "query": "mutation CreateTournament($input: CreateTournamentInput!) { createTournament(input: $input) { errors { ... on TournamentNameEmptyError { message } } tournament { id name startDate status ownerId } } }", - "variables": { - "input": { - "name": "Summer Championship", - "startDate": "2025-07-01T00:00:00Z", - "status": "OPEN" - } - } -} -``` -- **Response Example (Success):** -```json -{ - "data": { - "createTournament": { - "errors": null, - "tournament": { - "id": 1, - "name": "Summer Championship", - "startDate": "2025-07-01T00:00:00Z", - "status": "OPEN", - "ownerId": 1 - } - } - } -} -``` - -#### Update Tournament - -- **Mutation:** `updateTournament(input: UpdateTournamentInput!): UpdateTournamentPayload` -- **Requires:** JWT (owner) -- **Request Example:** -```json -{ - "query": "mutation UpdateTournament($input: UpdateTournamentInput!) { updateTournament(input: $input) { errors { ... on TournamentNameEmptyError { message } ... on TournamentNotFoundError { message } ... on TournamentNotOwnerError { message } } tournament { id name startDate status } } }", - "variables": { - "input": { - "tournamentId": 3, - "name": "Updated Championship Name", - "startDate": "2025-07-15T00:00:00Z", - "status": "CLOSED" - } - } -} -``` -- **Response Example (Success):** -```json -{ - "data": { - "updateTournament": { - "errors": null, - "tournament": { - "id": 3, - "name": "Updated Championship Name", - "startDate": "2025-07-15T00:00:00Z", - "status": "CLOSED" - } - } - } -} -``` - -#### Delete Tournament - -- **Mutation:** `deleteTournament(input: DeleteTournamentInput!): DeleteTournamentPayload` -- **Requires:** JWT (owner) -- **Request Example:** -```json -{ - "query": "mutation DeleteTournament($input: DeleteTournamentInput!) { deleteTournament(input: $input) { boolean errors { ... on TournamentNotFoundError { message } ... on TournamentNotOwnerError { message } } } }", - "variables": { - "input": { - "tournamentId": 3 - } - } -} -``` -- **Response Example (Success):** -```json -{ - "data": { - "deleteTournament": { - "boolean": true, - "errors": null - } - } -} -``` ---- - -### Participant Management - -#### Add Participant - -- **Mutation:** `addParticipant(input: AddParticipantInput!): AddParticipantPayload` -- **Requires:** JWT (owner) -- **Request Example:** -```json -{ - "query": "mutation AddParticipant($input: AddParticipantInput!) { addParticipant(input: $input) { errors { ... on TournamentClosedError { message } ... on TournamentNotFoundError { message } ... on TournamentNotOwnerError { message } ... on UserAlreadyParticipantError { message } ... on UserNotFoundError { message } } tournament { id name participants { participantId tournamentId participant { id firstName lastName email } } } } }", - "variables": { - "input": { - "userId": 2, - "tournamentId": 1 - } - } -} -``` -- **Response Example (Success):** -```json -{ - "data": { - "addParticipant": { - "errors": null, - "tournament": { - "id": 1, - "name": "Summer Championship", - "participants": [ - { - "participantId": 2, - "tournamentId": 1, - "participant": { - "id": 2, - "firstName": "Bob", - "lastName": "Johnson", - "email": "bob@example.com" - } - } - ] - } - } - } -} -``` - -#### Join Tournament - -- **Mutation:** `joinTournament(input: JoinTournamentInput!): JoinTournamentPayload` -- **Requires:** JWT (participant) -- **Request Example:** -```json -{ - "query": "mutation JoinTournament($input: JoinTournamentInput!) { joinTournament(input: $input) { boolean errors { ... on TournamentClosedError { message } ... on UserAlreadyParticipantError { message } ... on TournamentNotFoundError { message } } } }", - "variables": { - "input": { - "tournamentId": 1 - } - } -} -``` -- **Response Example (Success):** -```json -{ - "data": { - "joinTournament": { - "boolean": true, - "errors": null - } - } -} -``` - ---- - -### Bracket Management - -#### Generate Bracket - -- **Mutation:** `generateBracket(input: GenerateBracketInput!): GenerateBracketPayload` -- **Requires:** JWT (owner, tournament must be closed) -- **Request Example:** -```json -{ - "query": "mutation GenerateBracket($input: GenerateBracketInput!) { generateBracket(input: $input) { bracket { id tournamentId matches { id round player1Id player2Id winnerId } } errors { ... on BracketAlreadyExistsError { message } ... on BracketGenerationNotAllowedError { message } ... on NotEnoughParticipantsError { message } ... on TournamentNotFoundError { message } ... on TournamentNotOwnerError { message } } } }", - "variables": { - "input": { - "tournamentId": 1 - } - } -} -``` -- **Response Example (Success):** -```json -{ - "data": { - "generateBracket": { - "errors": null, - "bracket": { - "id": 1, - "tournamentId": 1, - "matches": [ - { - "id": 1, - "round": 1, - "player1Id": 2, - "player2Id": 3, - "winnerId": null - }, - { - "id": 2, - "round": 1, - "player1Id": 4, - "player2Id": 5, - "winnerId": null - } - ] - } - } - } -} -``` - -#### Play Match - -- **Mutation:** `play(input: PlayInput!): PlayPayload` -- **Requires:** JWT (owner) -- **Request Example:** -```json -{ - "query": "mutation Play($input: PlayInput!) { play(input: $input) { boolean errors { ... on InvalidMatchWinnerError { message } ... on MatchAlreadyPlayedError { message } ... on MatchNotFoundError { message } ... on TournamentNotClosedError { message } ... on TournamentNotOwnerError { message } } } }", - "variables": { - "input": { - "matchId": 1, - "winnerId": 2 - } - } -} -``` -- **Response Example (Success):** -```json -{ - "data": { - "play": { - "boolean": true, - "errors": null - } - } -} -``` - -#### Update Round - -- **Mutation:** `updateRound(input: UpdateRoundInput!): UpdateRoundPayload` -- **Requires:** JWT (owner) -- **Request Example:** -```json -{ - "query": "mutation UpdateRound($input: UpdateRoundInput!) { updateRound(input: $input) { bracket { id tournamentId matches { id round player1Id player2Id winnerId } } errors { ... on BracketNotFoundError { message } ... on NoMatchesInRoundError { message } ... on NotAllMatchesPlayedError { message } ... on TournamentNotOwnerError { message } ... on BracketAlreadyHasWinnerError { message } ... on NextRoundAlreadyGeneratedError { message } } } }", - "variables": { - "input": { - "bracketId": 1, - "roundNumber": 1 - } - } -} -``` -- **Response Example (Success):** -```json -{ - "data": { - "updateRound": { - "errors": null, - "bracket": { - "id": 1, - "tournamentId": 1, - "matches": [ - { - "id": 1, - "round": 1, - "player1Id": 2, - "player2Id": 3, - "winnerId": 2 - }, - { - "id": 2, - "round": 1, - "player1Id": 4, - "player2Id": 5, - "winnerId": 5 - }, - { - "id": 3, - "round": 2, - "player1Id": 2, - "player2Id": 5, - "winnerId": null - } - ] - } - } - } -} -``` ---- - -### Queries - -#### Get All Tournaments - -- **Query:** `tournaments(first: Int, after: String, where: TournamentFilterInput, order: [TournamentSortInput!]): TournamentsConnection` -- **Supports:** Paging, filtering, sorting -- **Request Example (Basic):** -```json -{ - "query": "query { tournaments(first: 10) { totalCount edges { cursor node { id name startDate status ownerId } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }", - "variables": {} -} -``` -- **Request Example (With Filtering):** -```json -{ - "query": "query GetTournaments($nameFilter: String!) { tournaments(first: 10, where: { name: { contains: $nameFilter } }) { totalCount edges { cursor node { id name startDate status ownerId } } } }", - "variables": { - "nameFilter": "Championship" - } -} -``` -- **Request Example (With Sorting):** -```json -{ - "query": "query { tournaments(first: 10, order: { name: DESC }) { totalCount edges { cursor node { id name startDate status ownerId } } } }", - "variables": {} -} -``` -- **Request Example (With Owner Details):** -```json -{ - "query": "query { tournaments(first: 10) { totalCount edges { cursor node { id name startDate status ownerId owner { id firstName lastName email } } } } }", - "variables": {} -} -``` -- **Response Example:** -```json -{ - "data": { - "tournaments": { - "totalCount": 5, - "edges": [ - { - "cursor": "MA==", - "node": { - "id": 1, - "name": "Summer Championship", - "startDate": "2025-07-01T00:00:00Z", - "status": "OPEN", - "ownerId": 1 - } - }, - { - "cursor": "MQ==", - "node": { - "id": 2, - "name": "Winter Tournament", - "startDate": "2025-12-01T00:00:00Z", - "status": "CLOSED", - "ownerId": 2 - } - } - ], - "pageInfo": { - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "MA==", - "endCursor": "MQ==" - } - } - } -} -``` - -#### Get Tournament By ID - -- **Query:** `tournamentById(id: Int!): Tournament` -- **Request Example (Basic):** -```json -{ - "query": "query GetTournamentById($id: Int!) { tournamentById(id: $id) { id name startDate status ownerId } }", - "variables": { - "id": 1 - } -} -``` -- **Request Example (With Participants and Bracket):** -```json -{ - "query": "query GetTournamentById($id: Int!) { tournamentById(id: $id) { id name startDate status ownerId bracket { id tournamentId matches { id round player1Id player2Id winnerId player1 { id firstName lastName email } player2 { id firstName lastName email } winner { id firstName lastName email } } } participants { participantId participant { id firstName lastName email } } } }", - "variables": { - "id": 1 - } -} -``` -- **Response Example:** -```json -{ - "data": { - "tournamentById": { - "id": 1, - "name": "Summer Championship", - "startDate": "2025-07-01T00:00:00Z", - "status": "CLOSED", - "ownerId": 1, - "bracket": { - "id": 1, - "tournamentId": 1, - "matches": [ - { - "id": 1, - "round": 1, - "player1Id": 2, - "player2Id": 3, - "winnerId": 2, - "player1": { - "id": 2, - "firstName": "Bob", - "lastName": "Johnson", - "email": "bob@example.com" - }, - "player2": { - "id": 3, - "firstName": "Carol", - "lastName": "Williams", - "email": "carol@example.com" - }, - "winner": { - "id": 2, - "firstName": "Bob", - "lastName": "Johnson", - "email": "bob@example.com" - } - } - ] - }, - "participants": [ - { - "participantId": 2, - "participant": { - "id": 2, - "firstName": "Bob", - "lastName": "Johnson", - "email": "bob@example.com" - } - }, - { - "participantId": 3, - "participant": { - "id": 3, - "firstName": "Carol", - "lastName": "Williams", - "email": "carol@example.com" - } - } - ] - } - } -} -``` - -#### Get Matches For Round - -- **Query:** `matchesForRound(tournamentId: Int!, roundNumber: Int!): [Match]` -- **Request Example (Basic):** -```json -{ - "query": "query GetMatchesForRound($tournamentId: Int!, $roundNumber: Int!) { matchesForRound(tournamentId: $tournamentId, roundNumber: $roundNumber) { id round bracketId player1Id player2Id winnerId } }", - "variables": { - "tournamentId": 1, - "roundNumber": 1 - } -} -``` -- **Request Example (With Player Details):** -```json -{ - "query": "query GetMatchesForRound($tournamentId: Int!, $roundNumber: Int!) { matchesForRound(tournamentId: $tournamentId, roundNumber: $roundNumber) { id round bracketId player1Id player2Id winnerId player1 { id firstName lastName email } player2 { id firstName lastName email } winner { id firstName lastName email } } }", - "variables": { - "tournamentId": 1, - "roundNumber": 1 - } -} -``` -- **Response Example:** -```json -{ - "data": { - "matchesForRound": [ - { - "id": 1, - "round": 1, - "bracketId": 1, - "player1Id": 2, - "player2Id": 3, - "winnerId": 2, - "player1": { - "id": 2, - "firstName": "Bob", - "lastName": "Johnson", - "email": "bob@example.com" - }, - "player2": { - "id": 3, - "firstName": "Carol", - "lastName": "Williams", - "email": "carol@example.com" - }, - "winner": { - "id": 2, - "firstName": "Bob", - "lastName": "Johnson", - "email": "bob@example.com" - } - }, - { - "id": 2, - "round": 1, - "bracketId": 1, - "player1Id": 4, - "player2Id": 5, - "winnerId": null, - "player1": { - "id": 4, - "firstName": "David", - "lastName": "Brown", - "email": "david@example.com" - }, - "player2": { - "id": 5, - "firstName": "Eve", - "lastName": "Davis", - "email": "eve@example.com" - }, - "winner": null - } - ] - } -} -``` - ---- - -## HTTP Request Information - -**Endpoint:** All GraphQL requests are sent to `/graphql` - -**Method:** `POST` - -**Headers:** -- `Content-Type: application/json` -- `Authorization: Bearer ` (for authenticated requests) - -**Request Body Structure:** -```json -{ - "query": "GraphQL query or mutation string", - "variables": { - "variableName": "value" - } -} -``` ---- - -**Note:** All GraphQL requests are sent to `/graphql` endpoint. For mutations requiring authentication, include the JWT in the `Authorization` header as `Bearer `. - ## Things Learned - Implementing JWT authentication in ASP.NET Core. - Setting up a GraphQL server using HotChocolate.