diff --git a/api/openapi.yaml b/api/openapi.yaml index 92163ec..62d3c70 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -15,7 +15,9 @@ paths: tags: - organization summary: Get all sports - description: Returns a list of all sports registered in the organization. If the caller is not an admin, only sports that the caller is a member of will be returned. + description: | + Returns a list of all sports registered in the organization. + - All authenticated users: can access this endpoint. responses: "200": description: The request was successful, and the server has returned the requested resource in the response body. @@ -39,7 +41,9 @@ paths: tags: - organization summary: Create sport - description: Creates a new sport in the organization. Only admins are allowed to create new sports. + description: | + Creates a new sport in the organization. + - Admins: can create sports. responses: "201": description: The request was successful, and a new resource was created. @@ -72,6 +76,10 @@ paths: tags: - organization summary: Update sport + description: | + Partially updates an existing sport's details. + - Directors: can update all fields except directors. + - Admins: can update all fields. parameters: - $ref: "#/components/parameters/sport_name" responses: @@ -106,6 +114,9 @@ paths: tags: - organization summary: Delete sport + description: | + Deletes a sport from the organization. + - Admins: can delete sports. parameters: - $ref: "#/components/parameters/sport_name" responses: @@ -127,6 +138,9 @@ paths: tags: - organization summary: Get sport + description: | + Returns the details of a specific sport. + - All authenticated users: can access this endpoint. parameters: - $ref: "#/components/parameters/sport_name" responses: @@ -153,6 +167,9 @@ paths: tags: - organization summary: Get all teams + description: | + Returns a list of all teams in the organization. + - All authenticated users: can access this endpoint. responses: "200": description: The request was successful, and the server has returned the @@ -176,6 +193,10 @@ paths: tags: - organization summary: Create team + description: | + Creates a new team in the organization. + - Directors: can create teams for their own sport. + - Admins: can create teams for any sport. responses: "201": description: The request was successful, and a new resource was created. @@ -208,6 +229,9 @@ paths: tags: - organization summary: Get team + description: | + Returns the details of a specific team. + - All authenticated users: can access this endpoint. parameters: - $ref: "#/components/parameters/team_id" responses: @@ -233,6 +257,11 @@ paths: tags: - organization summary: Update team + description: | + Partially updates a team's details. + - Trainers: can update all fields except sport and trainers. + - Directors: can update all fields except sport. + - Admins: can update all fields. parameters: - $ref: "#/components/parameters/team_id" responses: @@ -267,6 +296,10 @@ paths: tags: - organization summary: Delete team + description: | + Deletes a team from the organization. + - Directors: can delete teams in their sport. + - Admins: can delete any team. parameters: - $ref: "#/components/parameters/team_id" responses: @@ -289,6 +322,9 @@ paths: tags: - members summary: Get all members + description: | + Returns a list of all members in the organization. + - All authenticated users: can access this endpoint. responses: "200": description: The request was successful, and the server has returned the @@ -312,6 +348,9 @@ paths: tags: - members summary: Create member + description: | + Creates a new member in the organization. Includes a password field for setting initial credentials. + - Admins: can create members. responses: "201": description: The request was successful, and a new resource was created. @@ -344,6 +383,13 @@ paths: tags: - members summary: Get member details + description: | + Returns the full details of a specific member. + - Members themselves: can view their own details. + - Team members: can view details of others in the same team. + - Trainers: can view details of members in their team. + - Directors: can view details of members in their sport. + - Admins: can view any member's details. parameters: - $ref: "#/components/parameters/member_id" responses: @@ -369,6 +415,10 @@ paths: tags: - members summary: Update member details + description: | + Partially updates the details of a specific member. + - Members themselves: can update their own details. + - Admins: can update any member's details. parameters: - $ref: "#/components/parameters/member_id" responses: @@ -403,6 +453,9 @@ paths: tags: - members summary: Delete member + description: | + Deletes a member from the organization. + - Admins: can delete members. parameters: - $ref: "#/components/parameters/member_id" responses: @@ -425,6 +478,11 @@ paths: tags: - events summary: Get all events + description: | + Returns a list of all events. + - All authenticated users: can see events linked to their team or sport, or events where they are an attendee. + - Creators: can see all events they created. + - Admins: can see all events. responses: "200": description: The request was successful, and the server has returned the @@ -448,6 +506,11 @@ paths: tags: - events summary: Create event + description: | + Creates a new event. + - Directors: can create events for their sport. + - Trainers: can create events for their team. + - Admins: can create any event. responses: "201": description: The request was successful, and a new resource was created. @@ -480,6 +543,11 @@ paths: tags: - events summary: Get event details + description: | + Returns the details of a specific event. + - All authenticated users: can access events linked to their team or sport, or events where they are an attendee. + - Creators: can view events they created. + - Admins: can view any event. parameters: - $ref: "#/components/parameters/event_id" responses: @@ -505,6 +573,11 @@ paths: tags: - events summary: Update event details + description: | + Partially updates the details of a specific event. + - Creators: can update events they created. + - Directors: can update events linked to their sport. + - Admins: can update any event. parameters: - $ref: "#/components/parameters/event_id" responses: @@ -539,6 +612,11 @@ paths: tags: - events summary: Delete event + description: | + Deletes a specific event. + - Creators: can delete events they created. + - Directors: can delete events linked to their sport. + - Admins: can delete any event. parameters: - $ref: "#/components/parameters/event_id" responses: @@ -561,6 +639,11 @@ paths: tags: - feedback summary: Get all feedback + description: | + Returns a list of all feedback entries. + - Creators: can see feedback they submitted. + - Members: can see feedback about themselves. + - Admins: can see all feedback. responses: "200": description: The request was successful, and the server has returned the @@ -584,6 +667,10 @@ paths: tags: - feedback summary: Create feedback + description: | + Creates a new feedback entry for a member. + - Trainers: can create feedback for their trainees. + - Admins: can create feedback for any member. responses: "201": description: The request was successful, and a new resource was created. @@ -616,6 +703,11 @@ paths: tags: - feedback summary: Get feedback details + description: | + Returns the details of a specific feedback entry. + - Creators: can view feedback they submitted. + - Members: can view feedback about themselves. + - Admins: can view any feedback. parameters: - $ref: "#/components/parameters/feedback_id" responses: @@ -641,6 +733,10 @@ paths: tags: - feedback summary: Delete feedback + description: | + Deletes a specific feedback entry. + - Creators: can delete feedback they submitted. + - Admins: can delete any feedback. parameters: - $ref: "#/components/parameters/feedback_id" responses: @@ -662,6 +758,10 @@ paths: tags: - feedback summary: Update feedback details + description: | + Partially updates a specific feedback entry. + - Creators: can update feedback they submitted. + - Admins: can update any feedback. parameters: - $ref: "#/components/parameters/feedback_id" responses: @@ -697,6 +797,10 @@ paths: tags: - finance summary: Get all balances + description: | + Returns a list of all member balances. + - Directors: can view balances of members in their sport. + - Admins: can view all balances. responses: "200": description: The request was successful, and the server has returned the @@ -721,6 +825,11 @@ paths: tags: - finance summary: Get member balance + description: | + Returns the balance of a specific member. + - Members themselves: can view their own balance. + - Directors: can view balances of members in their sport. + - Admins: can view any member's balance. parameters: - $ref: "#/components/parameters/member_id" responses: @@ -747,6 +856,12 @@ paths: tags: - finance summary: Get all transactions + description: | + Returns a list of all transactions. Users only see transactions where they are the member or the creator. + - Members: can see transactions they are part of. + - Creators: can see transactions they created. + - Directors: can see transactions for members in their sport. + - Admins: can see all transactions. responses: "200": description: The request was successful, and the server has returned the @@ -770,6 +885,10 @@ paths: tags: - finance summary: Create transaction + description: | + Creates a new financial transaction for a member. + - Directors: can create transactions for members in their sport. + - Admins: can create transactions for any member. responses: "201": description: The request was successful, and a new resource was created. @@ -802,6 +921,12 @@ paths: tags: - finance summary: Get transaction + description: | + Returns the details of a specific transaction. + - Members: can view transactions they are part of. + - Creators: can view transactions they created. + - Directors: can view transactions for members in their sport. + - Admins: can view any transaction. parameters: - $ref: "#/components/parameters/transaction_id" responses: @@ -827,6 +952,10 @@ paths: tags: - finance summary: Update transaction + description: | + Partially updates a specific transaction. The member field can only be changed by admins. + - Creators: can update transactions they created (except the member field). + - Admins: can update any transaction including the member field. parameters: - $ref: "#/components/parameters/transaction_id" responses: @@ -861,6 +990,10 @@ paths: tags: - finance summary: Delete transaction + description: | + Deletes a specific transaction. + - Creators: can delete transactions they created. + - Admins: can delete any transaction. parameters: - $ref: "#/components/parameters/transaction_id" responses: @@ -883,6 +1016,11 @@ paths: tags: - letters summary: Send mail + description: | + Sends an email based on the provided HTML template. + - Trainers: can send mail to members of their team. + - Directors: can send mail to members related to their sport. + - Admins: can send mail to any member. responses: "204": $ref: "#/components/responses/NoContent" @@ -910,6 +1048,11 @@ paths: tags: - letters summary: Get pdf + description: | + Generates and returns a PDF document from the provided HTML template. + - Trainers: can generate PDFs related to their team. + - Directors: can generate PDFs related to their sport. + - Admins: can generate PDFs related to any member. responses: "200": description: The request was successful, and the server has returned the @@ -943,6 +1086,11 @@ paths: tags: - helper summary: Generate report + description: | + Generates an AI-based report for a member. Members can only generate reports for themselves. + - All authenticated users: can generate a report for themselves. + - Trainers: can generate reports for members of their team. + - Admin: can generate a report for any member. responses: "200": description: The request was successful, and the server has returned the @@ -1284,6 +1432,9 @@ components: type: string email: type: string + password: + type: string + format: password birthday: type: string format: date @@ -1297,6 +1448,7 @@ components: - first_name - last_name - email + - password description: Data transfer object for creating a new Member. Event: type: object diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index 5b07de0..65b8b3b 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,9 +1,9 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-12T12:58:15+00:00 +# timestamp: 2026-06-18T08:03:34+00:00 from __future__ import annotations -from pydantic import AwareDatetime, BaseModel, Field +from pydantic import AwareDatetime, BaseModel, Field, SecretStr from datetime import date from uuid import UUID from typing import Annotated @@ -99,6 +99,7 @@ class MemberCreate(BaseModel): first_name: str last_name: str email: str + password: SecretStr birthday: date | None = None phone_number: str | None = None address: str | None = None diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/api/EventsApi.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/api/EventsApi.java index ed7d69c..8567f1f 100644 --- a/services/spring-event/src/generated/java/tum/devoops/eventservice/api/EventsApi.java +++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/api/EventsApi.java @@ -51,6 +51,7 @@ default Optional getRequest() { /** * POST /events : Create event + * Creates a new event. - Directors: can create events for their sport. - Trainers: can create events for their team. - Admins: can create any event. * * @param eventCreate The request body for creating a new event. (required) * @return The request was successful, and a new resource was created. (status code 201) @@ -63,6 +64,7 @@ default Optional getRequest() { @Operation( operationId = "createEvent", summary = "Create event", + description = "Creates a new event. - Directors: can create events for their sport. - Trainers: can create events for their team. - Admins: can create any event. ", tags = { "events" }, responses = { @ApiResponse(responseCode = "201", description = "The request was successful, and a new resource was created.", content = { @@ -139,6 +141,7 @@ default ResponseEntity createEvent( /** * DELETE /events/{event_id} : Delete event + * Deletes a specific event. - Creators: can delete events they created. - Directors: can delete events linked to their sport. - Admins: can delete any event. * * @param eventId (required) * @return The request was successful, but there is no content to return in the response. (status code 204) @@ -150,6 +153,7 @@ default ResponseEntity createEvent( @Operation( operationId = "deleteEvent", summary = "Delete event", + description = "Deletes a specific event. - Creators: can delete events they created. - Directors: can delete events linked to their sport. - Admins: can delete any event. ", tags = { "events" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), @@ -210,6 +214,7 @@ default ResponseEntity deleteEvent( /** * GET /events : Get all events + * Returns a list of all events. - All authenticated users: can see events linked to their team or sport, or events where they are an attendee. - Creators: can see all events they created. - Admins: can see all events. * * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -219,6 +224,7 @@ default ResponseEntity deleteEvent( @Operation( operationId = "getAllEvents", summary = "Get all events", + description = "Returns a list of all events. - All authenticated users: can see events linked to their team or sport, or events where they are an attendee. - Creators: can see all events they created. - Admins: can see all events. ", tags = { "events" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -278,6 +284,7 @@ default ResponseEntity> getAllEvents( /** * GET /events/{event_id} : Get event details + * Returns the details of a specific event. - All authenticated users: can access events linked to their team or sport, or events where they are an attendee. - Creators: can view events they created. - Admins: can view any event. * * @param eventId (required) * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) @@ -289,6 +296,7 @@ default ResponseEntity> getAllEvents( @Operation( operationId = "getEventDetails", summary = "Get event details", + description = "Returns the details of a specific event. - All authenticated users: can access events linked to their team or sport, or events where they are an attendee. - Creators: can view events they created. - Admins: can view any event. ", tags = { "events" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -356,6 +364,7 @@ default ResponseEntity getEventDetails( /** * PATCH /events/{event_id} : Update event details + * Partially updates the details of a specific event. - Creators: can update events they created. - Directors: can update events linked to their sport. - Admins: can update any event. * * @param eventId (required) * @param eventPartialUpdate The request body for partially updating an event. (required) @@ -369,6 +378,7 @@ default ResponseEntity getEventDetails( @Operation( operationId = "updateEventDetails", summary = "Update event details", + description = "Partially updates the details of a specific event. - Creators: can update events they created. - Directors: can update events linked to their sport. - Admins: can update any event. ", tags = { "events" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java index 8b87aef..03ecfe3 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java @@ -51,6 +51,7 @@ default Optional getRequest() { /** * POST /feedback : Create feedback + * Creates a new feedback entry for a member. - Trainers: can create feedback for their trainees. - Admins: can create feedback for any member. * * @param feedbackCreate The request body for creating new feedback. (required) * @return The request was successful, and a new resource was created. (status code 201) @@ -63,6 +64,7 @@ default Optional getRequest() { @Operation( operationId = "createFeedback", summary = "Create feedback", + description = "Creates a new feedback entry for a member. - Trainers: can create feedback for their trainees. - Admins: can create feedback for any member. ", tags = { "feedback" }, responses = { @ApiResponse(responseCode = "201", description = "The request was successful, and a new resource was created.", content = { @@ -139,6 +141,7 @@ default ResponseEntity createFeedback( /** * DELETE /feedback/{feedback_id} : Delete feedback + * Deletes a specific feedback entry. - Creators: can delete feedback they submitted. - Admins: can delete any feedback. * * @param feedbackId (required) * @return The request was successful, but there is no content to return in the response. (status code 204) @@ -150,6 +153,7 @@ default ResponseEntity createFeedback( @Operation( operationId = "deleteFeedback", summary = "Delete feedback", + description = "Deletes a specific feedback entry. - Creators: can delete feedback they submitted. - Admins: can delete any feedback. ", tags = { "feedback" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), @@ -210,6 +214,7 @@ default ResponseEntity deleteFeedback( /** * GET /feedback : Get all feedback + * Returns a list of all feedback entries. - Creators: can see feedback they submitted. - Members: can see feedback about themselves. - Admins: can see all feedback. * * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -219,6 +224,7 @@ default ResponseEntity deleteFeedback( @Operation( operationId = "getAllFeedback", summary = "Get all feedback", + description = "Returns a list of all feedback entries. - Creators: can see feedback they submitted. - Members: can see feedback about themselves. - Admins: can see all feedback. ", tags = { "feedback" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -278,6 +284,7 @@ default ResponseEntity> getAllFeedback( /** * GET /feedback/{feedback_id} : Get feedback details + * Returns the details of a specific feedback entry. - Creators: can view feedback they submitted. - Members: can view feedback about themselves. - Admins: can view any feedback. * * @param feedbackId (required) * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) @@ -289,6 +296,7 @@ default ResponseEntity> getAllFeedback( @Operation( operationId = "getFeedbackDetails", summary = "Get feedback details", + description = "Returns the details of a specific feedback entry. - Creators: can view feedback they submitted. - Members: can view feedback about themselves. - Admins: can view any feedback. ", tags = { "feedback" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -356,6 +364,7 @@ default ResponseEntity getFeedbackDetails( /** * PATCH /feedback/{feedback_id} : Update feedback details + * Partially updates a specific feedback entry. - Creators: can update feedback they submitted. - Admins: can update any feedback. * * @param feedbackId (required) * @param feedbackPartialUpdate The request body for partially updating a specific feedback. (required) @@ -369,6 +378,7 @@ default ResponseEntity getFeedbackDetails( @Operation( operationId = "updateFeedbackDetails", summary = "Update feedback details", + description = "Partially updates a specific feedback entry. - Creators: can update feedback they submitted. - Admins: can update any feedback. ", tags = { "feedback" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { diff --git a/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java b/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java index 14c0dda..26dd235 100644 --- a/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java +++ b/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java @@ -51,6 +51,7 @@ default Optional getRequest() { /** * POST /finance/transactions : Create transaction + * Creates a new financial transaction for a member. - Directors: can create transactions for members in their sport. - Admins: can create transactions for any member. * * @param transactionCreate The request body for creating a new transaction. (required) * @return The request was successful, and a new resource was created. (status code 201) @@ -63,6 +64,7 @@ default Optional getRequest() { @Operation( operationId = "createTransaction", summary = "Create transaction", + description = "Creates a new financial transaction for a member. - Directors: can create transactions for members in their sport. - Admins: can create transactions for any member. ", tags = { "finance" }, responses = { @ApiResponse(responseCode = "201", description = "The request was successful, and a new resource was created.", content = { @@ -139,6 +141,7 @@ default ResponseEntity createTransaction( /** * DELETE /finance/transactions/{transaction_id} : Delete transaction + * Deletes a specific transaction. - Creators: can delete transactions they created. - Admins: can delete any transaction. * * @param transactionId (required) * @return The request was successful, but there is no content to return in the response. (status code 204) @@ -150,6 +153,7 @@ default ResponseEntity createTransaction( @Operation( operationId = "deleteTransaction", summary = "Delete transaction", + description = "Deletes a specific transaction. - Creators: can delete transactions they created. - Admins: can delete any transaction. ", tags = { "finance" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), @@ -210,6 +214,7 @@ default ResponseEntity deleteTransaction( /** * GET /finance/balances : Get all balances + * Returns a list of all member balances. - Directors: can view balances of members in their sport. - Admins: can view all balances. * * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -219,6 +224,7 @@ default ResponseEntity deleteTransaction( @Operation( operationId = "getAllBalances", summary = "Get all balances", + description = "Returns a list of all member balances. - Directors: can view balances of members in their sport. - Admins: can view all balances. ", tags = { "finance" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -278,6 +284,7 @@ default ResponseEntity> getAllBalances( /** * GET /finance/transactions : Get all transactions + * Returns a list of all transactions. Users only see transactions where they are the member or the creator. - Members: can see transactions they are part of. - Creators: can see transactions they created. - Directors: can see transactions for members in their sport. - Admins: can see all transactions. * * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -287,6 +294,7 @@ default ResponseEntity> getAllBalances( @Operation( operationId = "getAllTransactions", summary = "Get all transactions", + description = "Returns a list of all transactions. Users only see transactions where they are the member or the creator. - Members: can see transactions they are part of. - Creators: can see transactions they created. - Directors: can see transactions for members in their sport. - Admins: can see all transactions. ", tags = { "finance" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -346,6 +354,7 @@ default ResponseEntity> getAllTransactions( /** * GET /finance/balances/{member_id} : Get member balance + * Returns the balance of a specific member. - Members themselves: can view their own balance. - Directors: can view balances of members in their sport. - Admins: can view any member's balance. * * @param memberId (required) * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) @@ -357,6 +366,7 @@ default ResponseEntity> getAllTransactions( @Operation( operationId = "getMemberBalance", summary = "Get member balance", + description = "Returns the balance of a specific member. - Members themselves: can view their own balance. - Directors: can view balances of members in their sport. - Admins: can view any member's balance. ", tags = { "finance" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -424,6 +434,7 @@ default ResponseEntity getMemberBalance( /** * GET /finance/transactions/{transaction_id} : Get transaction + * Returns the details of a specific transaction. - Members: can view transactions they are part of. - Creators: can view transactions they created. - Directors: can view transactions for members in their sport. - Admins: can view any transaction. * * @param transactionId (required) * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) @@ -435,6 +446,7 @@ default ResponseEntity getMemberBalance( @Operation( operationId = "getTransaction", summary = "Get transaction", + description = "Returns the details of a specific transaction. - Members: can view transactions they are part of. - Creators: can view transactions they created. - Directors: can view transactions for members in their sport. - Admins: can view any transaction. ", tags = { "finance" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -502,6 +514,7 @@ default ResponseEntity getTransaction( /** * PATCH /finance/transactions/{transaction_id} : Update transaction + * Partially updates a specific transaction. The member field can only be changed by admins. - Creators: can update transactions they created (except the member field). - Admins: can update any transaction including the member field. * * @param transactionId (required) * @param transactionPartialUpdate The request body for partially updating a transaction. (required) @@ -515,6 +528,7 @@ default ResponseEntity getTransaction( @Operation( operationId = "updateTransaction", summary = "Update transaction", + description = "Partially updates a specific transaction. The member field can only be changed by admins. - Creators: can update transactions they created (except the member field). - Admins: can update any transaction including the member field. ", tags = { "finance" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { diff --git a/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java b/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java index 44886ba..57d5639 100644 --- a/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java +++ b/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java @@ -46,6 +46,7 @@ default Optional getRequest() { /** * POST /letters/pdf : Get pdf + * Generates and returns a PDF document from the provided HTML template. - Trainers: can generate PDFs related to their team. - Directors: can generate PDFs related to their sport. - Admins: can generate PDFs related to any member. * * @param body The request body for generating a pdf from a template. It must be a valid HTML string using the template format with placeholders for dynamic content. (required) * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) @@ -57,6 +58,7 @@ default Optional getRequest() { @Operation( operationId = "getPdf", summary = "Get pdf", + description = "Generates and returns a PDF document from the provided HTML template. - Trainers: can generate PDFs related to their team. - Directors: can generate PDFs related to their sport. - Admins: can generate PDFs related to any member. ", tags = { "letters" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -125,6 +127,7 @@ default ResponseEntity getPdf( /** * POST /letters/mail : Send mail + * Sends an email based on the provided HTML template. - Trainers: can send mail to members of their team. - Directors: can send mail to members related to their sport. - Admins: can send mail to any member. * * @param body The request body for sending mail. It will be used in the email content. It must be a valid HTML string using the template format with placeholders for dynamic content. (required) * @return The request was successful, but there is no content to return in the response. (status code 204) @@ -136,6 +139,7 @@ default ResponseEntity getPdf( @Operation( operationId = "sendMail", summary = "Send mail", + description = "Sends an email based on the provided HTML template. - Trainers: can send mail to members of their team. - Directors: can send mail to members related to their sport. - Admins: can send mail to any member. ", tags = { "letters" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/api/MembersApi.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/api/MembersApi.java index cebbf57..9f7a8e6 100644 --- a/services/spring-member/src/generated/java/tum/devoops/memberservice/api/MembersApi.java +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/api/MembersApi.java @@ -51,6 +51,7 @@ default Optional getRequest() { /** * POST /members : Create member + * Creates a new member in the organization. Includes a password field for setting initial credentials. - Admins: can create members. * * @param memberCreate The request body for creating a new member. (required) * @return The request was successful, and a new resource was created. (status code 201) @@ -63,6 +64,7 @@ default Optional getRequest() { @Operation( operationId = "createMember", summary = "Create member", + description = "Creates a new member in the organization. Includes a password field for setting initial credentials. - Admins: can create members. ", tags = { "members" }, responses = { @ApiResponse(responseCode = "201", description = "The request was successful, and a new resource was created.", content = { @@ -139,6 +141,7 @@ default ResponseEntity createMember( /** * DELETE /members/{member_id} : Delete member + * Deletes a member from the organization. - Admins: can delete members. * * @param memberId (required) * @return The request was successful, but there is no content to return in the response. (status code 204) @@ -150,6 +153,7 @@ default ResponseEntity createMember( @Operation( operationId = "deleteMember", summary = "Delete member", + description = "Deletes a member from the organization. - Admins: can delete members. ", tags = { "members" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), @@ -210,6 +214,7 @@ default ResponseEntity deleteMember( /** * GET /members : Get all members + * Returns a list of all members in the organization. - All authenticated users: can access this endpoint. * * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -219,6 +224,7 @@ default ResponseEntity deleteMember( @Operation( operationId = "getAllMembers", summary = "Get all members", + description = "Returns a list of all members in the organization. - All authenticated users: can access this endpoint. ", tags = { "members" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -278,6 +284,7 @@ default ResponseEntity> getAllMembers( /** * GET /members/{member_id} : Get member details + * Returns the full details of a specific member. - Members themselves: can view their own details. - Team members: can view details of others in the same team. - Trainers: can view details of members in their team. - Directors: can view details of members in their sport. - Admins: can view any member's details. * * @param memberId (required) * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) @@ -289,6 +296,7 @@ default ResponseEntity> getAllMembers( @Operation( operationId = "getMemberDetails", summary = "Get member details", + description = "Returns the full details of a specific member. - Members themselves: can view their own details. - Team members: can view details of others in the same team. - Trainers: can view details of members in their team. - Directors: can view details of members in their sport. - Admins: can view any member's details. ", tags = { "members" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -356,6 +364,7 @@ default ResponseEntity getMemberDetails( /** * PATCH /members/{member_id} : Update member details + * Partially updates the details of a specific member. - Members themselves: can update their own details. - Admins: can update any member's details. * * @param memberId (required) * @param memberPartialUpdate The request body for partially updating a member. (required) @@ -369,6 +378,7 @@ default ResponseEntity getMemberDetails( @Operation( operationId = "updateMemberDetails", summary = "Update member details", + description = "Partially updates the details of a specific member. - Members themselves: can update their own details. - Admins: can update any member's details. ", tags = { "members" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/MemberCreate.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/MemberCreate.java index c4c2cac..561edbe 100644 --- a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/MemberCreate.java +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/MemberCreate.java @@ -30,6 +30,8 @@ public class MemberCreate { private String email; + private String password; + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private @Nullable LocalDate birthday; @@ -46,10 +48,11 @@ public MemberCreate() { /** * Constructor with only required parameters */ - public MemberCreate(String firstName, String lastName, String email) { + public MemberCreate(String firstName, String lastName, String email, String password) { this.firstName = firstName; this.lastName = lastName; this.email = email; + this.password = password; } public MemberCreate firstName(String firstName) { @@ -112,6 +115,26 @@ public void setEmail(String email) { this.email = email; } + public MemberCreate password(String password) { + this.password = password; + return this; + } + + /** + * Get password + * @return password + */ + @NotNull + @Schema(name = "password", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("password") + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + public MemberCreate birthday(@Nullable LocalDate birthday) { this.birthday = birthday; return this; @@ -204,6 +227,7 @@ public boolean equals(Object o) { return Objects.equals(this.firstName, memberCreate.firstName) && Objects.equals(this.lastName, memberCreate.lastName) && Objects.equals(this.email, memberCreate.email) && + Objects.equals(this.password, memberCreate.password) && Objects.equals(this.birthday, memberCreate.birthday) && Objects.equals(this.phoneNumber, memberCreate.phoneNumber) && Objects.equals(this.address, memberCreate.address) && @@ -212,7 +236,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(firstName, lastName, email, birthday, phoneNumber, address, information); + return Objects.hash(firstName, lastName, email, password, birthday, phoneNumber, address, information); } @Override @@ -222,6 +246,7 @@ public String toString() { sb.append(" firstName: ").append(toIndentedString(firstName)).append("\n"); sb.append(" lastName: ").append(toIndentedString(lastName)).append("\n"); sb.append(" email: ").append(toIndentedString(email)).append("\n"); + sb.append(" password: ").append("*").append("\n"); sb.append(" birthday: ").append(toIndentedString(birthday)).append("\n"); sb.append(" phoneNumber: ").append(toIndentedString(phoneNumber)).append("\n"); sb.append(" address: ").append(toIndentedString(address)).append("\n"); diff --git a/services/spring-organization/config/checkstyle/checkstyle.xml b/services/spring-organization/config/checkstyle/checkstyle.xml index 7e1b5e0..8a1a908 100644 --- a/services/spring-organization/config/checkstyle/checkstyle.xml +++ b/services/spring-organization/config/checkstyle/checkstyle.xml @@ -22,6 +22,13 @@ + + + + + + diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/api/OrganizationApi.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/api/OrganizationApi.java index f1e864b..bc0c48a 100644 --- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/api/OrganizationApi.java +++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/api/OrganizationApi.java @@ -53,7 +53,7 @@ default Optional getRequest() { /** * POST /organization/sports : Create sport - * Creates a new sport in the organization. Only admins are allowed to create new sports. + * Creates a new sport in the organization. - Admins: can create sports. * * @param sportCreate The request body for creating a new sport. (required) * @return The request was successful, and a new resource was created. (status code 201) @@ -66,7 +66,7 @@ default Optional getRequest() { @Operation( operationId = "createSport", summary = "Create sport", - description = "Creates a new sport in the organization. Only admins are allowed to create new sports.", + description = "Creates a new sport in the organization. - Admins: can create sports. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "201", description = "The request was successful, and a new resource was created.", content = { @@ -143,6 +143,7 @@ default ResponseEntity createSport( /** * POST /organization/teams : Create team + * Creates a new team in the organization. - Directors: can create teams for their own sport. - Admins: can create teams for any sport. * * @param teamCreate The request body for creating a new team. (required) * @return The request was successful, and a new resource was created. (status code 201) @@ -155,6 +156,7 @@ default ResponseEntity createSport( @Operation( operationId = "createTeam", summary = "Create team", + description = "Creates a new team in the organization. - Directors: can create teams for their own sport. - Admins: can create teams for any sport. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "201", description = "The request was successful, and a new resource was created.", content = { @@ -231,6 +233,7 @@ default ResponseEntity createTeam( /** * DELETE /organization/sports/{sport_name} : Delete sport + * Deletes a sport from the organization. - Admins: can delete sports. * * @param sportName (required) * @return The request was successful, but there is no content to return in the response. (status code 204) @@ -242,6 +245,7 @@ default ResponseEntity createTeam( @Operation( operationId = "deleteSport", summary = "Delete sport", + description = "Deletes a sport from the organization. - Admins: can delete sports. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), @@ -302,6 +306,7 @@ default ResponseEntity deleteSport( /** * DELETE /organization/teams/{team_id} : Delete team + * Deletes a team from the organization. - Directors: can delete teams in their sport. - Admins: can delete any team. * * @param teamId (required) * @return The request was successful, but there is no content to return in the response. (status code 204) @@ -313,6 +318,7 @@ default ResponseEntity deleteSport( @Operation( operationId = "deleteTeam", summary = "Delete team", + description = "Deletes a team from the organization. - Directors: can delete teams in their sport. - Admins: can delete any team. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), @@ -373,7 +379,7 @@ default ResponseEntity deleteTeam( /** * GET /organization/sports : Get all sports - * Returns a list of all sports registered in the organization. If the caller is not an admin, only sports that the caller is a member of will be returned. + * Returns a list of all sports registered in the organization. - All authenticated users: can access this endpoint. * * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -383,7 +389,7 @@ default ResponseEntity deleteTeam( @Operation( operationId = "getAllSports", summary = "Get all sports", - description = "Returns a list of all sports registered in the organization. If the caller is not an admin, only sports that the caller is a member of will be returned.", + description = "Returns a list of all sports registered in the organization. - All authenticated users: can access this endpoint. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -443,6 +449,7 @@ default ResponseEntity> getAllSports( /** * GET /organization/teams : Get all teams + * Returns a list of all teams in the organization. - All authenticated users: can access this endpoint. * * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -452,6 +459,7 @@ default ResponseEntity> getAllSports( @Operation( operationId = "getAllTeams", summary = "Get all teams", + description = "Returns a list of all teams in the organization. - All authenticated users: can access this endpoint. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -511,6 +519,7 @@ default ResponseEntity> getAllTeams( /** * GET /organization/sports/{sport_name} : Get sport + * Returns the details of a specific sport. - All authenticated users: can access this endpoint. * * @param sportName (required) * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) @@ -522,6 +531,7 @@ default ResponseEntity> getAllTeams( @Operation( operationId = "getSport", summary = "Get sport", + description = "Returns the details of a specific sport. - All authenticated users: can access this endpoint. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -589,6 +599,7 @@ default ResponseEntity getSport( /** * GET /organization/teams/{team_id} : Get team + * Returns the details of a specific team. - All authenticated users: can access this endpoint. * * @param teamId (required) * @return The request was successful, and the server has returned the requested resource in the response body. (status code 200) @@ -600,6 +611,7 @@ default ResponseEntity getSport( @Operation( operationId = "getTeam", summary = "Get team", + description = "Returns the details of a specific team. - All authenticated users: can access this endpoint. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -667,6 +679,7 @@ default ResponseEntity getTeam( /** * PATCH /organization/sports/{sport_name} : Update sport + * Partially updates an existing sport's details. - Directors: can update all fields except directors. - Admins: can update all fields. * * @param sportName (required) * @param sportPartialUpdate The request body for partially updating a sport. (required) @@ -680,6 +693,7 @@ default ResponseEntity getTeam( @Operation( operationId = "updateSport", summary = "Update sport", + description = "Partially updates an existing sport's details. - Directors: can update all fields except directors. - Admins: can update all fields. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -757,6 +771,7 @@ default ResponseEntity updateSport( /** * PATCH /organization/teams/{team_id} : Update team + * Partially updates a team's details. - Trainers: can update all fields except sport and trainers. - Directors: can update all fields except sport. - Admins: can update all fields. * * @param teamId (required) * @param teamPartialUpdate The request body for partially updating a team. (required) @@ -770,6 +785,7 @@ default ResponseEntity updateSport( @Operation( operationId = "updateTeam", summary = "Update team", + description = "Partially updates a team's details. - Trainers: can update all fields except sport and trainers. - Directors: can update all fields except sport. - Admins: can update all fields. ", tags = { "organization" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/controller/OrganizationController.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/controller/OrganizationController.java new file mode 100644 index 0000000..53af3ee --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/controller/OrganizationController.java @@ -0,0 +1,110 @@ +package tum.devoops.organizationservice.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.RestController; + +import tum.devoops.organizationservice.api.OrganizationApi; +import tum.devoops.organizationservice.model.Sport; +import tum.devoops.organizationservice.model.SportCreate; +import tum.devoops.organizationservice.model.SportPartialUpdate; +import tum.devoops.organizationservice.model.Team; +import tum.devoops.organizationservice.model.TeamCreate; +import tum.devoops.organizationservice.model.TeamPartialUpdate; +import tum.devoops.organizationservice.service.OrganizationSportService; +import tum.devoops.organizationservice.service.OrganizationTeamService; + +@RestController +@PreAuthorize("hasAnyRole('admin', 'member')") +public class OrganizationController implements OrganizationApi { + + @Autowired + private OrganizationSportService sportService; + + @Autowired + private OrganizationTeamService teamService; + + @Override + public ResponseEntity> getAllTeams() { + return ResponseEntity.ok(teamService.getAllTeams()); + } + + @Override + public ResponseEntity getTeam(UUID teamId) { + return ResponseEntity.ok(teamService.getTeam(teamId)); + } + + @Override + public ResponseEntity createTeam(TeamCreate teamCreate) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.status(HttpStatus.CREATED).body(teamService.createTeam(teamCreate, requesterId, isAdmin)); + } + + @Override + public ResponseEntity updateTeam(UUID teamId, TeamPartialUpdate teamPartialUpdate) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(teamService.updateTeam(teamId, teamPartialUpdate, requesterId, isAdmin)); + } + + @Override + public ResponseEntity deleteTeam(UUID teamId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + teamService.deleteTeam(teamId, requesterId, isAdmin); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity> getAllSports() { + return ResponseEntity.ok(sportService.getAllSports()); + } + + @Override + @PreAuthorize("hasRole('admin')") + public ResponseEntity createSport(SportCreate sportCreate) { + return ResponseEntity.status(HttpStatus.CREATED).body(sportService.createSport(sportCreate)); + } + + @Override + public ResponseEntity getSport(String sportName) { + return ResponseEntity.ok(sportService.getSport(sportName)); + } + + @Override + public ResponseEntity updateSport(String sportName, SportPartialUpdate sportPartialUpdate) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(sportService.updateSport(sportName, sportPartialUpdate, requesterId, isAdmin)); + } + + @Override + @PreAuthorize("hasRole('admin')") + public ResponseEntity deleteSport(String sportName) { + sportService.deleteSport(sportName); + return ResponseEntity.noContent().build(); + } + + private UUID extractRequesterId(Authentication auth) { + Jwt jwt = (Jwt) auth.getPrincipal(); + return UUID.fromString(jwt.getSubject()); + } + + private boolean extractIsAdmin(Authentication auth) { + return auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_admin".equals(a.getAuthority())); + } +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/MemberEntity.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/MemberEntity.java new file mode 100644 index 0000000..3a2231c --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/MemberEntity.java @@ -0,0 +1,21 @@ +package tum.devoops.organizationservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(schema = "member", name = "members") +@Getter @Setter @NoArgsConstructor +public class MemberEntity { + + @Id + @Column(name = "id") + private UUID id; +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/BadRequestException.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/BadRequestException.java new file mode 100644 index 0000000..8a0bee9 --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package tum.devoops.organizationservice.exception; + +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/ConflictException.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/ConflictException.java new file mode 100644 index 0000000..383e9c5 --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/ConflictException.java @@ -0,0 +1,8 @@ +package tum.devoops.organizationservice.exception; + +public class ConflictException extends RuntimeException { + + public ConflictException(String message) { + super(message); + } +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/ForbiddenException.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/ForbiddenException.java new file mode 100644 index 0000000..e3f1105 --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package tum.devoops.organizationservice.exception; + +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/GlobalExceptionHandler.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..72095b3 --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,37 @@ +package tum.devoops.organizationservice.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import tum.devoops.organizationservice.model.BadRequestResponse; +import tum.devoops.organizationservice.model.ErrorResponse; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNotFound(NotFoundException ex) { + return new ErrorResponse().message(ex.getMessage()); + } + + @ExceptionHandler(ForbiddenException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ErrorResponse handleForbidden(ForbiddenException ex) { + return new ErrorResponse().message(ex.getMessage()); + } + + @ExceptionHandler(BadRequestException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public BadRequestResponse handleBadRequest(BadRequestException ex) { + return new BadRequestResponse().message(ex.getMessage()); + } + + @ExceptionHandler(ConflictException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ErrorResponse handleConflict(ConflictException ex) { + return new ErrorResponse().message(ex.getMessage()); + } +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/NotFoundException.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/NotFoundException.java new file mode 100644 index 0000000..f122441 --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/exception/NotFoundException.java @@ -0,0 +1,8 @@ +package tum.devoops.organizationservice.exception; + +public class NotFoundException extends RuntimeException { + + public NotFoundException(String message) { + super(message); + } +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/MemberRepository.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/MemberRepository.java new file mode 100644 index 0000000..fe8a0cd --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package tum.devoops.organizationservice.repository; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.organizationservice.entity.MemberEntity; + +public interface MemberRepository extends JpaRepository { +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java new file mode 100644 index 0000000..0804ae8 --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java @@ -0,0 +1,181 @@ +package tum.devoops.organizationservice.service; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import tum.devoops.organizationservice.entity.DirectorEntity; +import tum.devoops.organizationservice.entity.SportEntity; +import tum.devoops.organizationservice.entity.TeamEntity; +import tum.devoops.organizationservice.exception.BadRequestException; +import tum.devoops.organizationservice.exception.ConflictException; +import tum.devoops.organizationservice.exception.ForbiddenException; +import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Sport; +import tum.devoops.organizationservice.model.SportCreate; +import tum.devoops.organizationservice.model.SportPartialUpdate; +import tum.devoops.organizationservice.repository.DirectorRepository; +import tum.devoops.organizationservice.repository.MemberRepository; +import tum.devoops.organizationservice.repository.SportRepository; +import tum.devoops.organizationservice.repository.TeamRepository; +import tum.devoops.organizationservice.repository.TraineeRepository; +import tum.devoops.organizationservice.repository.TrainerRepository; + +@Service +public class OrganizationSportService { + + @Autowired + private SportRepository sportRepository; + @Autowired + private DirectorRepository directorRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private TeamRepository teamRepository; + @Autowired + private TrainerRepository trainerRepository; + @Autowired + private TraineeRepository traineeRepository; + + @Transactional(readOnly = true) + public List getAllSports() { + return sportRepository.findAll().stream() + .map(this::toSport) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public Sport getSport(String sportName) { + return toSport(findSportOrThrow(sportName)); + } + + @Transactional + public Sport createSport(SportCreate body) { + if (sportRepository.existsById(body.getName())) { + throw new ConflictException("Sport already exists: " + body.getName()); + } + List directorIds = resolveDirectorUuids(body.getDirectors()); + + SportEntity entity = new SportEntity(); + entity.setName(body.getName()); + entity.setDescription(body.getDescription()); + entity.setCreatedAt(LocalDate.now()); + sportRepository.save(entity); + + saveDirectors(body.getName(), directorIds); + + return toSport(findSportOrThrow(body.getName())); + } + + @Transactional + public Sport updateSport(String sportName, SportPartialUpdate body, UUID requesterId, boolean isAdmin) { + SportEntity sport = findSportOrThrow(sportName); + + boolean isDirector = directorRepository.findAllById_SportName(sportName).stream() + .anyMatch(d -> d.getId().getMemberId().equals(requesterId)); + if (!isAdmin && !isDirector) { + throw new ForbiddenException("Access denied"); + } + + String effectiveName = (body.getName() != null) ? body.getName() : sportName; + String effectiveDescription = (body.getDescription() != null) ? body.getDescription() : sport.getDescription(); + + if (!effectiveName.equals(sportName)) { + if (sportRepository.existsById(effectiveName)) { + throw new ConflictException("Sport already exists: " + effectiveName); + } + List oldDirectors = directorRepository.findAllById_SportName(sportName); + List teams = teamRepository.findAllBySportName(sportName); + for (TeamEntity team : teams) { + team.setSportName(effectiveName); + } + + SportEntity newSport = new SportEntity(); + newSport.setName(effectiveName); + newSport.setDescription(effectiveDescription); + newSport.setCreatedAt(sport.getCreatedAt()); + sportRepository.save(newSport); + + teamRepository.saveAll(teams); + directorRepository.deleteAllById_SportName(sportName); + + if (isAdmin && !body.getDirectors().isEmpty()) { + saveDirectors(effectiveName, resolveDirectorUuids(body.getDirectors())); + } else { + List migratedDirectors = oldDirectors.stream() + .map(d -> new DirectorEntity( + new DirectorEntity.Id(effectiveName, d.getId().getMemberId()))) + .collect(Collectors.toList()); + directorRepository.saveAll(migratedDirectors); + } + + sportRepository.delete(sport); + } else { + sport.setDescription(effectiveDescription); + sportRepository.save(sport); + + if (isAdmin && !body.getDirectors().isEmpty()) { + directorRepository.deleteAllById_SportName(sportName); + saveDirectors(sportName, resolveDirectorUuids(body.getDirectors())); + } + } + + return toSport(findSportOrThrow(effectiveName)); + } + + @Transactional + public void deleteSport(String sportName) { + SportEntity sport = findSportOrThrow(sportName); + + List teams = teamRepository.findAllBySportName(sportName); + for (TeamEntity team : teams) { + traineeRepository.deleteAllById_TeamId(team.getId()); + trainerRepository.deleteAllById_TeamId(team.getId()); + } + teamRepository.deleteAll(teams); + + directorRepository.deleteAllById_SportName(sportName); + sportRepository.delete(sport); + } + + private SportEntity findSportOrThrow(String sportName) { + return sportRepository.findById(sportName) + .orElseThrow(() -> new NotFoundException("Sport not found: " + sportName)); + } + + private List resolveDirectorUuids(List directorStrings) { + return directorStrings.stream() + .map(s -> { + try { + return UUID.fromString(s); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Invalid UUID for director: " + s); + } + }) + .peek(id -> { + if (!memberRepository.existsById(id)) { + throw new BadRequestException("Member not found: " + id); + } + }) + .collect(Collectors.toList()); + } + + private void saveDirectors(String sportName, List directorIds) { + List directors = directorIds.stream() + .map(id -> new DirectorEntity(new DirectorEntity.Id(sportName, id))) + .collect(Collectors.toList()); + directorRepository.saveAll(directors); + } + + private Sport toSport(SportEntity entity) { + List directors = entity.getDirectors().stream() + .map(d -> d.getId().getMemberId().toString()) + .collect(Collectors.toList()); + return new Sport(entity.getName(), entity.getDescription(), entity.getCreatedAt(), directors); + } +} diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java new file mode 100644 index 0000000..8fc066a --- /dev/null +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java @@ -0,0 +1,205 @@ +package tum.devoops.organizationservice.service; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import tum.devoops.organizationservice.entity.TeamEntity; +import tum.devoops.organizationservice.entity.TraineeEntity; +import tum.devoops.organizationservice.entity.TrainerEntity; +import tum.devoops.organizationservice.exception.BadRequestException; +import tum.devoops.organizationservice.exception.ForbiddenException; +import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Team; +import tum.devoops.organizationservice.model.TeamCreate; +import tum.devoops.organizationservice.model.TeamPartialUpdate; +import tum.devoops.organizationservice.repository.DirectorRepository; +import tum.devoops.organizationservice.repository.MemberRepository; +import tum.devoops.organizationservice.repository.SportRepository; +import tum.devoops.organizationservice.repository.TeamRepository; +import tum.devoops.organizationservice.repository.TraineeRepository; +import tum.devoops.organizationservice.repository.TrainerRepository; + +@Service +public class OrganizationTeamService { + + @Autowired + private TeamRepository teamRepository; + @Autowired + private SportRepository sportRepository; + @Autowired + private DirectorRepository directorRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private TrainerRepository trainerRepository; + @Autowired + private TraineeRepository traineeRepository; + + @Transactional(readOnly = true) + public List getAllTeams() { + return teamRepository.findAll().stream() + .map(this::toTeam) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public Team getTeam(UUID teamId) { + return toTeam(findTeamOrThrow(teamId)); + } + + @Transactional + public Team createTeam(TeamCreate body, UUID requesterId, boolean isAdmin) { + if (!sportRepository.existsById(body.getSport())) { + throw new BadRequestException("Sport not found: " + body.getSport()); + } + + boolean isDirectorOfSport = directorRepository.findAllById_SportName(body.getSport()).stream() + .anyMatch(d -> d.getId().getMemberId().equals(requesterId)); + if (!isAdmin && !isDirectorOfSport) { + throw new ForbiddenException("Access denied"); + } + + List trainerIds = resolveAndValidateMemberUuids(body.getTrainers(), "trainer"); + List traineeIds = resolveAndValidateMemberUuids(body.getTrainees(), "trainee"); + + TeamEntity team = new TeamEntity(); + team.setName(body.getName()); + team.setDescription(body.getDescription()); + team.setAddress(body.getAddress()); + team.setSportName(body.getSport()); + team.setCreatedAt(LocalDate.now()); + teamRepository.save(team); + + saveTrainers(team.getId(), trainerIds); + saveTrainees(team.getId(), traineeIds); + + return toTeam(findTeamOrThrow(team.getId())); + } + + @Transactional + public Team updateTeam(UUID teamId, TeamPartialUpdate body, UUID requesterId, boolean isAdmin) { + TeamEntity team = findTeamOrThrow(teamId); + + boolean isDirectorOfSport = directorRepository.findAllById_SportName(team.getSportName()).stream() + .anyMatch(d -> d.getId().getMemberId().equals(requesterId)); + boolean isTrainerOfTeam = trainerRepository.findAllById_TeamId(teamId).stream() + .anyMatch(t -> t.getId().getMemberId().equals(requesterId)); + + if (!isAdmin && !isDirectorOfSport && !isTrainerOfTeam) { + throw new ForbiddenException("Access denied"); + } + + if (body.getSport() != null && !isAdmin) { + throw new ForbiddenException("Only admins can update the sport field"); + } + if (!body.getTrainers().isEmpty() && !isAdmin && !isDirectorOfSport) { + throw new ForbiddenException("Only directors and admins can update the trainers list"); + } + + if (body.getSport() != null) { + if (!sportRepository.existsById(body.getSport())) { + throw new BadRequestException("Sport not found: " + body.getSport()); + } + team.setSportName(body.getSport()); + } + if (body.getName() != null) { + team.setName(body.getName()); + } + if (body.getDescription() != null) { + team.setDescription(body.getDescription()); + } + if (body.getAddress() != null) { + team.setAddress(body.getAddress()); + } + teamRepository.save(team); + + if (!body.getTrainers().isEmpty()) { + List trainerIds = resolveAndValidateMemberUuids(body.getTrainers(), "trainer"); + trainerRepository.deleteAllById_TeamId(teamId); + saveTrainers(teamId, trainerIds); + } + if (!body.getTrainees().isEmpty()) { + List traineeIds = resolveAndValidateMemberUuids(body.getTrainees(), "trainee"); + traineeRepository.deleteAllById_TeamId(teamId); + saveTrainees(teamId, traineeIds); + } + + return toTeam(findTeamOrThrow(teamId)); + } + + @Transactional + public void deleteTeam(UUID teamId, UUID requesterId, boolean isAdmin) { + TeamEntity team = findTeamOrThrow(teamId); + + boolean isDirectorOfSport = directorRepository.findAllById_SportName(team.getSportName()).stream() + .anyMatch(d -> d.getId().getMemberId().equals(requesterId)); + if (!isAdmin && !isDirectorOfSport) { + throw new ForbiddenException("Access denied"); + } + + traineeRepository.deleteAllById_TeamId(teamId); + trainerRepository.deleteAllById_TeamId(teamId); + teamRepository.delete(team); + } + + private TeamEntity findTeamOrThrow(UUID teamId) { + return teamRepository.findById(teamId) + .orElseThrow(() -> new NotFoundException("Team not found: " + teamId)); + } + + private List resolveAndValidateMemberUuids(List strings, String role) { + return strings.stream() + .map(s -> { + try { + return UUID.fromString(s); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Invalid UUID for " + role + ": " + s); + } + }) + .peek(id -> { + if (!memberRepository.existsById(id)) { + throw new BadRequestException("Member not found: " + id); + } + }) + .collect(Collectors.toList()); + } + + private void saveTrainers(UUID teamId, List memberIds) { + List trainers = memberIds.stream() + .map(id -> new TrainerEntity(new TrainerEntity.Id(teamId, id))) + .collect(Collectors.toList()); + trainerRepository.saveAll(trainers); + } + + private void saveTrainees(UUID teamId, List memberIds) { + List trainees = memberIds.stream() + .map(id -> new TraineeEntity(new TraineeEntity.Id(teamId, id))) + .collect(Collectors.toList()); + traineeRepository.saveAll(trainees); + } + + private Team toTeam(TeamEntity entity) { + List trainers = entity.getTrainers().stream() + .map(t -> t.getId().getMemberId().toString()) + .collect(Collectors.toList()); + List trainees = entity.getTrainees().stream() + .map(t -> t.getId().getMemberId().toString()) + .collect(Collectors.toList()); + return new Team( + entity.getId(), + entity.getName(), + entity.getDescription(), + entity.getCreatedAt(), + entity.getAddress(), + entity.getSportName(), + trainers, + trainees + ); + } +} diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java new file mode 100644 index 0000000..ec75204 --- /dev/null +++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java @@ -0,0 +1,591 @@ +package tum.devoops.organizationservice; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import tum.devoops.organizationservice.exception.ConflictException; +import tum.devoops.organizationservice.exception.ForbiddenException; +import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Sport; +import tum.devoops.organizationservice.model.Team; +import tum.devoops.organizationservice.service.OrganizationSportService; +import tum.devoops.organizationservice.service.OrganizationTeamService; + +@SpringBootTest(properties = { + "spring.autoconfigure.exclude=" + + "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration" +}) +@TestPropertySource(properties = {"spring.jpa.hibernate.ddl-auto=none"}) +@AutoConfigureMockMvc +class OrganizationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OrganizationSportService sportService; + + @MockitoBean + private OrganizationTeamService teamService; + + @MockitoBean + private JwtDecoder jwtDecoder; + + private static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID MEMBER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID TEAM_ID = UUID.fromString("00000000-0000-0000-0000-000000000010"); + + private RequestPostProcessor adminJwt() { + return jwt() + .jwt(j -> j.subject(ADMIN_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")); + } + + private RequestPostProcessor memberJwt() { + return jwt() + .jwt(j -> j.subject(MEMBER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")); + } + + private Sport sport(String name) { + return new Sport(name, "A test sport", LocalDate.of(2024, 1, 1), List.of()); + } + + private Team team(UUID id, String sport) { + return new Team(id, "Team Alpha", null, LocalDate.of(2024, 1, 1), null, sport, List.of(), List.of()); + } + + // --- getAllSports --- + + @Test + void getAllSports_returns200_withList_whenMemberAuthenticated() throws Exception { + when(sportService.getAllSports()).thenReturn(List.of(sport("soccer"), sport("tennis"))); + + mockMvc.perform(get("/organization/sports").with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].name").value("soccer")) + .andExpect(jsonPath("$[1].name").value("tennis")); + } + + @Test + void getAllSports_returns200_withEmptyList_whenNoSports() throws Exception { + when(sportService.getAllSports()).thenReturn(List.of()); + + mockMvc.perform(get("/organization/sports").with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void getAllSports_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/organization/sports")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getAllSports_returns403_whenNoOrgRole() throws Exception { + mockMvc.perform(get("/organization/sports") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainer")))) + .andExpect(status().isForbidden()); + } + + // --- getSport --- + + @Test + void getSport_returns200_withSport_whenFound() throws Exception { + when(sportService.getSport("soccer")).thenReturn(sport("soccer")); + + mockMvc.perform(get("/organization/sports/soccer").with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("soccer")); + } + + @Test + void getSport_returns404_whenNotFound() throws Exception { + when(sportService.getSport("unknown")).thenThrow(new NotFoundException("Sport not found: unknown")); + + mockMvc.perform(get("/organization/sports/unknown").with(memberJwt())) + .andExpect(status().isNotFound()); + } + + @Test + void getSport_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/organization/sports/soccer")) + .andExpect(status().isUnauthorized()); + } + + // --- createSport --- + + @Test + void createSport_returns201_withSport_whenAdmin() throws Exception { + when(sportService.createSport(any())).thenReturn(sport("soccer")); + + mockMvc.perform(post("/organization/sports") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"soccer\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("soccer")); + } + + @Test + void createSport_returns403_whenMember() throws Exception { + mockMvc.perform(post("/organization/sports") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"soccer\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void createSport_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/organization/sports") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"soccer\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void createSport_returns409_whenConflict() throws Exception { + when(sportService.createSport(any())).thenThrow(new ConflictException("Sport already exists: soccer")); + + mockMvc.perform(post("/organization/sports") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"soccer\"}")) + .andExpect(status().isConflict()); + } + + @Test + void createSport_returns400_whenNameMissing() throws Exception { + mockMvc.perform(post("/organization/sports") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + // --- updateSport --- + + @Test + void updateSport_returns200_whenAdmin() throws Exception { + when(sportService.updateSport(eq("soccer"), any(), eq(ADMIN_ID), eq(true))) + .thenReturn(sport("soccer")); + + mockMvc.perform(patch("/organization/sports/soccer") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"description\":\"updated\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("soccer")); + } + + @Test + void updateSport_returns200_whenMember() throws Exception { + when(sportService.updateSport(eq("soccer"), any(), eq(MEMBER_ID), eq(false))) + .thenReturn(sport("soccer")); + + mockMvc.perform(patch("/organization/sports/soccer") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"description\":\"updated\"}")) + .andExpect(status().isOk()); + } + + @Test + void updateSport_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception { + when(sportService.updateSport(any(), any(), any(), anyBoolean())).thenReturn(sport("soccer")); + + mockMvc.perform(patch("/organization/sports/soccer") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + + verify(sportService).updateSport(eq("soccer"), any(), eq(ADMIN_ID), eq(true)); + } + + @Test + void updateSport_passesRequesterIdAndIsAdminFalse_fromMemberJwt() throws Exception { + when(sportService.updateSport(any(), any(), any(), anyBoolean())).thenReturn(sport("soccer")); + + mockMvc.perform(patch("/organization/sports/soccer") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + + verify(sportService).updateSport(eq("soccer"), any(), eq(MEMBER_ID), eq(false)); + } + + @Test + void updateSport_returns404_whenNotFound() throws Exception { + when(sportService.updateSport(eq("unknown"), any(), any(), anyBoolean())) + .thenThrow(new NotFoundException("Sport not found: unknown")); + + mockMvc.perform(patch("/organization/sports/unknown") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isNotFound()); + } + + @Test + void updateSport_returns403_whenForbidden() throws Exception { + when(sportService.updateSport(eq("soccer"), any(), any(), anyBoolean())) + .thenThrow(new ForbiddenException("Access denied")); + + mockMvc.perform(patch("/organization/sports/soccer") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } + + @Test + void updateSport_returns409_whenRenameConflict() throws Exception { + when(sportService.updateSport(eq("soccer"), any(), any(), anyBoolean())) + .thenThrow(new ConflictException("Sport already exists: football")); + + mockMvc.perform(patch("/organization/sports/soccer") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"football\"}")) + .andExpect(status().isConflict()); + } + + @Test + void updateSport_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/organization/sports/soccer") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + // --- deleteSport --- + + @Test + void deleteSport_returns204_whenAdmin() throws Exception { + mockMvc.perform(delete("/organization/sports/soccer").with(adminJwt())) + .andExpect(status().isNoContent()); + } + + @Test + void deleteSport_returns403_whenMember() throws Exception { + mockMvc.perform(delete("/organization/sports/soccer").with(memberJwt())) + .andExpect(status().isForbidden()); + } + + @Test + void deleteSport_returns404_whenNotFound() throws Exception { + doThrow(new NotFoundException("Sport not found: unknown")) + .when(sportService).deleteSport("unknown"); + + mockMvc.perform(delete("/organization/sports/unknown").with(adminJwt())) + .andExpect(status().isNotFound()); + } + + @Test + void deleteSport_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/organization/sports/soccer")) + .andExpect(status().isUnauthorized()); + } + + // --- getAllTeams --- + + @Test + void getAllTeams_returns200_withList_whenMemberAuthenticated() throws Exception { + when(teamService.getAllTeams()).thenReturn(List.of(team(TEAM_ID, "soccer"), team(UUID.randomUUID(), "tennis"))); + + mockMvc.perform(get("/organization/teams").with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].name").value("Team Alpha")); + } + + @Test + void getAllTeams_returns200_withEmptyList() throws Exception { + when(teamService.getAllTeams()).thenReturn(List.of()); + + mockMvc.perform(get("/organization/teams").with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void getAllTeams_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/organization/teams")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getAllTeams_returns403_whenNoOrgRole() throws Exception { + mockMvc.perform(get("/organization/teams") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainer")))) + .andExpect(status().isForbidden()); + } + + // --- getTeam --- + + @Test + void getTeam_returns200_withTeam_whenFound() throws Exception { + when(teamService.getTeam(TEAM_ID)).thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(get("/organization/teams/" + TEAM_ID).with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Team Alpha")); + } + + @Test + void getTeam_returns404_whenNotFound() throws Exception { + when(teamService.getTeam(TEAM_ID)).thenThrow(new NotFoundException("Team not found: " + TEAM_ID)); + + mockMvc.perform(get("/organization/teams/" + TEAM_ID).with(memberJwt())) + .andExpect(status().isNotFound()); + } + + @Test + void getTeam_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/organization/teams/" + TEAM_ID)) + .andExpect(status().isUnauthorized()); + } + + // --- createTeam --- + + @Test + void createTeam_returns201_withTeam_whenAdmin() throws Exception { + when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(post("/organization/teams") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Team Alpha")); + } + + @Test + void createTeam_returns201_withTeam_whenMember() throws Exception { + when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(post("/organization/teams") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}")) + .andExpect(status().isCreated()); + } + + @Test + void createTeam_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception { + when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(post("/organization/teams") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}")) + .andExpect(status().isCreated()); + + verify(teamService).createTeam(any(), eq(ADMIN_ID), eq(true)); + } + + @Test + void createTeam_passesRequesterIdAndIsAdminFalse_fromMemberJwt() throws Exception { + when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(post("/organization/teams") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}")) + .andExpect(status().isCreated()); + + verify(teamService).createTeam(any(), eq(MEMBER_ID), eq(false)); + } + + @Test + void createTeam_returns400_whenNameMissing() throws Exception { + mockMvc.perform(post("/organization/teams") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sport\":\"soccer\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void createTeam_returns403_whenServiceThrowsForbidden() throws Exception { + when(teamService.createTeam(any(), any(), anyBoolean())) + .thenThrow(new ForbiddenException("Access denied")); + + mockMvc.perform(post("/organization/teams") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void createTeam_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/organization/teams") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}")) + .andExpect(status().isUnauthorized()); + } + + // --- updateTeam --- + + @Test + void updateTeam_returns200_whenAdmin() throws Exception { + when(teamService.updateTeam(eq(TEAM_ID), any(), eq(ADMIN_ID), eq(true))) + .thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(patch("/organization/teams/" + TEAM_ID) + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Updated\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Team Alpha")); + } + + @Test + void updateTeam_returns200_whenMember() throws Exception { + when(teamService.updateTeam(eq(TEAM_ID), any(), eq(MEMBER_ID), eq(false))) + .thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(patch("/organization/teams/" + TEAM_ID) + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + } + + @Test + void updateTeam_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception { + when(teamService.updateTeam(any(), any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(patch("/organization/teams/" + TEAM_ID) + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + + verify(teamService).updateTeam(eq(TEAM_ID), any(), eq(ADMIN_ID), eq(true)); + } + + @Test + void updateTeam_passesRequesterIdAndIsAdminFalse_fromMemberJwt() throws Exception { + when(teamService.updateTeam(any(), any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer")); + + mockMvc.perform(patch("/organization/teams/" + TEAM_ID) + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + + verify(teamService).updateTeam(eq(TEAM_ID), any(), eq(MEMBER_ID), eq(false)); + } + + @Test + void updateTeam_returns404_whenNotFound() throws Exception { + when(teamService.updateTeam(eq(TEAM_ID), any(), any(), anyBoolean())) + .thenThrow(new NotFoundException("Team not found: " + TEAM_ID)); + + mockMvc.perform(patch("/organization/teams/" + TEAM_ID) + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isNotFound()); + } + + @Test + void updateTeam_returns403_whenForbidden() throws Exception { + when(teamService.updateTeam(eq(TEAM_ID), any(), any(), anyBoolean())) + .thenThrow(new ForbiddenException("Access denied")); + + mockMvc.perform(patch("/organization/teams/" + TEAM_ID) + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } + + @Test + void updateTeam_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/organization/teams/" + TEAM_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + // --- deleteTeam --- + + @Test + void deleteTeam_returns204_whenAdmin() throws Exception { + mockMvc.perform(delete("/organization/teams/" + TEAM_ID).with(adminJwt())) + .andExpect(status().isNoContent()); + } + + @Test + void deleteTeam_returns204_whenMember() throws Exception { + mockMvc.perform(delete("/organization/teams/" + TEAM_ID).with(memberJwt())) + .andExpect(status().isNoContent()); + } + + @Test + void deleteTeam_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception { + mockMvc.perform(delete("/organization/teams/" + TEAM_ID).with(adminJwt())) + .andExpect(status().isNoContent()); + + verify(teamService).deleteTeam(eq(TEAM_ID), eq(ADMIN_ID), eq(true)); + } + + @Test + void deleteTeam_returns404_whenNotFound() throws Exception { + doThrow(new NotFoundException("Team not found: " + TEAM_ID)) + .when(teamService).deleteTeam(eq(TEAM_ID), any(), anyBoolean()); + + mockMvc.perform(delete("/organization/teams/" + TEAM_ID).with(memberJwt())) + .andExpect(status().isNotFound()); + } + + @Test + void deleteTeam_returns403_whenForbidden() throws Exception { + doThrow(new ForbiddenException("Access denied")) + .when(teamService).deleteTeam(eq(TEAM_ID), any(), anyBoolean()); + + mockMvc.perform(delete("/organization/teams/" + TEAM_ID).with(memberJwt())) + .andExpect(status().isForbidden()); + } + + @Test + void deleteTeam_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/organization/teams/" + TEAM_ID)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationServiceApplicationTests.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationServiceApplicationTests.java index ba8a861..0d4e67e 100644 --- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationServiceApplicationTests.java +++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationServiceApplicationTests.java @@ -2,7 +2,12 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import tum.devoops.organizationservice.service.OrganizationSportService; +import tum.devoops.organizationservice.service.OrganizationTeamService; /** * Context-load smoke test. @@ -20,6 +25,15 @@ }) class OrganizationServiceApplicationTests { + @MockitoBean + private OrganizationSportService sportService; + + @MockitoBean + private OrganizationTeamService teamService; + + @MockitoBean + private JwtDecoder jwtDecoder; + @Test void contextLoads() { } diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java new file mode 100644 index 0000000..16d7082 --- /dev/null +++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java @@ -0,0 +1,385 @@ +package tum.devoops.organizationservice; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; + +import tum.devoops.organizationservice.entity.DirectorEntity; +import tum.devoops.organizationservice.entity.SportEntity; +import tum.devoops.organizationservice.entity.TeamEntity; +import tum.devoops.organizationservice.exception.BadRequestException; +import tum.devoops.organizationservice.exception.ConflictException; +import tum.devoops.organizationservice.exception.ForbiddenException; +import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Sport; +import tum.devoops.organizationservice.model.SportCreate; +import tum.devoops.organizationservice.model.SportPartialUpdate; +import tum.devoops.organizationservice.repository.DirectorRepository; +import tum.devoops.organizationservice.repository.MemberRepository; +import tum.devoops.organizationservice.repository.SportRepository; +import tum.devoops.organizationservice.repository.TeamRepository; +import tum.devoops.organizationservice.repository.TraineeRepository; +import tum.devoops.organizationservice.repository.TrainerRepository; +import tum.devoops.organizationservice.service.OrganizationSportService; + +@ExtendWith(MockitoExtension.class) +class OrganizationSportServiceTest { + + @Mock + private SportRepository sportRepository; + @Mock + private DirectorRepository directorRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private TeamRepository teamRepository; + @Mock + private TrainerRepository trainerRepository; + @Mock + private TraineeRepository traineeRepository; + + @InjectMocks + private OrganizationSportService service; + + private static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID MEMBER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + + private SportEntity sportEntity(String name, List directors) { + SportEntity entity = new SportEntity(); + entity.setName(name); + entity.setDescription("A test sport"); + entity.setCreatedAt(LocalDate.of(2024, 1, 1)); + entity.setDirectors(directors); + return entity; + } + + private DirectorEntity directorEntity(String sportName, UUID memberId) { + return new DirectorEntity(new DirectorEntity.Id(sportName, memberId)); + } + + private TeamEntity teamEntity(UUID id, String sportName) { + TeamEntity team = new TeamEntity(); + team.setId(id); + team.setSportName(sportName); + return team; + } + + // --- getAllSports --- + + @Test + void getAllSports_returnsEmptyList_whenNoSports() { + when(sportRepository.findAll()).thenReturn(List.of()); + + assertThat(service.getAllSports()).isEmpty(); + } + + @Test + void getAllSports_returnsMappedList_whenSportsExist() { + UUID dirId = UUID.randomUUID(); + SportEntity entity = sportEntity("soccer", List.of(directorEntity("soccer", dirId))); + when(sportRepository.findAll()).thenReturn(List.of(entity)); + + List result = service.getAllSports(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("soccer"); + assertThat(result.get(0).getDescription()).isEqualTo("A test sport"); + assertThat(result.get(0).getCreatedAt()).isEqualTo(LocalDate.of(2024, 1, 1)); + assertThat(result.get(0).getDirectors()).containsExactly(dirId.toString()); + } + + // --- getSport --- + + @Test + void getSport_returnsMappedSport_whenFound() { + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(sportEntity("soccer", List.of()))); + + Sport result = service.getSport("soccer"); + + assertThat(result.getName()).isEqualTo("soccer"); + assertThat(result.getDirectors()).isEmpty(); + } + + @Test + void getSport_throwsNotFoundException_whenAbsent() { + when(sportRepository.findById("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getSport("unknown")) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("unknown"); + } + + // --- createSport --- + + @Test + void createSport_throwsConflict_whenNameAlreadyExists() { + when(sportRepository.existsById("soccer")).thenReturn(true); + + assertThatThrownBy(() -> service.createSport(new SportCreate("soccer"))) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("soccer"); + } + + @Test + void createSport_throwsBadRequest_whenDirectorUuidMalformed() { + when(sportRepository.existsById("soccer")).thenReturn(false); + + SportCreate body = new SportCreate("soccer"); + body.setDirectors(List.of("not-a-uuid")); + + assertThatThrownBy(() -> service.createSport(body)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("not-a-uuid"); + } + + @Test + void createSport_throwsBadRequest_whenDirectorMemberNotFound() { + when(sportRepository.existsById("soccer")).thenReturn(false); + when(memberRepository.existsById(MEMBER_ID)).thenReturn(false); + + SportCreate body = new SportCreate("soccer"); + body.setDirectors(List.of(MEMBER_ID.toString())); + + assertThatThrownBy(() -> service.createSport(body)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining(MEMBER_ID.toString()); + } + + @Test + void createSport_savesEntityAndDirectors_andReturnsResult() { + when(sportRepository.existsById("soccer")).thenReturn(false); + when(memberRepository.existsById(MEMBER_ID)).thenReturn(true); + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of( + sportEntity("soccer", List.of(directorEntity("soccer", MEMBER_ID))))); + + SportCreate body = new SportCreate("soccer"); + body.setDirectors(List.of(MEMBER_ID.toString())); + Sport result = service.createSport(body); + + verify(sportRepository).save(any(SportEntity.class)); + verify(directorRepository).saveAll(any()); + assertThat(result.getName()).isEqualTo("soccer"); + assertThat(result.getDirectors()).containsExactly(MEMBER_ID.toString()); + } + + @Test + void createSport_savesEntityWithNoDirectors_whenEmptyList() { + when(sportRepository.existsById("soccer")).thenReturn(false); + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(sportEntity("soccer", List.of()))); + + Sport result = service.createSport(new SportCreate("soccer")); + + verify(sportRepository).save(any(SportEntity.class)); + assertThat(result.getDirectors()).isEmpty(); + } + + // --- updateSport --- + + @Test + void updateSport_throwsNotFoundException_whenSportAbsent() { + when(sportRepository.findById("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateSport("unknown", new SportPartialUpdate(), MEMBER_ID, false)) + .isInstanceOf(NotFoundException.class); + } + + @Test + void updateSport_throwsForbidden_whenNotAdminAndNotDirector() { + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(sportEntity("soccer", List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + + assertThatThrownBy(() -> service.updateSport("soccer", new SportPartialUpdate(), MEMBER_ID, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void updateSport_allowsUpdate_whenMemberIsDirector() { + DirectorEntity director = directorEntity("soccer", MEMBER_ID); + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(sportEntity("soccer", List.of(director)))) + .thenReturn(Optional.of(sportEntity("soccer", List.of(director)))); + when(directorRepository.findAllById_SportName("soccer")) + .thenReturn(List.of(director)); + + Sport result = service.updateSport("soccer", new SportPartialUpdate(), MEMBER_ID, false); + + assertThat(result.getName()).isEqualTo("soccer"); + } + + @Test + void updateSport_updatesDescriptionOnly_whenNoNameChange_asAdmin() { + SportEntity entity = sportEntity("soccer", List.of()); + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(entity)) + .thenReturn(Optional.of(sportEntity("soccer", List.of()))); + + SportPartialUpdate body = new SportPartialUpdate(); + body.setDescription("new description"); + service.updateSport("soccer", body, ADMIN_ID, true); + + verify(sportRepository).save(entity); + verify(directorRepository, never()).deleteAllById_SportName(any()); + } + + @Test + void updateSport_doesNotReplaceDirectors_whenAdminAndEmptyList() { + SportEntity entity = sportEntity("soccer", List.of()); + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(entity)) + .thenReturn(Optional.of(sportEntity("soccer", List.of()))); + + service.updateSport("soccer", new SportPartialUpdate(), ADMIN_ID, true); + + verify(directorRepository, never()).deleteAllById_SportName("soccer"); + verify(directorRepository, never()).saveAll(any()); + } + + @Test + void updateSport_replacesDirectors_whenAdminAndNonEmptyList() { + SportEntity entity = sportEntity("soccer", List.of()); + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(entity)) + .thenReturn(Optional.of(sportEntity("soccer", + List.of(directorEntity("soccer", MEMBER_ID))))); + when(memberRepository.existsById(MEMBER_ID)).thenReturn(true); + + SportPartialUpdate body = new SportPartialUpdate(); + body.setDirectors(List.of(MEMBER_ID.toString())); + service.updateSport("soccer", body, ADMIN_ID, true); + + verify(directorRepository).deleteAllById_SportName("soccer"); + verify(directorRepository).saveAll(any()); + } + + @Test + void updateSport_throwsConflict_whenRenamingToExistingName() { + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(sportEntity("soccer", List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(sportRepository.existsById("football")).thenReturn(true); + + SportPartialUpdate body = new SportPartialUpdate(); + body.setName("football"); + + assertThatThrownBy(() -> service.updateSport("soccer", body, ADMIN_ID, true)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("football"); + } + + @Test + void updateSport_renamesSport_migratesTeamsAndDirectors() { + UUID dirId = UUID.randomUUID(); + UUID teamId = UUID.randomUUID(); + DirectorEntity oldDirector = directorEntity("soccer", dirId); + TeamEntity team = teamEntity(teamId, "soccer"); + + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(sportEntity("soccer", List.of(oldDirector)))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(oldDirector)); + when(teamRepository.findAllBySportName("soccer")).thenReturn(List.of(team)); + when(sportRepository.existsById("football")).thenReturn(false); + when(sportRepository.findById("football")) + .thenReturn(Optional.of( + sportEntity("football", List.of(directorEntity("football", dirId))))); + + SportPartialUpdate body = new SportPartialUpdate(); + body.setName("football"); + Sport result = service.updateSport("soccer", body, ADMIN_ID, true); + + verify(sportRepository).save(argThat(e -> "football".equals(e.getName()))); + verify(teamRepository).saveAll(any()); + verify(directorRepository).deleteAllById_SportName("soccer"); + verify(directorRepository).saveAll(argThat(it -> { + List saved = new ArrayList<>(); + it.forEach(saved::add); + return !saved.isEmpty() && "football".equals(saved.get(0).getId().getSportName()); + })); + verify(sportRepository).delete(any(SportEntity.class)); + assertThat(result.getName()).isEqualTo("football"); + } + + @Test + void updateSport_replacesDirectors_whenRenaming_andAdminProvidesNewList() { + UUID newDirId = UUID.randomUUID(); + when(sportRepository.findById("soccer")) + .thenReturn(Optional.of(sportEntity("soccer", List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(sportRepository.existsById("football")).thenReturn(false); + when(memberRepository.existsById(newDirId)).thenReturn(true); + when(sportRepository.findById("football")) + .thenReturn(Optional.of( + sportEntity("football", List.of(directorEntity("football", newDirId))))); + + SportPartialUpdate body = new SportPartialUpdate(); + body.setName("football"); + body.setDirectors(List.of(newDirId.toString())); + Sport result = service.updateSport("soccer", body, ADMIN_ID, true); + + verify(directorRepository).saveAll(argThat(it -> { + List saved = new ArrayList<>(); + it.forEach(saved::add); + return saved.stream().anyMatch(d -> newDirId.equals(d.getId().getMemberId())); + })); + assertThat(result.getDirectors()).containsExactly(newDirId.toString()); + } + + // --- deleteSport --- + + @Test + void deleteSport_throwsNotFoundException_whenAbsent() { + when(sportRepository.findById("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deleteSport("unknown")) + .isInstanceOf(NotFoundException.class); + } + + @Test + void deleteSport_deletesTrainersAndTrainees_perTeam_thenTeamsDirectorsSport() { + UUID teamId = UUID.randomUUID(); + TeamEntity team = teamEntity(teamId, "soccer"); + SportEntity entity = sportEntity("soccer", List.of()); + when(sportRepository.findById("soccer")).thenReturn(Optional.of(entity)); + when(teamRepository.findAllBySportName("soccer")).thenReturn(List.of(team)); + + service.deleteSport("soccer"); + + verify(traineeRepository).deleteAllById_TeamId(teamId); + verify(trainerRepository).deleteAllById_TeamId(teamId); + verify(teamRepository).deleteAll(List.of(team)); + verify(directorRepository).deleteAllById_SportName("soccer"); + verify(sportRepository).delete(entity); + } + + @Test + void deleteSport_deletesDirectorsAndSport_whenNoTeams() { + SportEntity entity = sportEntity("soccer", List.of()); + when(sportRepository.findById("soccer")).thenReturn(Optional.of(entity)); + when(teamRepository.findAllBySportName("soccer")).thenReturn(List.of()); + + service.deleteSport("soccer"); + + verify(traineeRepository, never()).deleteAllById_TeamId(any()); + verify(trainerRepository, never()).deleteAllById_TeamId(any()); + verify(directorRepository).deleteAllById_SportName("soccer"); + verify(sportRepository).delete(entity); + } +} diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java new file mode 100644 index 0000000..4811123 --- /dev/null +++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java @@ -0,0 +1,524 @@ +package tum.devoops.organizationservice; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; + +import tum.devoops.organizationservice.entity.DirectorEntity; +import tum.devoops.organizationservice.entity.TeamEntity; +import tum.devoops.organizationservice.entity.TraineeEntity; +import tum.devoops.organizationservice.entity.TrainerEntity; +import tum.devoops.organizationservice.exception.BadRequestException; +import tum.devoops.organizationservice.exception.ForbiddenException; +import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Team; +import tum.devoops.organizationservice.model.TeamCreate; +import tum.devoops.organizationservice.model.TeamPartialUpdate; +import tum.devoops.organizationservice.repository.DirectorRepository; +import tum.devoops.organizationservice.repository.MemberRepository; +import tum.devoops.organizationservice.repository.SportRepository; +import tum.devoops.organizationservice.repository.TeamRepository; +import tum.devoops.organizationservice.repository.TraineeRepository; +import tum.devoops.organizationservice.repository.TrainerRepository; +import tum.devoops.organizationservice.service.OrganizationTeamService; + +@ExtendWith(MockitoExtension.class) +class OrganizationTeamServiceTest { + + @Mock + private TeamRepository teamRepository; + @Mock + private SportRepository sportRepository; + @Mock + private DirectorRepository directorRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private TrainerRepository trainerRepository; + @Mock + private TraineeRepository traineeRepository; + + @InjectMocks + private OrganizationTeamService service; + + private static final UUID TEAM_ID = UUID.fromString("00000000-0000-0000-0000-000000000010"); + private static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID DIRECTOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID TRAINER_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + private static final UUID TRAINEE_ID = UUID.fromString("00000000-0000-0000-0000-000000000004"); + + private TeamEntity teamEntity(UUID id, String sportName, + List trainers, List trainees) { + TeamEntity entity = new TeamEntity(); + entity.setId(id); + entity.setName("Team Alpha"); + entity.setDescription("A test team"); + entity.setAddress("123 Main St"); + entity.setCreatedAt(LocalDate.of(2024, 1, 1)); + entity.setSportName(sportName); + entity.setTrainers(trainers); + entity.setTrainees(trainees); + return entity; + } + + private TrainerEntity trainerEntity(UUID teamId, UUID memberId) { + return new TrainerEntity(new TrainerEntity.Id(teamId, memberId)); + } + + private TraineeEntity traineeEntity(UUID teamId, UUID memberId) { + return new TraineeEntity(new TraineeEntity.Id(teamId, memberId)); + } + + private DirectorEntity directorEntity(String sportName, UUID memberId) { + return new DirectorEntity(new DirectorEntity.Id(sportName, memberId)); + } + + // --- getAllTeams --- + + @Test + void getAllTeams_returnsEmptyList_whenNoTeams() { + when(teamRepository.findAll()).thenReturn(List.of()); + + assertThat(service.getAllTeams()).isEmpty(); + } + + @Test + void getAllTeams_returnsMappedList_whenTeamsExist() { + TeamEntity entity = teamEntity(TEAM_ID, "soccer", + List.of(trainerEntity(TEAM_ID, TRAINER_ID)), + List.of(traineeEntity(TEAM_ID, TRAINEE_ID))); + when(teamRepository.findAll()).thenReturn(List.of(entity)); + + List result = service.getAllTeams(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(TEAM_ID); + assertThat(result.get(0).getName()).isEqualTo("Team Alpha"); + assertThat(result.get(0).getTrainers()).containsExactly(TRAINER_ID.toString()); + assertThat(result.get(0).getTrainees()).containsExactly(TRAINEE_ID.toString()); + } + + // --- getTeam --- + + @Test + void getTeam_returnsMappedTeam_whenFound() { + TeamEntity entity = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(entity)); + + Team result = service.getTeam(TEAM_ID); + + assertThat(result.getId()).isEqualTo(TEAM_ID); + assertThat(result.getName()).isEqualTo("Team Alpha"); + assertThat(result.getDescription()).isEqualTo("A test team"); + assertThat(result.getAddress()).isEqualTo("123 Main St"); + assertThat(result.getSport()).isEqualTo("soccer"); + assertThat(result.getCreatedAt()).isEqualTo(LocalDate.of(2024, 1, 1)); + } + + @Test + void getTeam_throwsNotFoundException_whenAbsent() { + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getTeam(TEAM_ID)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining(TEAM_ID.toString()); + } + + // --- createTeam --- + + @Test + void createTeam_throwsBadRequest_whenSportNotFound() { + when(sportRepository.existsById("soccer")).thenReturn(false); + + assertThatThrownBy(() -> service.createTeam(new TeamCreate("Team Alpha", "soccer"), ADMIN_ID, true)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("soccer"); + } + + @Test + void createTeam_throwsForbidden_whenNotAdminAndNotDirector() { + UUID callerId = UUID.randomUUID(); + when(sportRepository.existsById("soccer")).thenReturn(true); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + + assertThatThrownBy(() -> service.createTeam(new TeamCreate("Team Alpha", "soccer"), callerId, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void createTeam_allowsCreate_whenDirectorOfSport() { + DirectorEntity director = directorEntity("soccer", DIRECTOR_ID); + when(sportRepository.existsById("soccer")).thenReturn(true); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(director)); + when(teamRepository.save(any(TeamEntity.class))).thenAnswer(inv -> { + TeamEntity t = inv.getArgument(0); + t.setId(TEAM_ID); + return t; + }); + TeamEntity saved = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(saved)); + + Team result = service.createTeam(new TeamCreate("Team Alpha", "soccer"), DIRECTOR_ID, false); + + verify(teamRepository).save(any(TeamEntity.class)); + assertThat(result.getId()).isEqualTo(TEAM_ID); + } + + @Test + void createTeam_throwsBadRequest_whenTrainerUuidMalformed() { + when(sportRepository.existsById("soccer")).thenReturn(true); + when(directorRepository.findAllById_SportName("soccer")) + .thenReturn(List.of(directorEntity("soccer", DIRECTOR_ID))); + + TeamCreate body = new TeamCreate("Team Alpha", "soccer"); + body.setTrainers(List.of("not-a-uuid")); + + assertThatThrownBy(() -> service.createTeam(body, DIRECTOR_ID, false)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("not-a-uuid"); + } + + @Test + void createTeam_throwsBadRequest_whenTraineeMemberNotFound() { + when(sportRepository.existsById("soccer")).thenReturn(true); + when(directorRepository.findAllById_SportName("soccer")) + .thenReturn(List.of(directorEntity("soccer", DIRECTOR_ID))); + when(memberRepository.existsById(TRAINEE_ID)).thenReturn(false); + + TeamCreate body = new TeamCreate("Team Alpha", "soccer"); + body.setTrainees(List.of(TRAINEE_ID.toString())); + + assertThatThrownBy(() -> service.createTeam(body, DIRECTOR_ID, false)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining(TRAINEE_ID.toString()); + } + + @Test + void createTeam_savesEntityAndTrainersAndTrainees_andReturnsResult() { + when(sportRepository.existsById("soccer")).thenReturn(true); + when(directorRepository.findAllById_SportName("soccer")) + .thenReturn(List.of(directorEntity("soccer", DIRECTOR_ID))); + when(memberRepository.existsById(TRAINER_ID)).thenReturn(true); + when(memberRepository.existsById(TRAINEE_ID)).thenReturn(true); + when(teamRepository.save(any(TeamEntity.class))).thenAnswer(inv -> { + TeamEntity t = inv.getArgument(0); + t.setId(TEAM_ID); + return t; + }); + TeamEntity saved = teamEntity(TEAM_ID, "soccer", + List.of(trainerEntity(TEAM_ID, TRAINER_ID)), + List.of(traineeEntity(TEAM_ID, TRAINEE_ID))); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(saved)); + + TeamCreate body = new TeamCreate("Team Alpha", "soccer"); + body.setTrainers(List.of(TRAINER_ID.toString())); + body.setTrainees(List.of(TRAINEE_ID.toString())); + Team result = service.createTeam(body, DIRECTOR_ID, false); + + verify(teamRepository).save(any(TeamEntity.class)); + verify(trainerRepository).saveAll(any()); + verify(traineeRepository).saveAll(any()); + assertThat(result.getTrainers()).containsExactly(TRAINER_ID.toString()); + assertThat(result.getTrainees()).containsExactly(TRAINEE_ID.toString()); + } + + @Test + void createTeam_savesEntityWithNoMembers_whenEmptyLists() { + when(sportRepository.existsById("soccer")).thenReturn(true); + when(teamRepository.save(any(TeamEntity.class))).thenAnswer(inv -> { + TeamEntity t = inv.getArgument(0); + t.setId(TEAM_ID); + return t; + }); + TeamEntity saved = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(saved)); + + Team result = service.createTeam(new TeamCreate("Team Alpha", "soccer"), ADMIN_ID, true); + + verify(teamRepository).save(any(TeamEntity.class)); + assertThat(result.getTrainers()).isEmpty(); + assertThat(result.getTrainees()).isEmpty(); + } + + // --- updateTeam --- + + @Test + void updateTeam_throwsNotFoundException_whenTeamAbsent() { + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateTeam(TEAM_ID, new TeamPartialUpdate(), ADMIN_ID, true)) + .isInstanceOf(NotFoundException.class); + } + + @Test + void updateTeam_throwsForbidden_whenNotAdminNotDirectorNotTrainer() { + UUID outsiderId = UUID.randomUUID(); + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.updateTeam(TEAM_ID, new TeamPartialUpdate(), outsiderId, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void updateTeam_allowsUpdate_whenMemberIsTrainer() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(team)) + .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)) + .thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID))); + + Team result = service.updateTeam(TEAM_ID, new TeamPartialUpdate(), TRAINER_ID, false); + + assertThat(result.getId()).isEqualTo(TEAM_ID); + } + + @Test + void updateTeam_allowsUpdate_whenMemberIsDirector() { + DirectorEntity director = directorEntity("soccer", DIRECTOR_ID); + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(team)) + .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(director)); + + Team result = service.updateTeam(TEAM_ID, new TeamPartialUpdate(), DIRECTOR_ID, false); + + assertThat(result.getId()).isEqualTo(TEAM_ID); + } + + @Test + void updateTeam_throwsForbidden_whenTrainerTriesToUpdateSport() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)) + .thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID))); + + TeamPartialUpdate body = new TeamPartialUpdate(); + body.setSport("football"); + + assertThatThrownBy(() -> service.updateTeam(TEAM_ID, body, TRAINER_ID, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void updateTeam_throwsForbidden_whenTrainerTriesToUpdateTrainers() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)) + .thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID))); + + TeamPartialUpdate body = new TeamPartialUpdate(); + body.setTrainers(List.of(TRAINER_ID.toString())); + + assertThatThrownBy(() -> service.updateTeam(TEAM_ID, body, TRAINER_ID, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void updateTeam_throwsBadRequest_whenNewSportNotFound() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(sportRepository.existsById("unknown")).thenReturn(false); + + TeamPartialUpdate body = new TeamPartialUpdate(); + body.setSport("unknown"); + + assertThatThrownBy(() -> service.updateTeam(TEAM_ID, body, ADMIN_ID, true)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("unknown"); + } + + @Test + void updateTeam_updatesScalarFields_whenNonNull() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(team)) + .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)) + .thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID))); + + TeamPartialUpdate body = new TeamPartialUpdate(); + body.setName("New Name"); + body.setDescription("New Desc"); + body.setAddress("New Address"); + service.updateTeam(TEAM_ID, body, TRAINER_ID, false); + + assertThat(team.getName()).isEqualTo("New Name"); + assertThat(team.getDescription()).isEqualTo("New Desc"); + assertThat(team.getAddress()).isEqualTo("New Address"); + verify(teamRepository).save(team); + } + + @Test + void updateTeam_doesNotUpdateScalarField_whenNull() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(team)) + .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)) + .thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID))); + + service.updateTeam(TEAM_ID, new TeamPartialUpdate(), TRAINER_ID, false); + + assertThat(team.getName()).isEqualTo("Team Alpha"); + assertThat(team.getDescription()).isEqualTo("A test team"); + assertThat(team.getAddress()).isEqualTo("123 Main St"); + } + + @Test + void updateTeam_updatesSport_whenAdminSetsNewSport() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(team)) + .thenReturn(Optional.of(teamEntity(TEAM_ID, "football", List.of(), List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(sportRepository.existsById("football")).thenReturn(true); + + TeamPartialUpdate body = new TeamPartialUpdate(); + body.setSport("football"); + Team result = service.updateTeam(TEAM_ID, body, ADMIN_ID, true); + + assertThat(team.getSportName()).isEqualTo("football"); + verify(teamRepository).save(team); + assertThat(result.getSport()).isEqualTo("football"); + } + + @Test + void updateTeam_doesNotReplaceTrainers_whenEmptyList() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(team)) + .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)) + .thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID))); + + service.updateTeam(TEAM_ID, new TeamPartialUpdate(), TRAINER_ID, false); + + verify(trainerRepository, never()).deleteAllById_TeamId(any()); + verify(trainerRepository, never()).saveAll(any()); + } + + @Test + void updateTeam_replacesTrainers_whenNonEmptyList() { + DirectorEntity director = directorEntity("soccer", DIRECTOR_ID); + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(team)) + .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", + List.of(trainerEntity(TEAM_ID, TRAINER_ID)), List.of()))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(director)); + when(memberRepository.existsById(TRAINER_ID)).thenReturn(true); + + TeamPartialUpdate body = new TeamPartialUpdate(); + body.setTrainers(List.of(TRAINER_ID.toString())); + service.updateTeam(TEAM_ID, body, DIRECTOR_ID, false); + + verify(trainerRepository).deleteAllById_TeamId(TEAM_ID); + verify(trainerRepository).saveAll(any()); + } + + @Test + void updateTeam_replacesTrainees_whenNonEmptyList() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(team)) + .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", + List.of(), List.of(traineeEntity(TEAM_ID, TRAINEE_ID))))); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)) + .thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID))); + when(memberRepository.existsById(TRAINEE_ID)).thenReturn(true); + + TeamPartialUpdate body = new TeamPartialUpdate(); + body.setTrainees(List.of(TRAINEE_ID.toString())); + service.updateTeam(TEAM_ID, body, TRAINER_ID, false); + + verify(traineeRepository).deleteAllById_TeamId(TEAM_ID); + verify(traineeRepository).saveAll(any()); + } + + @Test + void updateTeam_throwsBadRequest_whenTraineeUuidMalformed() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + when(trainerRepository.findAllById_TeamId(TEAM_ID)) + .thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID))); + + TeamPartialUpdate body = new TeamPartialUpdate(); + body.setTrainees(List.of("not-a-uuid")); + + assertThatThrownBy(() -> service.updateTeam(TEAM_ID, body, TRAINER_ID, false)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("not-a-uuid"); + } + + // --- deleteTeam --- + + @Test + void deleteTeam_throwsNotFoundException_whenAbsent() { + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deleteTeam(TEAM_ID, ADMIN_ID, true)) + .isInstanceOf(NotFoundException.class); + } + + @Test + void deleteTeam_throwsForbidden_whenTrainerTriesToDelete() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of()); + + assertThatThrownBy(() -> service.deleteTeam(TEAM_ID, TRAINER_ID, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void deleteTeam_allowsDelete_whenDirectorOfSport() { + DirectorEntity director = directorEntity("soccer", DIRECTOR_ID); + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(director)); + + service.deleteTeam(TEAM_ID, DIRECTOR_ID, false); + + verify(teamRepository).delete(team); + } + + @Test + void deleteTeam_deletesTraineesTrainersAndTeam() { + TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of()); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(directorRepository.findAllById_SportName("soccer")) + .thenReturn(List.of(directorEntity("soccer", DIRECTOR_ID))); + + service.deleteTeam(TEAM_ID, DIRECTOR_ID, false); + + verify(traineeRepository).deleteAllById_TeamId(TEAM_ID); + verify(trainerRepository).deleteAllById_TeamId(TEAM_ID); + verify(teamRepository).delete(team); + } +} diff --git a/web-client/src/api.ts b/web-client/src/api.ts index b4a6582..1e23e85 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -13,13 +13,15 @@ export interface paths { }; /** * Get all sports - * @description Returns a list of all sports registered in the organization. If the caller is not an admin, only sports that the caller is a member of will be returned. + * @description Returns a list of all sports registered in the organization. + * - All authenticated users: can access this endpoint. */ get: operations["getAllSports"]; put?: never; /** * Create sport - * @description Creates a new sport in the organization. Only admins are allowed to create new sports. + * @description Creates a new sport in the organization. + * - Admins: can create sports. */ post: operations["createSport"]; delete?: never; @@ -35,15 +37,28 @@ export interface paths { path?: never; cookie?: never; }; - /** Get sport */ + /** + * Get sport + * @description Returns the details of a specific sport. + * - All authenticated users: can access this endpoint. + */ get: operations["getSport"]; put?: never; post?: never; - /** Delete sport */ + /** + * Delete sport + * @description Deletes a sport from the organization. + * - Admins: can delete sports. + */ delete: operations["deleteSport"]; options?: never; head?: never; - /** Update sport */ + /** + * Update sport + * @description Partially updates an existing sport's details. + * - Directors: can update all fields except directors. + * - Admins: can update all fields. + */ patch: operations["updateSport"]; trace?: never; }; @@ -54,10 +69,19 @@ export interface paths { path?: never; cookie?: never; }; - /** Get all teams */ + /** + * Get all teams + * @description Returns a list of all teams in the organization. + * - All authenticated users: can access this endpoint. + */ get: operations["getAllTeams"]; put?: never; - /** Create team */ + /** + * Create team + * @description Creates a new team in the organization. + * - Directors: can create teams for their own sport. + * - Admins: can create teams for any sport. + */ post: operations["createTeam"]; delete?: never; options?: never; @@ -72,15 +96,30 @@ export interface paths { path?: never; cookie?: never; }; - /** Get team */ + /** + * Get team + * @description Returns the details of a specific team. + * - All authenticated users: can access this endpoint. + */ get: operations["getTeam"]; put?: never; post?: never; - /** Delete team */ + /** + * Delete team + * @description Deletes a team from the organization. + * - Directors: can delete teams in their sport. + * - Admins: can delete any team. + */ delete: operations["deleteTeam"]; options?: never; head?: never; - /** Update team */ + /** + * Update team + * @description Partially updates a team's details. + * - Trainers: can update all fields except sport and trainers. + * - Directors: can update all fields except sport. + * - Admins: can update all fields. + */ patch: operations["updateTeam"]; trace?: never; }; @@ -91,10 +130,18 @@ export interface paths { path?: never; cookie?: never; }; - /** Get all members */ + /** + * Get all members + * @description Returns a list of all members in the organization. + * - All authenticated users: can access this endpoint. + */ get: operations["getAllMembers"]; put?: never; - /** Create member */ + /** + * Create member + * @description Creates a new member in the organization. Includes a password field for setting initial credentials. + * - Admins: can create members. + */ post: operations["createMember"]; delete?: never; options?: never; @@ -109,15 +156,32 @@ export interface paths { path?: never; cookie?: never; }; - /** Get member details */ + /** + * Get member details + * @description Returns the full details of a specific member. + * - Members themselves: can view their own details. + * - Team members: can view details of others in the same team. + * - Trainers: can view details of members in their team. + * - Directors: can view details of members in their sport. + * - Admins: can view any member's details. + */ get: operations["getMemberDetails"]; put?: never; post?: never; - /** Delete member */ + /** + * Delete member + * @description Deletes a member from the organization. + * - Admins: can delete members. + */ delete: operations["deleteMember"]; options?: never; head?: never; - /** Update member details */ + /** + * Update member details + * @description Partially updates the details of a specific member. + * - Members themselves: can update their own details. + * - Admins: can update any member's details. + */ patch: operations["updateMemberDetails"]; trace?: never; }; @@ -128,10 +192,22 @@ export interface paths { path?: never; cookie?: never; }; - /** Get all events */ + /** + * Get all events + * @description Returns a list of all events. + * - All authenticated users: can see events linked to their team or sport, or events where they are an attendee. + * - Creators: can see all events they created. + * - Admins: can see all events. + */ get: operations["getAllEvents"]; put?: never; - /** Create event */ + /** + * Create event + * @description Creates a new event. + * - Directors: can create events for their sport. + * - Trainers: can create events for their team. + * - Admins: can create any event. + */ post: operations["createEvent"]; delete?: never; options?: never; @@ -146,15 +222,33 @@ export interface paths { path?: never; cookie?: never; }; - /** Get event details */ + /** + * Get event details + * @description Returns the details of a specific event. + * - All authenticated users: can access events linked to their team or sport, or events where they are an attendee. + * - Creators: can view events they created. + * - Admins: can view any event. + */ get: operations["getEventDetails"]; put?: never; post?: never; - /** Delete event */ + /** + * Delete event + * @description Deletes a specific event. + * - Creators: can delete events they created. + * - Directors: can delete events linked to their sport. + * - Admins: can delete any event. + */ delete: operations["deleteEvent"]; options?: never; head?: never; - /** Update event details */ + /** + * Update event details + * @description Partially updates the details of a specific event. + * - Creators: can update events they created. + * - Directors: can update events linked to their sport. + * - Admins: can update any event. + */ patch: operations["updateEventDetails"]; trace?: never; }; @@ -165,10 +259,21 @@ export interface paths { path?: never; cookie?: never; }; - /** Get all feedback */ + /** + * Get all feedback + * @description Returns a list of all feedback entries. + * - Creators: can see feedback they submitted. + * - Members: can see feedback about themselves. + * - Admins: can see all feedback. + */ get: operations["getAllFeedback"]; put?: never; - /** Create feedback */ + /** + * Create feedback + * @description Creates a new feedback entry for a member. + * - Trainers: can create feedback for their trainees. + * - Admins: can create feedback for any member. + */ post: operations["createFeedback"]; delete?: never; options?: never; @@ -183,15 +288,31 @@ export interface paths { path?: never; cookie?: never; }; - /** Get feedback details */ + /** + * Get feedback details + * @description Returns the details of a specific feedback entry. + * - Creators: can view feedback they submitted. + * - Members: can view feedback about themselves. + * - Admins: can view any feedback. + */ get: operations["getFeedbackDetails"]; put?: never; post?: never; - /** Delete feedback */ + /** + * Delete feedback + * @description Deletes a specific feedback entry. + * - Creators: can delete feedback they submitted. + * - Admins: can delete any feedback. + */ delete: operations["deleteFeedback"]; options?: never; head?: never; - /** Update feedback details */ + /** + * Update feedback details + * @description Partially updates a specific feedback entry. + * - Creators: can update feedback they submitted. + * - Admins: can update any feedback. + */ patch: operations["updateFeedbackDetails"]; trace?: never; }; @@ -202,7 +323,12 @@ export interface paths { path?: never; cookie?: never; }; - /** Get all balances */ + /** + * Get all balances + * @description Returns a list of all member balances. + * - Directors: can view balances of members in their sport. + * - Admins: can view all balances. + */ get: operations["getAllBalances"]; put?: never; post?: never; @@ -219,7 +345,13 @@ export interface paths { path?: never; cookie?: never; }; - /** Get member balance */ + /** + * Get member balance + * @description Returns the balance of a specific member. + * - Members themselves: can view their own balance. + * - Directors: can view balances of members in their sport. + * - Admins: can view any member's balance. + */ get: operations["getMemberBalance"]; put?: never; post?: never; @@ -236,10 +368,22 @@ export interface paths { path?: never; cookie?: never; }; - /** Get all transactions */ + /** + * Get all transactions + * @description Returns a list of all transactions. Users only see transactions where they are the member or the creator. + * - Members: can see transactions they are part of. + * - Creators: can see transactions they created. + * - Directors: can see transactions for members in their sport. + * - Admins: can see all transactions. + */ get: operations["getAllTransactions"]; put?: never; - /** Create transaction */ + /** + * Create transaction + * @description Creates a new financial transaction for a member. + * - Directors: can create transactions for members in their sport. + * - Admins: can create transactions for any member. + */ post: operations["createTransaction"]; delete?: never; options?: never; @@ -254,15 +398,32 @@ export interface paths { path?: never; cookie?: never; }; - /** Get transaction */ + /** + * Get transaction + * @description Returns the details of a specific transaction. + * - Members: can view transactions they are part of. + * - Creators: can view transactions they created. + * - Directors: can view transactions for members in their sport. + * - Admins: can view any transaction. + */ get: operations["getTransaction"]; put?: never; post?: never; - /** Delete transaction */ + /** + * Delete transaction + * @description Deletes a specific transaction. + * - Creators: can delete transactions they created. + * - Admins: can delete any transaction. + */ delete: operations["deleteTransaction"]; options?: never; head?: never; - /** Update transaction */ + /** + * Update transaction + * @description Partially updates a specific transaction. The member field can only be changed by admins. + * - Creators: can update transactions they created (except the member field). + * - Admins: can update any transaction including the member field. + */ patch: operations["updateTransaction"]; trace?: never; }; @@ -275,7 +436,13 @@ export interface paths { }; get?: never; put?: never; - /** Send mail */ + /** + * Send mail + * @description Sends an email based on the provided HTML template. + * - Trainers: can send mail to members of their team. + * - Directors: can send mail to members related to their sport. + * - Admins: can send mail to any member. + */ post: operations["sendMail"]; delete?: never; options?: never; @@ -292,7 +459,13 @@ export interface paths { }; get?: never; put?: never; - /** Get pdf */ + /** + * Get pdf + * @description Generates and returns a PDF document from the provided HTML template. + * - Trainers: can generate PDFs related to their team. + * - Directors: can generate PDFs related to their sport. + * - Admins: can generate PDFs related to any member. + */ post: operations["getPdf"]; delete?: never; options?: never; @@ -307,7 +480,13 @@ export interface paths { path?: never; cookie?: never; }; - /** Generate report */ + /** + * Generate report + * @description Generates an AI-based report for a member. Members can only generate reports for themselves. + * - All authenticated users: can generate a report for themselves. + * - Trainers: can generate reports for members of their team. + * - Admin: can generate a report for any member. + */ get: operations["generateReport"]; put?: never; post?: never; @@ -418,6 +597,8 @@ export interface components { first_name: string; last_name: string; email: string; + /** Format: password */ + password: string; /** Format: date */ birthday?: string; phone_number?: string;