diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index c0bcafe..308007b 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,3 +1,2 @@ -wrapperVersion=3.3.4 -distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar diff --git a/docs/project-plan.md b/docs/project-plan.md index a2dfc9a..7a86059 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -144,15 +144,23 @@ Status: βœ… Completed --- -## πŸ”œ Phase 06 – Update & Delete Operations +## βœ… Phase 06 – Update & Delete Operations **Goal:** Complete CRUD cycle properly. -- PUT endpoints -- DELETE endpoints -- Cache invalidation on writes -- Proper transaction boundaries -- Validation rules +- PUT endpoints (full update with validation) +- PATCH endpoints (partial update, nullable fields) +- DELETE endpoints (with 204 No Content) +- Relationship management endpoints (add/remove tags from tracks, tracks from playlists) +- Entity `update()` methods for controlled mutation +- V5 Flyway migration: `ON DELETE CASCADE` on join table foreign keys +- Enriched response DTOs (tracks include tags, playlists include tracks) +- `DataIntegrityViolationException` β†’ 409 Conflict handler +- `@Transactional` + `@CacheEvict(allEntries=true)` on all write operations +- Update/Patch request DTOs per entity +- Integration tests for all new endpoints + +Status: βœ… Completed --- @@ -249,4 +257,4 @@ The project will be considered β€œenterprise-ready” when: # πŸ“Œ Current Phase -πŸ‘‰ Phase 06 – Update & Delete Operations +πŸ‘‰ Phase 07 – Pagination & Filtering diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java b/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java index 31b4027..02e744e 100644 --- a/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java +++ b/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java @@ -1,38 +1,140 @@ -package com.jfontdev.trackstack.controller; - -import com.jfontdev.trackstack.dto.playlist.PlaylistRequestDTO; -import com.jfontdev.trackstack.dto.playlist.PlaylistResponseDTO; -import com.jfontdev.trackstack.service.PlaylistService; -import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/playlists") -public class PlaylistController { - - private final PlaylistService playlistService; - - public PlaylistController(PlaylistService playlistService) { - this.playlistService = playlistService; - } - - @PostMapping - public ResponseEntity create(@Valid @RequestBody PlaylistRequestDTO dto) { - PlaylistResponseDTO response = playlistService.createPlaylist(dto); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @GetMapping("/{id}") - public PlaylistResponseDTO getById(@PathVariable Long id) { - return playlistService.getPlaylistById(id); - } - - @GetMapping - public List getAll() { - return playlistService.getAllPlaylists(); - } -} +package com.jfontdev.trackstack.controller; + +import com.jfontdev.trackstack.dto.playlist.PlaylistPatchRequestDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistRequestDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistResponseDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistUpdateRequestDTO; +import com.jfontdev.trackstack.service.PlaylistService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * REST controller for managing playlists. + *

+ * Provides endpoints for full CRUD operations on playlists, as well as + * track relationship management. This controller delegates all business + * logic to the {@link PlaylistService} and only handles HTTP concerns + * (request binding, status codes, response formatting). + */ +@RestController +@RequestMapping("/api/playlists") +public class PlaylistController { + + private final PlaylistService playlistService; + + /** + * Constructs a new {@code PlaylistController} with the required service. + * + * @param playlistService the service handling playlist business logic + */ + public PlaylistController(PlaylistService playlistService) { + this.playlistService = playlistService; + } + + /** + * Creates a new playlist. + * + * @param dto the validated request body containing playlist details + * @return 201 Created with the newly created playlist + */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody PlaylistRequestDTO dto) { + PlaylistResponseDTO response = playlistService.createPlaylist(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * Retrieves a playlist by its ID. + * + * @param id the playlist's unique identifier + * @return 200 OK with the playlist details (including tracks), or 404 if not found + */ + @GetMapping("/{id}") + public PlaylistResponseDTO getById(@PathVariable Long id) { + return playlistService.getPlaylistById(id); + } + + /** + * Retrieves all playlists. + * + * @return 200 OK with a list of all playlists (each including their tracks) + */ + @GetMapping + public List getAll() { + return playlistService.getAllPlaylists(); + } + + /** + * Fully updates an existing playlist (PUT semantics). + *

+ * All fields in the request body replace the existing values. + * + * @param id the playlist's unique identifier + * @param dto the validated request body containing the new playlist details + * @return 200 OK with the updated playlist, or 404 if not found + */ + @PutMapping("/{id}") + public PlaylistResponseDTO update(@PathVariable Long id, @Valid @RequestBody PlaylistUpdateRequestDTO dto) { + return playlistService.updatePlaylist(id, dto); + } + + /** + * Partially updates an existing playlist (PATCH semantics). + *

+ * Only non-null fields in the request body are applied to the existing playlist. + * + * @param id the playlist's unique identifier + * @param dto the validated request body containing the fields to update + * @return 200 OK with the updated playlist, or 404 if not found + */ + @PatchMapping("/{id}") + public PlaylistResponseDTO patch(@PathVariable Long id, @Valid @RequestBody PlaylistPatchRequestDTO dto) { + return playlistService.patchPlaylist(id, dto); + } + + /** + * Deletes a playlist by its ID. + *

+ * The tracks themselves are not deleted -- only the playlist and its + * track associations are removed (handled by ON DELETE CASCADE at the + * database level). + * + * @param id the playlist's unique identifier + * @return 204 No Content on success, or 404 if not found + */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + playlistService.deletePlaylist(id); + return ResponseEntity.noContent().build(); + } + + /** + * Adds a track to a playlist. + * + * @param id the playlist's unique identifier + * @param trackId the track's unique identifier + * @return 200 OK with the updated playlist (including the new track), or 404 if + * either the playlist or track is not found + */ + @PutMapping("/{id}/tracks/{trackId}") + public PlaylistResponseDTO addTrack(@PathVariable Long id, @PathVariable Long trackId) { + return playlistService.addTrackToPlaylist(id, trackId); + } + + /** + * Removes a track from a playlist. + * + * @param id the playlist's unique identifier + * @param trackId the track's unique identifier + * @return 200 OK with the updated playlist (without the removed track), or 404 if + * either the playlist or track is not found + */ + @DeleteMapping("/{id}/tracks/{trackId}") + public PlaylistResponseDTO removeTrack(@PathVariable Long id, @PathVariable Long trackId) { + return playlistService.removeTrackFromPlaylist(id, trackId); + } +} diff --git a/src/main/java/com/jfontdev/trackstack/controller/TagController.java b/src/main/java/com/jfontdev/trackstack/controller/TagController.java index 4464b69..db2e06a 100644 --- a/src/main/java/com/jfontdev/trackstack/controller/TagController.java +++ b/src/main/java/com/jfontdev/trackstack/controller/TagController.java @@ -1,39 +1,113 @@ -package com.jfontdev.trackstack.controller; - - -import com.jfontdev.trackstack.dto.tag.TagRequestDTO; -import com.jfontdev.trackstack.dto.tag.TagResponseDTO; -import com.jfontdev.trackstack.service.TagService; -import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/tags") -public class TagController { - - private final TagService tagService; - - public TagController(TagService tagService) { - this.tagService = tagService; - } - - @PostMapping - public ResponseEntity create(@Valid @RequestBody TagRequestDTO dto) { - TagResponseDTO response = tagService.createTag(dto); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @GetMapping("/{id}") - public TagResponseDTO getById(@PathVariable Long id) { - return tagService.getTagById(id); - } - - @GetMapping - public List getAll() { - return tagService.getAllTags(); - } -} +package com.jfontdev.trackstack.controller; + +import com.jfontdev.trackstack.dto.tag.TagPatchRequestDTO; +import com.jfontdev.trackstack.dto.tag.TagRequestDTO; +import com.jfontdev.trackstack.dto.tag.TagResponseDTO; +import com.jfontdev.trackstack.dto.tag.TagUpdateRequestDTO; +import com.jfontdev.trackstack.service.TagService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * REST controller for managing tags. + *

+ * Provides endpoints for full CRUD operations on tags. This controller + * delegates all business logic to the {@link TagService} and only handles + * HTTP concerns (request binding, status codes, response formatting). + */ +@RestController +@RequestMapping("/api/tags") +public class TagController { + + private final TagService tagService; + + /** + * Constructs a new {@code TagController} with the required service. + * + * @param tagService the service handling tag business logic + */ + public TagController(TagService tagService) { + this.tagService = tagService; + } + + /** + * Creates a new tag. + * + * @param dto the validated request body containing tag details + * @return 201 Created with the newly created tag + */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody TagRequestDTO dto) { + TagResponseDTO response = tagService.createTag(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * Retrieves a tag by its ID. + * + * @param id the tag's unique identifier + * @return 200 OK with the tag details, or 404 if not found + */ + @GetMapping("/{id}") + public TagResponseDTO getById(@PathVariable Long id) { + return tagService.getTagById(id); + } + + /** + * Retrieves all tags. + * + * @return 200 OK with a list of all tags + */ + @GetMapping + public List getAll() { + return tagService.getAllTags(); + } + + /** + * Fully updates an existing tag (PUT semantics). + *

+ * The tag name in the request body replaces the existing value. + * The new name must remain unique. + * + * @param id the tag's unique identifier + * @param dto the validated request body containing the new tag details + * @return 200 OK with the updated tag, or 404 if not found + */ + @PutMapping("/{id}") + public TagResponseDTO update(@PathVariable Long id, @Valid @RequestBody TagUpdateRequestDTO dto) { + return tagService.updateTag(id, dto); + } + + /** + * Partially updates an existing tag (PATCH semantics). + *

+ * Only non-null fields in the request body are applied to the existing tag. + * + * @param id the tag's unique identifier + * @param dto the validated request body containing the fields to update + * @return 200 OK with the updated tag, or 404 if not found + */ + @PatchMapping("/{id}") + public TagResponseDTO patch(@PathVariable Long id, @Valid @RequestBody TagPatchRequestDTO dto) { + return tagService.patchTag(id, dto); + } + + /** + * Deletes a tag by its ID. + *

+ * Also removes all track associations for this tag (handled by + * ON DELETE CASCADE at the database level). + * + * @param id the tag's unique identifier + * @return 204 No Content on success, or 404 if not found + */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + tagService.deleteTag(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/jfontdev/trackstack/controller/TrackController.java b/src/main/java/com/jfontdev/trackstack/controller/TrackController.java index ee9c90d..2732bc8 100644 --- a/src/main/java/com/jfontdev/trackstack/controller/TrackController.java +++ b/src/main/java/com/jfontdev/trackstack/controller/TrackController.java @@ -1,39 +1,139 @@ -package com.jfontdev.trackstack.controller; - -import com.jfontdev.trackstack.dto.track.TrackRequestDTO; -import com.jfontdev.trackstack.dto.track.TrackResponseDTO; -import com.jfontdev.trackstack.service.TrackService; -import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/tracks") -public class TrackController { - - - private final TrackService trackService; - - public TrackController(TrackService trackService) { - this.trackService = trackService; - } - - @PostMapping - public ResponseEntity create(@Valid @RequestBody TrackRequestDTO dto) { - TrackResponseDTO response = trackService.createTrack(dto); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @GetMapping("/{id}") - public TrackResponseDTO getById(@PathVariable Long id) { - return trackService.getTrackById(id); - } - - @GetMapping - public List getAll() { - return trackService.getAllTracks(); - } -} +package com.jfontdev.trackstack.controller; + +import com.jfontdev.trackstack.dto.track.TrackPatchRequestDTO; +import com.jfontdev.trackstack.dto.track.TrackRequestDTO; +import com.jfontdev.trackstack.dto.track.TrackResponseDTO; +import com.jfontdev.trackstack.dto.track.TrackUpdateRequestDTO; +import com.jfontdev.trackstack.service.TrackService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * REST controller for managing tracks. + *

+ * Provides endpoints for full CRUD operations on tracks, as well as + * tag relationship management. This controller delegates all business + * logic to the {@link TrackService} and only handles HTTP concerns + * (request binding, status codes, response formatting). + */ +@RestController +@RequestMapping("/api/tracks") +public class TrackController { + + private final TrackService trackService; + + /** + * Constructs a new {@code TrackController} with the required service. + * + * @param trackService the service handling track business logic + */ + public TrackController(TrackService trackService) { + this.trackService = trackService; + } + + /** + * Creates a new track. + * + * @param dto the validated request body containing track details + * @return 201 Created with the newly created track + */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody TrackRequestDTO dto) { + TrackResponseDTO response = trackService.createTrack(dto); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * Retrieves a track by its ID. + * + * @param id the track's unique identifier + * @return 200 OK with the track details, or 404 if not found + */ + @GetMapping("/{id}") + public TrackResponseDTO getById(@PathVariable Long id) { + return trackService.getTrackById(id); + } + + /** + * Retrieves all tracks. + * + * @return 200 OK with a list of all tracks + */ + @GetMapping + public List getAll() { + return trackService.getAllTracks(); + } + + /** + * Fully updates an existing track (PUT semantics). + *

+ * All fields in the request body replace the existing values. + * + * @param id the track's unique identifier + * @param dto the validated request body containing the new track details + * @return 200 OK with the updated track, or 404 if not found + */ + @PutMapping("/{id}") + public TrackResponseDTO update(@PathVariable Long id, @Valid @RequestBody TrackUpdateRequestDTO dto) { + return trackService.updateTrack(id, dto); + } + + /** + * Partially updates an existing track (PATCH semantics). + *

+ * Only non-null fields in the request body are applied to the existing track. + * + * @param id the track's unique identifier + * @param dto the request body containing the fields to update + * @return 200 OK with the updated track, or 404 if not found + */ + @PatchMapping("/{id}") + public TrackResponseDTO patch(@PathVariable Long id, @Valid @RequestBody TrackPatchRequestDTO dto) { + return trackService.patchTrack(id, dto); + } + + /** + * Deletes a track by its ID. + *

+ * Also removes all tag associations and playlist memberships for this track + * (handled by ON DELETE CASCADE at the database level). + * + * @param id the track's unique identifier + * @return 204 No Content on success, or 404 if not found + */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + trackService.deleteTrack(id); + return ResponseEntity.noContent().build(); + } + + /** + * Associates a tag with a track. + * + * @param id the track's unique identifier + * @param tagId the tag's unique identifier + * @return 200 OK with the updated track (including the new tag), or 404 if + * either the track or tag is not found + */ + @PutMapping("/{id}/tags/{tagId}") + public TrackResponseDTO addTag(@PathVariable Long id, @PathVariable Long tagId) { + return trackService.addTagToTrack(id, tagId); + } + + /** + * Removes a tag association from a track. + * + * @param id the track's unique identifier + * @param tagId the tag's unique identifier + * @return 200 OK with the updated track (without the removed tag), or 404 if + * either the track or tag is not found + */ + @DeleteMapping("/{id}/tags/{tagId}") + public TrackResponseDTO removeTag(@PathVariable Long id, @PathVariable Long tagId) { + return trackService.removeTagFromTrack(id, tagId); + } +} diff --git a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java new file mode 100644 index 0000000..eb94dd6 --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java @@ -0,0 +1,20 @@ +package com.jfontdev.trackstack.dto.playlist; + +import jakarta.validation.constraints.Size; + +/** + * Request DTO for partially updating (PATCH) an existing playlist. + *

+ * All fields are nullable because PATCH semantics allow updating only a subset + * of fields. The service layer merges non-null values with the existing entity + * state before persisting. Nullable-friendly validations ensure that if a field + * IS provided, it meets domain invariants (no empty strings). + * + * @param name the new playlist name, or null to keep the current value + * @param description the new playlist description, or null to keep the current + * value + */ +public record PlaylistPatchRequestDTO( + @Size(min = 1, message = "Name must not be empty if provided") String name, + @Size(max = 500, message = "Description must not exceed 500 characters") String description) { +} diff --git a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistRequestDTO.java index 2faf097..bba35f3 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistRequestDTO.java @@ -1,9 +1,9 @@ package com.jfontdev.trackstack.dto.playlist; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; public record PlaylistRequestDTO( - @NotBlank String name, - String description -) { + @NotBlank(message = "Name must not be empty") String name, + @Size(max = 500, message = "Description must not exceed 500 characters") String description) { } \ No newline at end of file diff --git a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistResponseDTO.java b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistResponseDTO.java index ac4870a..448f0bd 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistResponseDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistResponseDTO.java @@ -1,8 +1,25 @@ -package com.jfontdev.trackstack.dto.playlist; - -public record PlaylistResponseDTO( - Long id, - String name, - String description -) { -} \ No newline at end of file +package com.jfontdev.trackstack.dto.playlist; + +import com.jfontdev.trackstack.dto.track.TrackResponseDTO; + +import java.util.List; + +/** + * Response DTO representing a playlist returned by the API. + *

+ * Includes the playlist's metadata and its associated tracks. This DTO is the + * single representation of a playlist in all API responses (create, update, + * get by ID, list). + * + * @param id the playlist's unique identifier + * @param name the playlist name + * @param description the playlist description + * @param tracks the tracks belonging to this playlist + */ +public record PlaylistResponseDTO( + Long id, + String name, + String description, + List tracks +) { +} diff --git a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistUpdateRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistUpdateRequestDTO.java new file mode 100644 index 0000000..a0e4de2 --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistUpdateRequestDTO.java @@ -0,0 +1,19 @@ +package com.jfontdev.trackstack.dto.playlist; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Request DTO for fully updating (PUT) an existing playlist. + *

+ * PUT semantics require all fields to be provided. The description field + * is nullable on the entity, so omitting it (or sending null) clears the + * description. + * + * @param name the new playlist name (required) + * @param description the new playlist description (optional) + */ +public record PlaylistUpdateRequestDTO( + @NotBlank(message = "Name must not be empty") String name, + @Size(max = 500, message = "Description must not exceed 500 characters") String description) { +} diff --git a/src/main/java/com/jfontdev/trackstack/dto/tag/TagPatchRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/tag/TagPatchRequestDTO.java new file mode 100644 index 0000000..f79f2d0 --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/tag/TagPatchRequestDTO.java @@ -0,0 +1,18 @@ +package com.jfontdev.trackstack.dto.tag; + +import jakarta.validation.constraints.Size; + +/** + * Request DTO for partially updating (PATCH) an existing tag. + *

+ * All fields are nullable because PATCH semantics allow updating only a subset + * of fields. The service layer merges non-null values with the existing entity + * state before persisting. Nullable-friendly validations ensure that if a field + * IS provided, it meets domain invariants (no empty strings). + * + * @param name the new tag name, or null to keep the current value + */ +public record TagPatchRequestDTO( + @Size(min = 1, message = "Name must not be empty if provided") String name +) { +} diff --git a/src/main/java/com/jfontdev/trackstack/dto/tag/TagRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/tag/TagRequestDTO.java index a5a49cf..ed2455b 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/tag/TagRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/tag/TagRequestDTO.java @@ -3,6 +3,5 @@ import jakarta.validation.constraints.NotBlank; public record TagRequestDTO( - @NotBlank String name -) { + @NotBlank(message = "Name must not be empty") String name) { } \ No newline at end of file diff --git a/src/main/java/com/jfontdev/trackstack/dto/tag/TagUpdateRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/tag/TagUpdateRequestDTO.java new file mode 100644 index 0000000..da04f56 --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/tag/TagUpdateRequestDTO.java @@ -0,0 +1,15 @@ +package com.jfontdev.trackstack.dto.tag; + +import jakarta.validation.constraints.NotBlank; + +/** + * Request DTO for fully updating (PUT) an existing tag. + *

+ * PUT semantics require all fields to be provided. The tag name must not be + * blank and must remain unique (enforced at the database level). + * + * @param name the new tag name (required, must be unique) + */ +public record TagUpdateRequestDTO( + @NotBlank(message = "Name must not be empty") String name) { +} diff --git a/src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java new file mode 100644 index 0000000..3c0373a --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java @@ -0,0 +1,27 @@ +package com.jfontdev.trackstack.dto.track; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +/** + * Request DTO for partially updating (PATCH) an existing track. + *

+ * All fields are nullable because PATCH semantics allow updating only a subset + * of fields. The service layer merges non-null values from this DTO with the + * existing entity state before persisting. Nullable-friendly validations ensure + * that if a field IS provided, it meets domain invariants (no empty strings, + * no negative BPM, correct duration format). + * + * @param title the new track title, or null to keep the current value + * @param artist the new track artist, or null to keep the current value + * @param bpm the new beats per minute, or null to keep the current value + * @param key the new musical key, or null to keep the current value + * @param duration the new track duration, or null to keep the current value + */ +public record TrackPatchRequestDTO(@Size(min = 1, message = "Title must not be empty if provided") String title, + @Size(min = 1, message = "Artist must not be empty if provided") String artist, + @Positive(message = "BPM must be positive if provided") Double bpm, + String key, + @Pattern(regexp = "^\\d+:\\d{2}$", message = "Duration must be in mm:ss format if provided") String duration) { +} diff --git a/src/main/java/com/jfontdev/trackstack/dto/track/TrackRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/track/TrackRequestDTO.java index b9f21d5..1d23ef2 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/track/TrackRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackRequestDTO.java @@ -1,10 +1,12 @@ package com.jfontdev.trackstack.dto.track; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; -public record TrackRequestDTO(@NotBlank String title, - @NotBlank String artist, - Double bpm, - String key, - @NotBlank String duration) { +public record TrackRequestDTO(@NotBlank(message = "Title must not be empty") String title, + @NotBlank(message = "Artist must not be empty") String artist, + @Positive(message = "BPM must be positive if provided") Double bpm, + String key, + @NotBlank(message = "Duration must not be empty") @Pattern(regexp = "^\\d+:[0-5]\\d$", message = "Duration must be in mm:ss format") String duration) { } diff --git a/src/main/java/com/jfontdev/trackstack/dto/track/TrackResponseDTO.java b/src/main/java/com/jfontdev/trackstack/dto/track/TrackResponseDTO.java index 2f4d1c0..7855584 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/track/TrackResponseDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackResponseDTO.java @@ -1,9 +1,29 @@ -package com.jfontdev.trackstack.dto.track; - -public record TrackResponseDTO(Long id, - String title, - String artist, - Double bpm, - String key, - String duration) { -} +package com.jfontdev.trackstack.dto.track; + +import com.jfontdev.trackstack.dto.tag.TagResponseDTO; + +import java.util.List; + +/** + * Response DTO representing a track returned by the API. + *

+ * Includes the track's metadata and its associated tags. This DTO is the + * single representation of a track in all API responses (create, update, + * get by ID, list). + * + * @param id the track's unique identifier + * @param title the track title + * @param artist the track artist + * @param bpm the beats per minute + * @param key the musical key + * @param duration the track duration + * @param tags the tags associated with this track + */ +public record TrackResponseDTO(Long id, + String title, + String artist, + Double bpm, + String key, + String duration, + List tags) { +} diff --git a/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java new file mode 100644 index 0000000..f1ea755 --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java @@ -0,0 +1,25 @@ +package com.jfontdev.trackstack.dto.track; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +/** + * Request DTO for fully updating (PUT) an existing track. + *

+ * All required fields must be provided because PUT semantics replace the + * entire resource. Fields that are nullable on the entity (bpm, key) remain + * nullable here -- omitting them sets them to null on the entity. + * + * @param title the track title (required) + * @param artist the track artist (required) + * @param bpm the beats per minute (optional) + * @param key the musical key (optional) + * @param duration the track duration (required) + */ +public record TrackUpdateRequestDTO(@NotBlank(message = "Title must not be empty") String title, + @NotBlank(message = "Artist must not be empty") String artist, + @Positive(message = "BPM must be positive if provided") Double bpm, + String key, + @NotBlank(message = "Duration must not be empty") @Pattern(regexp = "^\\d+:[0-5]\\d$", message = "Duration must be in mm:ss format") String duration) { +} diff --git a/src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java b/src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java index c069a36..73313a8 100644 --- a/src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java @@ -1,29 +1,72 @@ -package com.jfontdev.trackstack.exception; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.util.HashMap; -import java.util.Map; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(NotFoundException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public Map handleNotFound(NotFoundException ex) { - return Map.of("error", ex.getMessage()); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public Map handleValidation(MethodArgumentNotValidException ex) { - Map errors = new HashMap<>(); - ex.getBindingResult().getFieldErrors().forEach(field -> errors.put(field.getField(), field.getDefaultMessage())); - - return Map.of("errors", errors); - } -} +package com.jfontdev.trackstack.exception; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +/** + * Global exception handler that maps application exceptions to HTTP responses. + *

+ * By centralizing exception handling here, controllers remain clean and focused + * on request routing. Each handler method maps a specific exception type to an + * appropriate HTTP status code and error response body. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * Handles {@link NotFoundException} thrown when a requested entity does not exist. + *

+ * Returns a 404 Not Found response with the exception message in the body. + * + * @param ex the not found exception + * @return a map containing the error message + */ + @ExceptionHandler(NotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public Map handleNotFound(NotFoundException ex) { + return Map.of("error", ex.getMessage()); + } + + /** + * Handles {@link MethodArgumentNotValidException} thrown when request body + * validation fails (e.g., {@code @NotBlank} constraints). + *

+ * Returns a 400 Bad Request response with a map of field names to error messages. + * + * @param ex the validation exception + * @return a map containing field-level error messages + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleValidation(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(field -> errors.putIfAbsent(field.getField(), field.getDefaultMessage())); + + return Map.of("errors", errors); + } + + /** + * Handles {@link DataIntegrityViolationException} thrown when a database + * constraint is violated (e.g., unique constraint on tag name, foreign key + * violations). + *

+ * Returns a 409 Conflict response with a generic error message. We intentionally + * do not expose the underlying database error details to the client for security + * reasons. + * + * @param ex the data integrity violation exception + * @return a map containing the error message + */ + @ExceptionHandler(DataIntegrityViolationException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public Map handleDataIntegrityViolation(DataIntegrityViolationException ex) { + return Map.of("error", "Operation violates a data integrity constraint."); + } +} diff --git a/src/main/java/com/jfontdev/trackstack/model/Playlist.java b/src/main/java/com/jfontdev/trackstack/model/Playlist.java index a228583..5425a94 100644 --- a/src/main/java/com/jfontdev/trackstack/model/Playlist.java +++ b/src/main/java/com/jfontdev/trackstack/model/Playlist.java @@ -1,50 +1,128 @@ -package com.jfontdev.trackstack.model; - -import jakarta.persistence.*; - -import java.util.Set; - -@Entity -@Table(name = "playlists") -public class Playlist { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String name; - private String description; - - @ManyToMany - @JoinTable( - name = "playlist_tracks", - joinColumns = @JoinColumn(name = "playlist_id"), - inverseJoinColumns = @JoinColumn(name = "track_id") - ) - private Set tracks; - - public Playlist(String name, String description) { - this.name = name; - this.description = description; - } - - public Playlist() { - - } - - public static Playlist create(String name, String description) { - return new Playlist(name, description); - } - - public Long getId() { - return id; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - -} +package com.jfontdev.trackstack.model; + +import jakarta.persistence.*; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * JPA entity representing a playlist of tracks. + *

+ * Playlists are named collections of {@link Track}s with an optional description. + * They participate in a many-to-many relationship with Track, where Playlist + * is the owning side (manages the {@code playlist_tracks} join table). + *

+ * This entity follows the static factory method pattern for creation + * and provides an {@code update} method for encapsulated mutation. + */ +@Entity +@Table(name = "playlists") +public class Playlist { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + private String description; + + @ManyToMany + @JoinTable( + name = "playlist_tracks", + joinColumns = @JoinColumn(name = "playlist_id"), + inverseJoinColumns = @JoinColumn(name = "track_id") + ) + private Set tracks = new HashSet<>(); + + /** + * Parameterized constructor used by the static factory method. + * + * @param name the playlist name + * @param description an optional description of the playlist + */ + public Playlist(String name, String description) { + this.name = name; + this.description = description; + } + + /** + * Default no-args constructor required by JPA. + */ + public Playlist() { + + } + + /** + * Static factory method for creating a new Playlist instance. + * + * @param name the playlist name + * @param description an optional description + * @return a new Playlist instance with the provided field values + */ + public static Playlist create(String name, String description) { + return new Playlist(name, description); + } + + /** + * Updates all mutable fields of this playlist. + *

+ * Encapsulates mutation in a single operation. The service layer calls this + * for both full (PUT) and partial (PATCH) updates -- for PATCH, the service + * merges non-null fields before calling this method. + * + * @param name the new playlist name + * @param description the new playlist description + */ + public void update(String name, String description) { + this.name = name; + this.description = description; + } + + /** + * Adds a track to this playlist. + *

+ * This manages the owning side of the Playlist-Track many-to-many relationship. + * The join table {@code playlist_tracks} is updated when this playlist is persisted. + * + * @param track the track to add to this playlist + */ + public void addTrack(Track track) { + this.tracks.add(track); + } + + /** + * Removes a track from this playlist. + *

+ * This manages the owning side of the Playlist-Track many-to-many relationship. + * The corresponding join table row is removed when this playlist is persisted. + * + * @param track the track to remove from this playlist + */ + public void removeTrack(Track track) { + this.tracks.remove(track); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + /** + * Returns an unmodifiable view of the tracks in this playlist. + *

+ * To modify the tracks, use {@link #addTrack(Track)} or {@link #removeTrack(Track)} + * to maintain domain encapsulation. + * + * @return an unmodifiable set of tracks belonging to this playlist + */ + public Set getTracks() { + return Collections.unmodifiableSet(tracks); + } +} diff --git a/src/main/java/com/jfontdev/trackstack/model/Tag.java b/src/main/java/com/jfontdev/trackstack/model/Tag.java index 67ada79..77bfc27 100644 --- a/src/main/java/com/jfontdev/trackstack/model/Tag.java +++ b/src/main/java/com/jfontdev/trackstack/model/Tag.java @@ -1,39 +1,77 @@ -package com.jfontdev.trackstack.model; - -import jakarta.persistence.*; - -import java.util.Set; - -@Entity -@Table(name = "tags") -public class Tag { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String name; - - @ManyToMany(mappedBy = "tags") - private Set tracks; - - public Tag(String name) { - this.name = name; - } - - public Tag() { - - } - - public static Tag create(String name) { - return new Tag(name); - } - - public Long getId() { - return id; - } - - public String getName() { - return name; - } -} +package com.jfontdev.trackstack.model; + +import jakarta.persistence.*; + +import java.util.HashSet; +import java.util.Set; + +/** + * JPA entity representing a tag that can be applied to tracks. + *

+ * Tags provide a flexible categorization mechanism for tracks (e.g., "chill", + * "upbeat", "workout"). Each tag has a unique name enforced at the database level. + * Tags participate in a many-to-many relationship with {@link Track}, where + * Track is the owning side. + *

+ * This entity follows the static factory method pattern for creation + * and provides an {@code update} method for encapsulated mutation. + */ +@Entity +@Table(name = "tags") +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @ManyToMany(mappedBy = "tags") + private Set tracks = new HashSet<>(); + + /** + * Parameterized constructor used by the static factory method. + * + * @param name the tag name + */ + public Tag(String name) { + this.name = name; + } + + /** + * Default no-args constructor required by JPA. + */ + public Tag() { + + } + + /** + * Static factory method for creating a new Tag instance. + * + * @param name the tag name + * @return a new Tag instance with the provided name + */ + public static Tag create(String name) { + return new Tag(name); + } + + /** + * Updates the mutable fields of this tag. + *

+ * Encapsulates mutation in a single operation. The service layer calls this + * for both full (PUT) and partial (PATCH) updates. + * + * @param name the new tag name + */ + public void update(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/jfontdev/trackstack/model/Track.java b/src/main/java/com/jfontdev/trackstack/model/Track.java index 0676fc5..fb873dd 100644 --- a/src/main/java/com/jfontdev/trackstack/model/Track.java +++ b/src/main/java/com/jfontdev/trackstack/model/Track.java @@ -1,76 +1,174 @@ -package com.jfontdev.trackstack.model; - -import jakarta.persistence.*; - -import java.util.Set; - -@Entity -@Table(name = "tracks") -public class Track { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String title; - private String artist; - private String album; - private Double bpm; - private String key; // musical key - private String duration; - - @ManyToMany - @JoinTable( - name = "track_tags", - joinColumns = @JoinColumn(name = "track_id"), - inverseJoinColumns = @JoinColumn(name = "tag_id") - ) - private Set tags; - - @ManyToMany(mappedBy = "tracks") - private Set playlists; - - public Track(String title, String artist, Double bpm, String key, String duration) { - this.title = title; - this.artist = artist; - this.bpm = bpm; - this.key = key; - this.duration = duration; - } - - public Track() { - - } - - public static Track create(String title, String artist, Double bpm, String key, String duration) { - return new Track(title, artist, bpm, key, duration); - } - - public Long getId() { - return id; - } - - public String getTitle() { - return title; - } - - public String getArtist() { - return artist; - } - - public String getAlbum() { - return album; - } - - public Double getBpm() { - return bpm; - } - - public String getKey() { - return key; - } - - public String getDuration() { - return duration; - } -} +package com.jfontdev.trackstack.model; + +import jakarta.persistence.*; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * JPA entity representing a music track. + *

+ * Tracks are the core domain object in TrackStack. Each track has metadata + * such as title, artist, BPM, musical key, and duration. Tracks can be + * associated with multiple {@link Tag}s and belong to multiple {@link Playlist}s + * through many-to-many relationships. + *

+ * This entity follows the static factory method pattern for creation + * and provides an {@code update} method for encapsulated mutation, + * keeping the entity in control of its own state transitions. + */ +@Entity +@Table(name = "tracks") +public class Track { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + private String artist; + private String album; + private Double bpm; + private String key; // musical key + private String duration; + + @ManyToMany + @JoinTable( + name = "track_tags", + joinColumns = @JoinColumn(name = "track_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + private Set tags = new HashSet<>(); + + @ManyToMany(mappedBy = "tracks") + private Set playlists = new HashSet<>(); + + /** + * Parameterized constructor used by the static factory method. + * + * @param title the track title + * @param artist the track artist + * @param bpm the beats per minute + * @param key the musical key (e.g., "A minor") + * @param duration the track duration (e.g., "3:45") + */ + public Track(String title, String artist, Double bpm, String key, String duration) { + this.title = title; + this.artist = artist; + this.bpm = bpm; + this.key = key; + this.duration = duration; + } + + /** + * Default no-args constructor required by JPA. + */ + public Track() { + + } + + /** + * Static factory method for creating a new Track instance. + *

+ * Controllers and services should use this method instead of calling + * the constructor directly, keeping entity creation centralized. + * + * @param title the track title + * @param artist the track artist + * @param bpm the beats per minute + * @param key the musical key + * @param duration the track duration + * @return a new Track instance with the provided field values + */ + public static Track create(String title, String artist, Double bpm, String key, String duration) { + return new Track(title, artist, bpm, key, duration); + } + + /** + * Updates all mutable fields of this track. + *

+ * This method encapsulates mutation in a single operation, mirroring + * the factory method pattern used for creation. The service layer calls + * this method for both full (PUT) and partial (PATCH) updates -- for PATCH, + * the service merges non-null fields from the request with existing values + * before calling this method. + * + * @param title the new track title + * @param artist the new track artist + * @param bpm the new beats per minute + * @param key the new musical key + * @param duration the new track duration + */ + public void update(String title, String artist, Double bpm, String key, String duration) { + this.title = title; + this.artist = artist; + this.bpm = bpm; + this.key = key; + this.duration = duration; + } + + /** + * Adds a tag to this track's tag set. + *

+ * This manages the owning side of the Track-Tag many-to-many relationship. + * The join table {@code track_tags} is updated when this track is persisted. + * + * @param tag the tag to associate with this track + */ + public void addTag(Tag tag) { + this.tags.add(tag); + } + + /** + * Removes a tag from this track's tag set. + *

+ * This manages the owning side of the Track-Tag many-to-many relationship. + * The corresponding join table row is removed when this track is persisted. + * + * @param tag the tag to disassociate from this track + */ + public void removeTag(Tag tag) { + this.tags.remove(tag); + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getArtist() { + return artist; + } + + public String getAlbum() { + return album; + } + + public Double getBpm() { + return bpm; + } + + public String getKey() { + return key; + } + + public String getDuration() { + return duration; + } + + /** + * Returns an unmodifiable view of the tags associated with this track. + *

+ * To modify the tags, use {@link #addTag(Tag)} or {@link #removeTag(Tag)} + * to maintain domain encapsulation. + * + * @return an unmodifiable set of tags associated with this track + */ + public Set getTags() { + return Collections.unmodifiableSet(tags); + } +} diff --git a/src/main/java/com/jfontdev/trackstack/service/PlaylistService.java b/src/main/java/com/jfontdev/trackstack/service/PlaylistService.java index bc8a38a..f19db3a 100644 --- a/src/main/java/com/jfontdev/trackstack/service/PlaylistService.java +++ b/src/main/java/com/jfontdev/trackstack/service/PlaylistService.java @@ -1,43 +1,114 @@ -package com.jfontdev.trackstack.service; - -import com.jfontdev.trackstack.dto.playlist.PlaylistRequestDTO; -import com.jfontdev.trackstack.dto.playlist.PlaylistResponseDTO; - -import java.util.List; - -/** - * Service interface for managing {@link com.jfontdev.trackstack.model.Playlist} - * entities. - *

- * This interface defines the contract for playlist-related business operations. - * By using an interface, we decouple the controller from the specific - * implementation, - * making the code easier to test and maintain. - */ -public interface PlaylistService { - - /** - * Creates a new playlist based on the provided request data. - * - * @param dto the data transfer object containing the playlist details - * @return a response DTO containing the newly created playlist's details - */ - PlaylistResponseDTO createPlaylist(PlaylistRequestDTO dto); - - /** - * Retrieves a playlist by its unique identifier. - * - * @param id the unique identifier of the playlist - * @return a response DTO containing the playlist's details - * @throws com.jfontdev.trackstack.exception.NotFoundException if the playlist - * is not found - */ - PlaylistResponseDTO getPlaylistById(Long id); - - /** - * Retrieves all playlists in the system. - * - * @return a list of response DTOs representing all playlists - */ - List getAllPlaylists(); -} \ No newline at end of file +package com.jfontdev.trackstack.service; + +import com.jfontdev.trackstack.dto.playlist.PlaylistPatchRequestDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistRequestDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistResponseDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistUpdateRequestDTO; + +import java.util.List; + +/** + * Service interface for managing {@link com.jfontdev.trackstack.model.Playlist} + * entities. + *

+ * This interface defines the contract for playlist-related business operations, + * including full CRUD, partial updates, and track relationship management. + * By using an interface, we decouple the controller from the specific + * implementation, making the code easier to test and maintain. + */ +public interface PlaylistService { + + /** + * Creates a new playlist based on the provided request data. + * + * @param dto the data transfer object containing the playlist details + * @return a response DTO containing the newly created playlist's details + */ + PlaylistResponseDTO createPlaylist(PlaylistRequestDTO dto); + + /** + * Retrieves a playlist by its unique identifier. + * + * @param id the unique identifier of the playlist + * @return a response DTO containing the playlist's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the playlist + * is not found + */ + PlaylistResponseDTO getPlaylistById(Long id); + + /** + * Retrieves all playlists in the system. + * + * @return a list of response DTOs representing all playlists + */ + List getAllPlaylists(); + + /** + * Fully updates an existing playlist (PUT semantics). + *

+ * All fields are replaced with the values from the request DTO. + * The description field is nullable, so omitting it clears the description. + * + * @param id the unique identifier of the playlist to update + * @param dto the data transfer object containing the new playlist details + * @return a response DTO containing the updated playlist's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the playlist + * is not found + */ + PlaylistResponseDTO updatePlaylist(Long id, PlaylistUpdateRequestDTO dto); + + /** + * Partially updates an existing playlist (PATCH semantics). + *

+ * Only non-null fields from the request DTO are applied. Fields that are + * null in the DTO retain their current values. + * + * @param id the unique identifier of the playlist to patch + * @param dto the data transfer object containing the fields to update + * @return a response DTO containing the updated playlist's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the playlist + * is not found + */ + PlaylistResponseDTO patchPlaylist(Long id, PlaylistPatchRequestDTO dto); + + /** + * Deletes a playlist by its unique identifier. + *

+ * The playlist is removed from the database along with all its join table + * associations (track relationships) thanks to ON DELETE CASCADE on the + * foreign keys. The tracks themselves are not deleted. + * + * @param id the unique identifier of the playlist to delete + * @throws com.jfontdev.trackstack.exception.NotFoundException if the playlist + * is not found + */ + void deletePlaylist(Long id); + + /** + * Adds a track to a playlist. + *

+ * If the track is already in the playlist, this operation is idempotent + * (no error is thrown, the association simply remains). + * + * @param playlistId the unique identifier of the playlist + * @param trackId the unique identifier of the track to add + * @return a response DTO containing the updated playlist's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the playlist + * or track is not found + */ + PlaylistResponseDTO addTrackToPlaylist(Long playlistId, Long trackId); + + /** + * Removes a track from a playlist. + *

+ * If the track is not currently in the playlist, this operation is + * idempotent (no error is thrown). + * + * @param playlistId the unique identifier of the playlist + * @param trackId the unique identifier of the track to remove + * @return a response DTO containing the updated playlist's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the playlist + * or track is not found + */ + PlaylistResponseDTO removeTrackFromPlaylist(Long playlistId, Long trackId); +} diff --git a/src/main/java/com/jfontdev/trackstack/service/TagService.java b/src/main/java/com/jfontdev/trackstack/service/TagService.java index c64d95d..020b288 100644 --- a/src/main/java/com/jfontdev/trackstack/service/TagService.java +++ b/src/main/java/com/jfontdev/trackstack/service/TagService.java @@ -1,43 +1,86 @@ -package com.jfontdev.trackstack.service; - -import com.jfontdev.trackstack.dto.tag.TagRequestDTO; -import com.jfontdev.trackstack.dto.tag.TagResponseDTO; - -import java.util.List; - -/** - * Service interface for managing {@link com.jfontdev.trackstack.model.Tag} - * entities. - *

- * This interface defines the contract for tag-related business operations. - * By using an interface, we decouple the controller from the specific - * implementation, - * making the code easier to test and maintain. - */ -public interface TagService { - - /** - * Creates a new tag based on the provided request data. - * - * @param dto the data transfer object containing the tag details - * @return a response DTO containing the newly created tag's details - */ - TagResponseDTO createTag(TagRequestDTO dto); - - /** - * Retrieves a tag by its unique identifier. - * - * @param id the unique identifier of the tag - * @return a response DTO containing the tag's details - * @throws com.jfontdev.trackstack.exception.NotFoundException if the tag is not - * found - */ - TagResponseDTO getTagById(Long id); - - /** - * Retrieves all tags in the system. - * - * @return a list of response DTOs representing all tags - */ - List getAllTags(); -} \ No newline at end of file +package com.jfontdev.trackstack.service; + +import com.jfontdev.trackstack.dto.tag.TagPatchRequestDTO; +import com.jfontdev.trackstack.dto.tag.TagRequestDTO; +import com.jfontdev.trackstack.dto.tag.TagResponseDTO; +import com.jfontdev.trackstack.dto.tag.TagUpdateRequestDTO; + +import java.util.List; + +/** + * Service interface for managing {@link com.jfontdev.trackstack.model.Tag} + * entities. + *

+ * This interface defines the contract for tag-related business operations, + * including full CRUD and partial updates. By using an interface, we decouple + * the controller from the specific implementation, making the code easier + * to test and maintain. + */ +public interface TagService { + + /** + * Creates a new tag based on the provided request data. + * + * @param dto the data transfer object containing the tag details + * @return a response DTO containing the newly created tag's details + */ + TagResponseDTO createTag(TagRequestDTO dto); + + /** + * Retrieves a tag by its unique identifier. + * + * @param id the unique identifier of the tag + * @return a response DTO containing the tag's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the tag is not + * found + */ + TagResponseDTO getTagById(Long id); + + /** + * Retrieves all tags in the system. + * + * @return a list of response DTOs representing all tags + */ + List getAllTags(); + + /** + * Fully updates an existing tag (PUT semantics). + *

+ * The tag name is replaced with the value from the request DTO. + * The new name must remain unique (enforced at the database level). + * + * @param id the unique identifier of the tag to update + * @param dto the data transfer object containing the new tag details + * @return a response DTO containing the updated tag's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the tag is not + * found + */ + TagResponseDTO updateTag(Long id, TagUpdateRequestDTO dto); + + /** + * Partially updates an existing tag (PATCH semantics). + *

+ * Only non-null fields from the request DTO are applied. Fields that are + * null in the DTO retain their current values. + * + * @param id the unique identifier of the tag to patch + * @param dto the data transfer object containing the fields to update + * @return a response DTO containing the updated tag's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the tag is not + * found + */ + TagResponseDTO patchTag(Long id, TagPatchRequestDTO dto); + + /** + * Deletes a tag by its unique identifier. + *

+ * The tag is removed from the database along with all its join table + * associations (track relationships) thanks to ON DELETE CASCADE on + * the foreign keys. + * + * @param id the unique identifier of the tag to delete + * @throws com.jfontdev.trackstack.exception.NotFoundException if the tag is not + * found + */ + void deleteTag(Long id); +} diff --git a/src/main/java/com/jfontdev/trackstack/service/TrackService.java b/src/main/java/com/jfontdev/trackstack/service/TrackService.java index 32ca489..d83e703 100644 --- a/src/main/java/com/jfontdev/trackstack/service/TrackService.java +++ b/src/main/java/com/jfontdev/trackstack/service/TrackService.java @@ -1,43 +1,115 @@ -package com.jfontdev.trackstack.service; - -import com.jfontdev.trackstack.dto.track.TrackRequestDTO; -import com.jfontdev.trackstack.dto.track.TrackResponseDTO; - -import java.util.List; - -/** - * Service interface for managing {@link com.jfontdev.trackstack.model.Track} - * entities. - *

- * This interface defines the contract for track-related business operations. - * By using an interface, we decouple the controller from the specific - * implementation, - * making the code easier to test and maintain. - */ -public interface TrackService { - - /** - * Creates a new track based on the provided request data. - * - * @param dto the data transfer object containing the track details - * @return a response DTO containing the newly created track's details - */ - TrackResponseDTO createTrack(TrackRequestDTO dto); - - /** - * Retrieves a track by its unique identifier. - * - * @param id the unique identifier of the track - * @return a response DTO containing the track's details - * @throws com.jfontdev.trackstack.exception.NotFoundException if the track is - * not found - */ - TrackResponseDTO getTrackById(Long id); - - /** - * Retrieves all tracks in the system. - * - * @return a list of response DTOs representing all tracks - */ - List getAllTracks(); -} +package com.jfontdev.trackstack.service; + +import com.jfontdev.trackstack.dto.track.TrackPatchRequestDTO; +import com.jfontdev.trackstack.dto.track.TrackRequestDTO; +import com.jfontdev.trackstack.dto.track.TrackResponseDTO; +import com.jfontdev.trackstack.dto.track.TrackUpdateRequestDTO; + +import java.util.List; + +/** + * Service interface for managing {@link com.jfontdev.trackstack.model.Track} + * entities. + *

+ * This interface defines the contract for track-related business operations, + * including full CRUD, partial updates, and tag relationship management. + * By using an interface, we decouple the controller from the specific + * implementation, making the code easier to test and maintain. + */ +public interface TrackService { + + /** + * Creates a new track based on the provided request data. + * + * @param dto the data transfer object containing the track details + * @return a response DTO containing the newly created track's details + */ + TrackResponseDTO createTrack(TrackRequestDTO dto); + + /** + * Retrieves a track by its unique identifier. + * + * @param id the unique identifier of the track + * @return a response DTO containing the track's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the track is + * not found + */ + TrackResponseDTO getTrackById(Long id); + + /** + * Retrieves all tracks in the system. + * + * @return a list of response DTOs representing all tracks + */ + List getAllTracks(); + + /** + * Fully updates an existing track (PUT semantics). + *

+ * All fields are replaced with the values from the request DTO. + * Fields that are nullable on the entity (bpm, key) are set to null + * if not provided. + * + * @param id the unique identifier of the track to update + * @param dto the data transfer object containing the new track details + * @return a response DTO containing the updated track's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the track is + * not found + */ + TrackResponseDTO updateTrack(Long id, TrackUpdateRequestDTO dto); + + /** + * Partially updates an existing track (PATCH semantics). + *

+ * Only non-null fields from the request DTO are applied to the existing + * entity. Fields that are null in the DTO retain their current values. + * + * @param id the unique identifier of the track to patch + * @param dto the data transfer object containing the fields to update + * @return a response DTO containing the updated track's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the track is + * not found + */ + TrackResponseDTO patchTrack(Long id, TrackPatchRequestDTO dto); + + /** + * Deletes a track by its unique identifier. + *

+ * The track is removed from the database along with all its join table + * associations (tag relationships and playlist memberships) thanks to + * ON DELETE CASCADE on the foreign keys. + * + * @param id the unique identifier of the track to delete + * @throws com.jfontdev.trackstack.exception.NotFoundException if the track is + * not found + */ + void deleteTrack(Long id); + + /** + * Associates a tag with a track. + *

+ * If the tag is already associated with the track, this operation is + * idempotent (no error is thrown, the association simply remains). + * + * @param trackId the unique identifier of the track + * @param tagId the unique identifier of the tag to add + * @return a response DTO containing the updated track's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the track or + * tag is not found + */ + TrackResponseDTO addTagToTrack(Long trackId, Long tagId); + + /** + * Removes a tag association from a track. + *

+ * If the tag is not currently associated with the track, this operation is + * idempotent (no error is thrown). + * + * @param trackId the unique identifier of the track + * @param tagId the unique identifier of the tag to remove + * @return a response DTO containing the updated track's details + * @throws com.jfontdev.trackstack.exception.NotFoundException if the track or + * tag is not found + */ + TrackResponseDTO removeTagFromTrack(Long trackId, Long tagId); +} diff --git a/src/main/java/com/jfontdev/trackstack/service/impl/PlaylistServiceImpl.java b/src/main/java/com/jfontdev/trackstack/service/impl/PlaylistServiceImpl.java index 3a0664c..915f23f 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/PlaylistServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/PlaylistServiceImpl.java @@ -1,137 +1,297 @@ -package com.jfontdev.trackstack.service.impl; - -import com.jfontdev.trackstack.dto.playlist.PlaylistRequestDTO; -import com.jfontdev.trackstack.dto.playlist.PlaylistResponseDTO; -import com.jfontdev.trackstack.exception.NotFoundException; -import com.jfontdev.trackstack.model.Playlist; -import com.jfontdev.trackstack.repository.PlaylistRepository; -import com.jfontdev.trackstack.service.PlaylistService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -/** - * Implementation of the {@link PlaylistService} interface. - *

- * This service handles the business logic for managing {@link Playlist} - * entities. - * It acts as a bridge between the controller layer (which handles HTTP - * requests) - * and the repository layer (which handles database operations). - *

- * Caching Strategy: - * We use Spring's caching abstraction to improve read performance. - * - Read operations ({@code getPlaylistById}, {@code getAllPlaylists}) are - * cached under the "playlists" cache. - * - Write operations ({@code createPlaylist}) evict the entire "playlists" - * cache to ensure - * that subsequent reads (especially {@code getAllPlaylists}) do not return - * stale data. - */ -@Service -public class PlaylistServiceImpl implements PlaylistService { - - private static final Logger log = LoggerFactory.getLogger(PlaylistServiceImpl.class); - - private final PlaylistRepository playlistRepository; - - /** - * Constructs a new {@code PlaylistServiceImpl} with the required repository. - * We use constructor injection to ensure the repository is provided and - * immutable. - * - * @param playlistRepository the repository used for database operations on - * playlists - */ - public PlaylistServiceImpl(PlaylistRepository playlistRepository) { - this.playlistRepository = playlistRepository; - } - - /** - * Creates a new playlist in the system. - *

- * This method maps the incoming DTO to a domain entity, saves it to the - * database, - * and then maps the saved entity back to a response DTO. - *

- * Cache Eviction: We evict all entries in the "playlists" cache because - * adding - * a new playlist invalidates the result of {@code getAllPlaylists()}. - * - * @param dto the data transfer object containing the details of the playlist to - * create - * @return a response DTO containing the saved playlist's details, including its - * generated ID - */ - @Override - @CacheEvict(value = "playlists", allEntries = true) - public PlaylistResponseDTO createPlaylist(PlaylistRequestDTO dto) { - log.info("Evicting 'playlists' cache. Creating new playlist: {}", dto.name()); - Playlist playlist = Playlist.create(dto.name(), dto.description()); - Playlist saved = playlistRepository.saveAndFlush(playlist); - - return new PlaylistResponseDTO( - saved.getId(), - saved.getName(), - saved.getDescription()); - } - - /** - * Retrieves a playlist by its unique identifier. - *

- * Caching: The result of this method is cached. If the playlist is - * requested again - * with the same ID, the cached value is returned instead of querying the - * database. - * - * @param id the unique identifier of the playlist to retrieve - * @return a response DTO containing the playlist's details - * @throws NotFoundException if no playlist is found with the provided ID - */ - @Override - @Cacheable(value = "playlists", key = "#id") - public PlaylistResponseDTO getPlaylistById(Long id) { - log.info("Cache miss for 'playlists' with id: {}. Fetching from database.", id); - Optional playlist = playlistRepository.findById(id); - - if (playlist.isEmpty()) { - throw new NotFoundException("Playlist not found."); - } - - Playlist p = playlist.get(); - - return new PlaylistResponseDTO( - p.getId(), - p.getName(), - p.getDescription()); - } - - /** - * Retrieves all playlists currently stored in the system. - *

- * Caching: The entire list of playlists is cached. This is highly - * efficient for - * read-heavy workloads, but requires careful eviction (clearing the cache) - * whenever - * a playlist is added, updated, or deleted to prevent stale data. - * - * @return a list of response DTOs representing all playlists - */ - @Override - @Cacheable(value = "playlists") - public List getAllPlaylists() { - log.info("Cache miss for 'playlists' list. Fetching all playlists from database."); - return playlistRepository.findAll() - .stream() - .map(p -> new PlaylistResponseDTO( - p.getId(), - p.getName(), - p.getDescription())) - .toList(); - } -} +package com.jfontdev.trackstack.service.impl; + +import com.jfontdev.trackstack.dto.tag.TagResponseDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistPatchRequestDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistRequestDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistResponseDTO; +import com.jfontdev.trackstack.dto.playlist.PlaylistUpdateRequestDTO; +import com.jfontdev.trackstack.dto.track.TrackResponseDTO; +import com.jfontdev.trackstack.exception.NotFoundException; +import com.jfontdev.trackstack.model.Playlist; +import com.jfontdev.trackstack.model.Track; +import com.jfontdev.trackstack.repository.PlaylistRepository; +import com.jfontdev.trackstack.repository.TrackRepository; +import com.jfontdev.trackstack.service.PlaylistService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Implementation of the {@link PlaylistService} interface. + *

+ * This service handles the business logic for managing {@link Playlist} + * entities. + * It acts as a bridge between the controller layer (which handles HTTP + * requests) + * and the repository layer (which handles database operations). + *

+ * Caching Strategy: + * We use Spring's caching abstraction to improve read performance. + * - Read operations ({@code getPlaylistById}, {@code getAllPlaylists}) are + * cached under the "playlists" cache. + * - Write operations (create, update, patch, delete, and relationship changes) + * evict the entire "playlists" cache to ensure that subsequent reads do not + * return stale data. + *

+ * Transaction Strategy: + * All write operations are annotated with {@code @Transactional} to ensure + * proper rollback on failure. + */ +@Service +public class PlaylistServiceImpl implements PlaylistService { + + private static final Logger log = LoggerFactory.getLogger(PlaylistServiceImpl.class); + + private final PlaylistRepository playlistRepository; + private final TrackRepository trackRepository; + + /** + * Constructs a new {@code PlaylistServiceImpl} with the required repositories. + *

+ * We inject both {@link PlaylistRepository} and {@link TrackRepository} because + * this service manages the Playlist-Track relationship (the owning side). + * + * @param playlistRepository the repository used for database operations on playlists + * @param trackRepository the repository used to look up tracks for relationship management + */ + public PlaylistServiceImpl(PlaylistRepository playlistRepository, TrackRepository trackRepository) { + this.playlistRepository = playlistRepository; + this.trackRepository = trackRepository; + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: We evict all entries in the "playlists" cache because + * adding a new playlist invalidates the result of {@code getAllPlaylists()}. + */ + @Override + @CacheEvict(value = "playlists", allEntries = true) + @Transactional + public PlaylistResponseDTO createPlaylist(PlaylistRequestDTO dto) { + log.info("Evicting 'playlists' cache. Creating new playlist: {}", dto.name()); + Playlist playlist = Playlist.create(dto.name(), dto.description()); + Playlist saved = playlistRepository.saveAndFlush(playlist); + + return mapToResponseDTO(saved); + } + + /** + * {@inheritDoc} + *

+ * Caching: The result of this method is cached. If the playlist is + * requested again with the same ID, the cached value is returned instead + * of querying the database. + */ + @Override + @Cacheable(value = "playlists", key = "#id") + @Transactional(readOnly = true) + public PlaylistResponseDTO getPlaylistById(Long id) { + log.info("Cache miss for 'playlists' with id: {}. Fetching from database.", id); + Playlist playlist = findPlaylistOrThrow(id); + + return mapToResponseDTO(playlist); + } + + /** + * {@inheritDoc} + *

+ * Caching: The entire list of playlists is cached. This is highly + * efficient for read-heavy workloads, but requires careful eviction whenever + * a playlist is added, updated, or deleted to prevent stale data. + */ + @Override + @Cacheable(value = "playlists") + @Transactional(readOnly = true) + public List getAllPlaylists() { + log.info("Cache miss for 'playlists' list. Fetching all playlists from database."); + return playlistRepository.findAll() + .stream() + .map(this::mapToResponseDTO) + .toList(); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "playlists" cache because + * updating a playlist invalidates both the individual entry and the list. + */ + @Override + @CacheEvict(value = "playlists", allEntries = true) + @Transactional + public PlaylistResponseDTO updatePlaylist(Long id, PlaylistUpdateRequestDTO dto) { + log.info("Evicting 'playlists' cache. Updating playlist with id: {}", id); + Playlist playlist = findPlaylistOrThrow(id); + + playlist.update(dto.name(), dto.description()); + Playlist saved = playlistRepository.saveAndFlush(playlist); + + return mapToResponseDTO(saved); + } + + /** + * {@inheritDoc} + *

+ * Merges non-null fields from the patch DTO with the existing entity's values, + * then delegates to the entity's {@code update} method. + *

+ * Cache Eviction: Evicts all entries in the "playlists" cache. + */ + @Override + @CacheEvict(value = "playlists", allEntries = true) + @Transactional + public PlaylistResponseDTO patchPlaylist(Long id, PlaylistPatchRequestDTO dto) { + log.info("Evicting 'playlists' cache. Patching playlist with id: {}", id); + Playlist playlist = findPlaylistOrThrow(id); + + String name = dto.name() != null ? dto.name() : playlist.getName(); + String description = dto.description() != null ? dto.description() : playlist.getDescription(); + + playlist.update(name, description); + Playlist saved = playlistRepository.saveAndFlush(playlist); + + return mapToResponseDTO(saved); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "playlists" cache because + * deleting a playlist invalidates the list cache. + */ + @Override + @CacheEvict(value = "playlists", allEntries = true) + @Transactional + public void deletePlaylist(Long id) { + log.info("Evicting 'playlists' cache. Deleting playlist with id: {}", id); + Playlist playlist = findPlaylistOrThrow(id); + + playlistRepository.delete(playlist); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "playlists" cache because + * changing a playlist's tracks invalidates cached playlist representations. + */ + @Override + @CacheEvict(value = "playlists", allEntries = true) + @Transactional + public PlaylistResponseDTO addTrackToPlaylist(Long playlistId, Long trackId) { + log.info("Evicting 'playlists' cache. Adding track {} to playlist {}", trackId, playlistId); + Playlist playlist = findPlaylistOrThrow(playlistId); + Track track = findTrackOrThrow(trackId); + + playlist.addTrack(track); + Playlist saved = playlistRepository.saveAndFlush(playlist); + + return mapToResponseDTO(saved); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "playlists" cache because + * changing a playlist's tracks invalidates cached playlist representations. + */ + @Override + @CacheEvict(value = "playlists", allEntries = true) + @Transactional + public PlaylistResponseDTO removeTrackFromPlaylist(Long playlistId, Long trackId) { + log.info("Evicting 'playlists' cache. Removing track {} from playlist {}", trackId, playlistId); + Playlist playlist = findPlaylistOrThrow(playlistId); + Track track = findTrackOrThrow(trackId); + + playlist.removeTrack(track); + Playlist saved = playlistRepository.saveAndFlush(playlist); + + return mapToResponseDTO(saved); + } + + /** + * Finds a playlist by ID or throws a {@link NotFoundException}. + *

+ * This is an internal helper that centralizes the Optional handling + * pattern used across all methods that require an existing playlist. + * + * @param id the playlist ID to look up + * @return the found Playlist entity + * @throws NotFoundException if no playlist exists with the given ID + */ + private Playlist findPlaylistOrThrow(Long id) { + Optional playlist = playlistRepository.findById(id); + + if (playlist.isEmpty()) { + throw new NotFoundException("Playlist not found."); + } + + return playlist.get(); + } + + /** + * Finds a track by ID or throws a {@link NotFoundException}. + *

+ * Used by relationship management methods that need to look up tracks. + * + * @param id the track ID to look up + * @return the found Track entity + * @throws NotFoundException if no track exists with the given ID + */ + private Track findTrackOrThrow(Long id) { + Optional track = trackRepository.findById(id); + + if (track.isEmpty()) { + throw new NotFoundException("Track not found"); + } + + return track.get(); + } + + /** + * Maps a {@link Playlist} entity to a {@link PlaylistResponseDTO}. + *

+ * This centralizes the entity-to-DTO mapping logic to avoid repetition + * across service methods. The mapping includes the playlist's associated + * tracks, and each track includes its own tags. Tracks are sorted by title + * and tags are sorted by name to guarantee deterministic API responses + * despite the underlying sets' undefined iteration order. + * + * @param playlist the entity to map + * @return the corresponding response DTO + */ + private PlaylistResponseDTO mapToResponseDTO(Playlist playlist) { + List trackDTOs = playlist.getTracks().stream() + .map(track -> { + List tagDTOs = track.getTags().stream() + .map(tag -> new TagResponseDTO(tag.getId(), tag.getName())) + .sorted(Comparator.comparing(TagResponseDTO::name)) + .toList(); + + return new TrackResponseDTO( + track.getId(), + track.getTitle(), + track.getArtist(), + track.getBpm(), + track.getKey(), + track.getDuration(), + tagDTOs); + }) + .sorted(Comparator.comparing(TrackResponseDTO::title)) + .toList(); + + return new PlaylistResponseDTO( + playlist.getId(), + playlist.getName(), + playlist.getDescription(), + trackDTOs); + } +} diff --git a/src/main/java/com/jfontdev/trackstack/service/impl/TagServiceImpl.java b/src/main/java/com/jfontdev/trackstack/service/impl/TagServiceImpl.java index 4c7cdfb..5075906 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/TagServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/TagServiceImpl.java @@ -1,132 +1,218 @@ -package com.jfontdev.trackstack.service.impl; - -import com.jfontdev.trackstack.dto.tag.TagRequestDTO; -import com.jfontdev.trackstack.dto.tag.TagResponseDTO; -import com.jfontdev.trackstack.exception.NotFoundException; -import com.jfontdev.trackstack.model.Tag; -import com.jfontdev.trackstack.repository.TagRepository; -import com.jfontdev.trackstack.service.TagService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -/** - * Implementation of the {@link TagService} interface. - *

- * This service handles the business logic for managing {@link Tag} entities. - * It acts as a bridge between the controller layer (which handles HTTP - * requests) - * and the repository layer (which handles database operations). - *

- * Caching Strategy: - * We use Spring's caching abstraction to improve read performance. - * - Read operations ({@code getTagById}, {@code getAllTags}) are cached under - * the "tags" cache. - * - Write operations ({@code createTag}) evict the entire "tags" cache to - * ensure - * that subsequent reads (especially {@code getAllTags}) do not return stale - * data. - */ -@Service -public class TagServiceImpl implements TagService { - - private static final Logger log = LoggerFactory.getLogger(TagServiceImpl.class); - - private final TagRepository tagRepository; - - /** - * Constructs a new {@code TagServiceImpl} with the required repository. - * We use constructor injection to ensure the repository is provided and - * immutable. - * - * @param tagRepository the repository used for database operations on tags - */ - public TagServiceImpl(TagRepository tagRepository) { - this.tagRepository = tagRepository; - } - - /** - * Creates a new tag in the system. - *

- * This method maps the incoming DTO to a domain entity, saves it to the - * database, - * and then maps the saved entity back to a response DTO. - *

- * Cache Eviction: We evict all entries in the "tags" cache because - * adding - * a new tag invalidates the result of {@code getAllTags()}. - * - * @param dto the data transfer object containing the details of the tag to - * create - * @return a response DTO containing the saved tag's details, including its - * generated ID - */ - @Override - @CacheEvict(value = "tags", allEntries = true) - public TagResponseDTO createTag(TagRequestDTO dto) { - log.info("Evicting 'tags' cache. Creating new tag: {}", dto.name()); - Tag tag = Tag.create(dto.name()); - Tag saved = tagRepository.saveAndFlush(tag); - - return new TagResponseDTO( - saved.getId(), - saved.getName()); - } - - /** - * Retrieves a tag by its unique identifier. - *

- * Caching: The result of this method is cached. If the tag is requested - * again - * with the same ID, the cached value is returned instead of querying the - * database. - * - * @param id the unique identifier of the tag to retrieve - * @return a response DTO containing the tag's details - * @throws NotFoundException if no tag is found with the provided ID - */ - @Override - @Cacheable(value = "tags", key = "#id") - public TagResponseDTO getTagById(Long id) { - log.info("Cache miss for 'tags' with id: {}. Fetching from database.", id); - Optional tag = tagRepository.findById(id); - - if (tag.isEmpty()) { - throw new NotFoundException("Tag not found."); - } - - Tag t = tag.get(); - - return new TagResponseDTO( - t.getId(), - t.getName()); - } - - /** - * Retrieves all tags currently stored in the system. - *

- * Caching: The entire list of tags is cached. This is highly efficient - * for - * read-heavy workloads, but requires careful eviction (clearing the cache) - * whenever - * a tag is added, updated, or deleted to prevent stale data. - * - * @return a list of response DTOs representing all tags - */ - @Override - @Cacheable(value = "tags") - public List getAllTags() { - log.info("Cache miss for 'tags' list. Fetching all tags from database."); - return tagRepository.findAll() - .stream() - .map(t -> new TagResponseDTO( - t.getId(), - t.getName())) - .toList(); - } -} +package com.jfontdev.trackstack.service.impl; + +import com.jfontdev.trackstack.dto.tag.TagPatchRequestDTO; +import com.jfontdev.trackstack.dto.tag.TagRequestDTO; +import com.jfontdev.trackstack.dto.tag.TagResponseDTO; +import com.jfontdev.trackstack.dto.tag.TagUpdateRequestDTO; +import com.jfontdev.trackstack.exception.NotFoundException; +import com.jfontdev.trackstack.model.Tag; +import com.jfontdev.trackstack.repository.TagRepository; +import com.jfontdev.trackstack.service.TagService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Implementation of the {@link TagService} interface. + *

+ * This service handles the business logic for managing {@link Tag} entities. + * It acts as a bridge between the controller layer (which handles HTTP + * requests) + * and the repository layer (which handles database operations). + *

+ * Caching Strategy: + * We use Spring's caching abstraction to improve read performance. + * - Read operations ({@code getTagById}, {@code getAllTags}) are cached under + * the "tags" cache. + * - Write operations (create, update, patch, delete) evict the entire "tags" + * cache to ensure that subsequent reads do not return stale data. + *

+ * Transaction Strategy: + * All write operations are annotated with {@code @Transactional} to ensure + * proper rollback on failure. + */ +@Service +public class TagServiceImpl implements TagService { + + private static final Logger log = LoggerFactory.getLogger(TagServiceImpl.class); + + private final TagRepository tagRepository; + + /** + * Constructs a new {@code TagServiceImpl} with the required repository. + * We use constructor injection to ensure the repository is provided and + * immutable. + * + * @param tagRepository the repository used for database operations on tags + */ + public TagServiceImpl(TagRepository tagRepository) { + this.tagRepository = tagRepository; + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: We evict all entries in the "tags" cache because + * adding a new tag invalidates the result of {@code getAllTags()}. + */ + @Override + @CacheEvict(value = "tags", allEntries = true) + @Transactional + public TagResponseDTO createTag(TagRequestDTO dto) { + log.info("Evicting 'tags' cache. Creating new tag: {}", dto.name()); + Tag tag = Tag.create(dto.name()); + Tag saved = tagRepository.saveAndFlush(tag); + + return mapToResponseDTO(saved); + } + + /** + * {@inheritDoc} + *

+ * Caching: The result of this method is cached. If the tag is requested + * again with the same ID, the cached value is returned instead of querying the + * database. + */ + @Override + @Cacheable(value = "tags", key = "#id") + @Transactional(readOnly = true) + public TagResponseDTO getTagById(Long id) { + log.info("Cache miss for 'tags' with id: {}. Fetching from database.", id); + Tag tag = findTagOrThrow(id); + + return mapToResponseDTO(tag); + } + + /** + * {@inheritDoc} + *

+ * Caching: The entire list of tags is cached. This is highly efficient + * for read-heavy workloads, but requires careful eviction whenever a tag + * is added, updated, or deleted to prevent stale data. + */ + @Override + @Cacheable(value = "tags") + @Transactional(readOnly = true) + public List getAllTags() { + log.info("Cache miss for 'tags' list. Fetching all tags from database."); + return tagRepository.findAll() + .stream() + .map(this::mapToResponseDTO) + .toList(); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "tags" cache because + * updating a tag invalidates both the individual entry and the list. It also + * evicts "tracks" and "playlists" caches because they both include tag names. + */ + @Override + @Caching(evict = { + @CacheEvict(value = "tags", allEntries = true), + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) + @Transactional + public TagResponseDTO updateTag(Long id, TagUpdateRequestDTO dto) { + log.info("Evicting 'tags', 'tracks', and 'playlists' caches. Updating tag with id: {}", id); + Tag tag = findTagOrThrow(id); + + tag.update(dto.name()); + Tag saved = tagRepository.saveAndFlush(tag); + + return mapToResponseDTO(saved); + } + + /** + * {@inheritDoc} + *

+ * Merges non-null fields from the patch DTO with the existing entity's values, + * then delegates to the entity's {@code update} method. + *

+ * Cache Eviction: Evicts all entries in the "tags" cache. It also + * evicts "tracks" and "playlists" caches because they both include tag names. + */ + @Override + @Caching(evict = { + @CacheEvict(value = "tags", allEntries = true), + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) + @Transactional + public TagResponseDTO patchTag(Long id, TagPatchRequestDTO dto) { + log.info("Evicting 'tags', 'tracks', and 'playlists' caches. Patching tag with id: {}", id); + Tag tag = findTagOrThrow(id); + + String name = dto.name() != null ? dto.name() : tag.getName(); + + tag.update(name); + Tag saved = tagRepository.saveAndFlush(tag); + + return mapToResponseDTO(saved); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "tags" cache because + * deleting a tag invalidates the list cache. It also evicts "tracks" + * and "playlists" caches because they both include tag names. + */ + @Override + @Caching(evict = { + @CacheEvict(value = "tags", allEntries = true), + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) + @Transactional + public void deleteTag(Long id) { + log.info("Evicting 'tags', 'tracks', and 'playlists' caches. Deleting tag with id: {}", id); + Tag tag = findTagOrThrow(id); + + tagRepository.delete(tag); + } + + /** + * Finds a tag by ID or throws a {@link NotFoundException}. + *

+ * This is an internal helper that centralizes the Optional handling + * pattern used across all methods that require an existing tag. + * + * @param id the tag ID to look up + * @return the found Tag entity + * @throws NotFoundException if no tag exists with the given ID + */ + private Tag findTagOrThrow(Long id) { + Optional tag = tagRepository.findById(id); + + if (tag.isEmpty()) { + throw new NotFoundException("Tag not found."); + } + + return tag.get(); + } + + /** + * Maps a {@link Tag} entity to a {@link TagResponseDTO}. + *

+ * This centralizes the entity-to-DTO mapping logic to avoid repetition + * across service methods. + * + * @param tag the entity to map + * @return the corresponding response DTO + */ + private TagResponseDTO mapToResponseDTO(Tag tag) { + return new TagResponseDTO( + tag.getId(), + tag.getName()); + } +} diff --git a/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java b/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java index e61662f..e03a82e 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java @@ -1,148 +1,321 @@ -package com.jfontdev.trackstack.service.impl; - -import com.jfontdev.trackstack.dto.track.TrackRequestDTO; -import com.jfontdev.trackstack.dto.track.TrackResponseDTO; -import com.jfontdev.trackstack.exception.NotFoundException; -import com.jfontdev.trackstack.model.Track; -import com.jfontdev.trackstack.repository.TrackRepository; -import com.jfontdev.trackstack.service.TrackService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -/** - * Implementation of the {@link TrackService} interface. - *

- * This service handles the business logic for managing {@link Track} entities. - * It acts as a bridge between the controller layer (which handles HTTP - * requests) - * and the repository layer (which handles database operations). - *

- * Caching Strategy: - * We use Spring's caching abstraction to improve read performance. - * - Read operations ({@code getTrackById}, {@code getAllTracks}) are cached - * under the "tracks" cache. - * - Write operations ({@code createTrack}) evict the entire "tracks" cache to - * ensure - * that subsequent reads (especially {@code getAllTracks}) do not return stale - * data. - */ -@Service -public class TrackServiceImpl implements TrackService { - - private static final Logger log = LoggerFactory.getLogger(TrackServiceImpl.class); - - private final TrackRepository trackRepository; - - /** - * Constructs a new {@code TrackServiceImpl} with the required repository. - * We use constructor injection to ensure the repository is provided and - * immutable. - * - * @param trackRepository the repository used for database operations on tracks - */ - public TrackServiceImpl(TrackRepository trackRepository) { - this.trackRepository = trackRepository; - } - - /** - * Creates a new track in the system. - *

- * This method maps the incoming DTO to a domain entity, saves it to the - * database, - * and then maps the saved entity back to a response DTO. - *

- * Cache Eviction: We evict all entries in the "tracks" cache because - * adding - * a new track invalidates the result of {@code getAllTracks()}. - * - * @param dto the data transfer object containing the details of the track to - * create - * @return a response DTO containing the saved track's details, including its - * generated ID - */ - - @Override - @CacheEvict(value = "tracks", allEntries = true) - public TrackResponseDTO createTrack(TrackRequestDTO dto) { - log.info("Evicting 'tracks' cache. Creating new track: {}", dto.title()); - Track track = Track.create( - dto.title(), - dto.artist(), - dto.bpm(), - dto.key(), - dto.duration()); - - Track savedTrack = trackRepository.saveAndFlush(track); - - return new TrackResponseDTO( - savedTrack.getId(), - savedTrack.getTitle(), - savedTrack.getArtist(), - savedTrack.getBpm(), - savedTrack.getKey(), - savedTrack.getDuration()); - } - - /** - * Retrieves a track by its unique identifier. - *

- * Caching: The result of this method is cached. If the track is - * requested again - * with the same ID, the cached value is returned instead of querying the - * database. - * - * @param id the unique identifier of the track to retrieve - * @return a response DTO containing the track's details - * @throws NotFoundException if no track is found with the provided ID - */ - @Override - @Cacheable(value = "tracks", key = "#id") - public TrackResponseDTO getTrackById(Long id) { - log.info("Cache miss for 'tracks' with id: {}. Fetching from database.", id); - Optional track = trackRepository.findById(id); - - if (track.isEmpty()) { - throw new NotFoundException("Track not found"); - } - - Track foundTrack = track.get(); - - return new TrackResponseDTO( - foundTrack.getId(), - foundTrack.getTitle(), - foundTrack.getArtist(), - foundTrack.getBpm(), - foundTrack.getKey(), - foundTrack.getDuration()); - } - - /** - * Retrieves all tracks currently stored in the system. - *

- * Caching: The entire list of tracks is cached. This is highly efficient - * for - * read-heavy workloads, but requires careful eviction (clearing the cache) - * whenever - * a track is added, updated, or deleted to prevent stale data. - * - * @return a list of response DTOs representing all tracks - */ - @Override - @Cacheable(value = "tracks") - public List getAllTracks() { - log.info("Cache miss for 'tracks' list. Fetching all tracks from database."); - return trackRepository.findAll().stream().map(track -> new TrackResponseDTO( - track.getId(), - track.getTitle(), - track.getArtist(), - track.getBpm(), - track.getKey(), - track.getDuration())).toList(); - } -} +package com.jfontdev.trackstack.service.impl; + +import com.jfontdev.trackstack.dto.tag.TagResponseDTO; +import com.jfontdev.trackstack.dto.track.TrackPatchRequestDTO; +import com.jfontdev.trackstack.dto.track.TrackRequestDTO; +import com.jfontdev.trackstack.dto.track.TrackResponseDTO; +import com.jfontdev.trackstack.dto.track.TrackUpdateRequestDTO; +import com.jfontdev.trackstack.exception.NotFoundException; +import com.jfontdev.trackstack.model.Tag; +import com.jfontdev.trackstack.model.Track; +import com.jfontdev.trackstack.repository.TagRepository; +import com.jfontdev.trackstack.repository.TrackRepository; +import com.jfontdev.trackstack.service.TrackService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Implementation of the {@link TrackService} interface. + *

+ * This service handles the business logic for managing {@link Track} entities. + * It acts as a bridge between the controller layer (which handles HTTP + * requests) + * and the repository layer (which handles database operations). + *

+ * Caching Strategy: + * We use Spring's caching abstraction to improve read performance. + * - Read operations ({@code getTrackById}, {@code getAllTracks}) are cached + * under the "tracks" cache. + * - Write operations (create, update, patch, delete, and relationship changes) + * evict the entire "tracks" cache to ensure that subsequent reads do not + * return stale data. + *

+ * Transaction Strategy: + * All write operations are annotated with {@code @Transactional} (read-write) + * to ensure proper rollback on failure. Read operations such as + * {@code getTrackById} and {@code getAllTracks} are explicitly annotated with + * {@code @Transactional(readOnly = true)} to clearly mark them as read-only + * and to integrate cleanly with Spring's transaction management. + */ +@Service +public class TrackServiceImpl implements TrackService { + + private static final Logger log = LoggerFactory.getLogger(TrackServiceImpl.class); + + private final TrackRepository trackRepository; + private final TagRepository tagRepository; + + /** + * Constructs a new {@code TrackServiceImpl} with the required repositories. + *

+ * We inject both {@link TrackRepository} and {@link TagRepository} because + * this service manages the Track-Tag relationship (the owning side). + * + * @param trackRepository the repository used for database operations on tracks + * @param tagRepository the repository used to look up tags for relationship + * management + */ + public TrackServiceImpl(TrackRepository trackRepository, TagRepository tagRepository) { + this.trackRepository = trackRepository; + this.tagRepository = tagRepository; + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: We evict all entries in the "tracks" cache because + * adding a new track invalidates the result of {@code getAllTracks()}. + */ + @Override + @CacheEvict(value = "tracks", allEntries = true) + @Transactional + public TrackResponseDTO createTrack(TrackRequestDTO dto) { + log.info("Evicting 'tracks' cache. Creating new track: {}", dto.title()); + Track track = Track.create( + dto.title(), + dto.artist(), + dto.bpm(), + dto.key(), + dto.duration()); + + Track savedTrack = trackRepository.saveAndFlush(track); + + return mapToResponseDTO(savedTrack); + } + + /** + * {@inheritDoc} + *

+ * Caching: The result of this method is cached. If the track is + * requested again with the same ID, the cached value is returned instead + * of querying the database. + */ + @Override + @Cacheable(value = "tracks", key = "#id") + @Transactional(readOnly = true) + public TrackResponseDTO getTrackById(Long id) { + log.info("Cache miss for 'tracks' with id: {}. Fetching from database.", id); + Track foundTrack = findTrackOrThrow(id); + + return mapToResponseDTO(foundTrack); + } + + /** + * {@inheritDoc} + *

+ * Caching: The entire list of tracks is cached. This is highly efficient + * for read-heavy workloads, but requires careful eviction whenever a track + * is added, updated, or deleted to prevent stale data. + */ + @Override + @Cacheable(value = "tracks") + @Transactional(readOnly = true) + public List getAllTracks() { + log.info("Cache miss for 'tracks' list. Fetching all tracks from database."); + return trackRepository.findAll().stream() + .map(this::mapToResponseDTO) + .toList(); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "tracks" cache because + * updating a track invalidates both the individual entry and the list. It also + * evicts the "playlists" cache because tracked changes affect playlist + * responses. + */ + @Override + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) + @Transactional + public TrackResponseDTO updateTrack(Long id, TrackUpdateRequestDTO dto) { + log.info("Evicting 'tracks' and 'playlists' caches. Updating track with id: {}", id); + Track track = findTrackOrThrow(id); + + track.update(dto.title(), dto.artist(), dto.bpm(), dto.key(), dto.duration()); + Track savedTrack = trackRepository.saveAndFlush(track); + + return mapToResponseDTO(savedTrack); + } + + /** + * {@inheritDoc} + *

+ * Merges non-null fields from the patch DTO with the existing entity's values, + * then delegates to the entity's {@code update} method. + *

+ * Cache Eviction: Evicts all entries in the "tracks" cache. It also + * evicts the "playlists" cache because tracked changes affect playlist + * responses. + */ + @Override + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) + @Transactional + public TrackResponseDTO patchTrack(Long id, TrackPatchRequestDTO dto) { + log.info("Evicting 'tracks' and 'playlists' caches. Patching track with id: {}", id); + Track track = findTrackOrThrow(id); + + String title = dto.title() != null ? dto.title() : track.getTitle(); + String artist = dto.artist() != null ? dto.artist() : track.getArtist(); + Double bpm = dto.bpm() != null ? dto.bpm() : track.getBpm(); + String key = dto.key() != null ? dto.key() : track.getKey(); + String duration = dto.duration() != null ? dto.duration() : track.getDuration(); + + track.update(title, artist, bpm, key, duration); + Track savedTrack = trackRepository.saveAndFlush(track); + + return mapToResponseDTO(savedTrack); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "tracks" cache and + * "playlists" + * cache because deleting a track invalidates both the tracks list cache + * and any playlist that contained this track. + */ + @Override + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) + @Transactional + public void deleteTrack(Long id) { + log.info("Evicting 'tracks' and 'playlists' caches. Deleting track with id: {}", id); + Track track = findTrackOrThrow(id); + + trackRepository.delete(track); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "tracks" cache because + * changing a track's tags invalidates cached track representations. It also + * evicts the "playlists" cache because playlists include track tags in their + * representation. + */ + @Override + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) + @Transactional + public TrackResponseDTO addTagToTrack(Long trackId, Long tagId) { + log.info("Evicting 'tracks' and 'playlists' caches. Adding tag {} to track {}", tagId, trackId); + Track track = findTrackOrThrow(trackId); + Tag tag = findTagOrThrow(tagId); + + track.addTag(tag); + Track savedTrack = trackRepository.saveAndFlush(track); + + return mapToResponseDTO(savedTrack); + } + + /** + * {@inheritDoc} + *

+ * Cache Eviction: Evicts all entries in the "tracks" cache because + * changing a track's tags invalidates cached track representations. It also + * evicts the "playlists" cache because playlists include track tags in their + * representation. + */ + @Override + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) + @Transactional + public TrackResponseDTO removeTagFromTrack(Long trackId, Long tagId) { + log.info("Evicting 'tracks' and 'playlists' caches. Removing tag {} from track {}", tagId, trackId); + Track track = findTrackOrThrow(trackId); + Tag tag = findTagOrThrow(tagId); + + track.removeTag(tag); + Track savedTrack = trackRepository.saveAndFlush(track); + + return mapToResponseDTO(savedTrack); + } + + /** + * Finds a track by ID or throws a {@link NotFoundException}. + *

+ * This is an internal helper that centralizes the Optional handling + * pattern used across all methods that require an existing track. + * + * @param id the track ID to look up + * @return the found Track entity + * @throws NotFoundException if no track exists with the given ID + */ + private Track findTrackOrThrow(Long id) { + Optional track = trackRepository.findById(id); + + if (track.isEmpty()) { + throw new NotFoundException("Track not found"); + } + + return track.get(); + } + + /** + * Finds a tag by ID or throws a {@link NotFoundException}. + *

+ * Used by relationship management methods that need to look up tags. + * + * @param id the tag ID to look up + * @return the found Tag entity + * @throws NotFoundException if no tag exists with the given ID + */ + private Tag findTagOrThrow(Long id) { + Optional tag = tagRepository.findById(id); + + if (tag.isEmpty()) { + throw new NotFoundException("Tag not found."); + } + + return tag.get(); + } + + /** + * Maps a {@link Track} entity to a {@link TrackResponseDTO}. + *

+ * This centralizes the entity-to-DTO mapping logic to avoid repetition + * across service methods. The mapping includes the track's associated tags. + * The tags are sorted by name to guarantee deterministic API responses + * despite the underlying set's undefined iteration order. + * + * @param track the entity to map + * @return the corresponding response DTO + */ + private TrackResponseDTO mapToResponseDTO(Track track) { + List tagDTOs = track.getTags().stream() + .map(tag -> new TagResponseDTO(tag.getId(), tag.getName())) + .sorted(Comparator.comparing(TagResponseDTO::name)) + .toList(); + + return new TrackResponseDTO( + track.getId(), + track.getTitle(), + track.getArtist(), + track.getBpm(), + track.getKey(), + track.getDuration(), + tagDTOs); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d2a6dc2..30a55da 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,6 +21,11 @@ spring: max-idle: ${REDIS_POOL_MAX_IDLE:8} min-idle: ${REDIS_POOL_MIN_IDLE:0} + jpa: + properties: + hibernate: + default_batch_fetch_size: 100 + server: port: 8080 diff --git a/src/main/resources/db/migration/V5__add_cascade_delete_to_join_tables.sql b/src/main/resources/db/migration/V5__add_cascade_delete_to_join_tables.sql new file mode 100644 index 0000000..222b038 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_cascade_delete_to_join_tables.sql @@ -0,0 +1,34 @@ +-- ============================================================ +-- V5: Add ON DELETE CASCADE to join table foreign keys. +-- +-- Why: Phase 6 introduces DELETE operations for Track, Tag, +-- and Playlist. Without cascade behavior, deleting an entity +-- that has associations in a join table would fail with a +-- foreign key constraint violation. With ON DELETE CASCADE, +-- the join table rows are automatically removed when the +-- referenced entity is deleted. +-- ============================================================ + +-- ============================ +-- track_tags +-- ============================ + +ALTER TABLE track_tags DROP CONSTRAINT track_tags_track_id_fkey; +ALTER TABLE track_tags ADD CONSTRAINT track_tags_track_id_fkey + FOREIGN KEY (track_id) REFERENCES tracks (id) ON DELETE CASCADE; + +ALTER TABLE track_tags DROP CONSTRAINT track_tags_tag_id_fkey; +ALTER TABLE track_tags ADD CONSTRAINT track_tags_tag_id_fkey + FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE; + +-- ============================ +-- playlist_tracks +-- ============================ + +ALTER TABLE playlist_tracks DROP CONSTRAINT playlist_tracks_playlist_id_fkey; +ALTER TABLE playlist_tracks ADD CONSTRAINT playlist_tracks_playlist_id_fkey + FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE; + +ALTER TABLE playlist_tracks DROP CONSTRAINT playlist_tracks_track_id_fkey; +ALTER TABLE playlist_tracks ADD CONSTRAINT playlist_tracks_track_id_fkey + FOREIGN KEY (track_id) REFERENCES tracks (id) ON DELETE CASCADE; diff --git a/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java b/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java index f801df5..faf0110 100644 --- a/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java @@ -1,80 +1,380 @@ -package com.jfontdev.trackstack; - -import io.restassured.http.ContentType; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; - -/** - * Integration tests for the Playlist API. - * - *

These tests ensure playlist endpoints work end-to-end against a real - * database configured through Testcontainers.

- */ -public class PlaylistControllerIntegrationTest extends BaseIntegrationTest { - - /** - * Verifies playlist creation, retrieval by ID, and listing all playlists. - */ - @Test - void createPlaylistThenGetByIdAndList() { - // GIVEN a valid playlist request - Map payload = Map.of( - "name", "Warmup Set", - "description", "Groovy openers for the night"); - - // WHEN the playlist is created - Number id = given() - .contentType(ContentType.JSON) - .body(payload) - .when() - .post("/api/playlists") - .then() - .statusCode(201) - .body("id", notNullValue()) - .body("name", equalTo("Warmup Set")) - .extract() - .path("id"); - - long playlistId = id.longValue(); - - // THEN it can be retrieved by id - given() - .when() - .get("/api/playlists/{id}", playlistId) - .then() - .statusCode(200) - .body("id", equalTo((int) playlistId)) - .body("name", equalTo("Warmup Set")); - - // THEN it appears in the list endpoint - given() - .when() - .get("/api/playlists") - .then() - .statusCode(200) - .body("size()", is(1)) - .body("[0].id", equalTo((int) playlistId)); - } - - /** - * Verifies a missing playlist id returns a 404 with an error payload. - */ - @Test - void getPlaylistByIdReturnsNotFoundForMissingId() { - // GIVEN a playlist id that does not exist - long missingId = 9999L; - - // WHEN the playlist is requested - // THEN a 404 error is returned - given() - .when() - .get("/api/playlists/{id}", missingId) - .then() - .statusCode(404) - .body("error", equalTo("Playlist not found.")); - } -} +package com.jfontdev.trackstack; + +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +/** + * Integration tests for the Playlist API. + * + *

These tests ensure playlist endpoints work end-to-end against a real + * database configured through Testcontainers. They cover creation, retrieval, + * full update (PUT), partial update (PATCH), deletion, and track relationship + * management.

+ */ +public class PlaylistControllerIntegrationTest extends BaseIntegrationTest { + + /** + * Verifies playlist creation, retrieval by ID, and listing all playlists. + * Also verifies the response includes an empty tracks list for a new playlist. + */ + @Test + void createPlaylistThenGetByIdAndList() { + // GIVEN a valid playlist request + Map payload = Map.of( + "name", "Warmup Set", + "description", "Groovy openers for the night"); + + // WHEN the playlist is created + Number id = given() + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/playlists") + .then() + .statusCode(201) + .body("id", notNullValue()) + .body("name", equalTo("Warmup Set")) + .body("tracks", hasSize(0)) + .extract() + .path("id"); + + long playlistId = id.longValue(); + + // THEN it can be retrieved by id + given() + .when() + .get("/api/playlists/{id}", playlistId) + .then() + .statusCode(200) + .body("id", equalTo((int) playlistId)) + .body("name", equalTo("Warmup Set")); + + // THEN it appears in the list endpoint + given() + .when() + .get("/api/playlists") + .then() + .statusCode(200) + .body("size()", is(1)) + .body("[0].id", equalTo((int) playlistId)); + } + + /** + * Verifies a missing playlist id returns a 404 with an error payload. + */ + @Test + void getPlaylistByIdReturnsNotFoundForMissingId() { + // GIVEN a playlist id that does not exist + long missingId = 9999L; + + // WHEN the playlist is requested + // THEN a 404 error is returned + given() + .when() + .get("/api/playlists/{id}", missingId) + .then() + .statusCode(404) + .body("error", equalTo("Playlist not found.")); + } + + /** + * Verifies full update (PUT) replaces all fields and returns 200. + */ + @Test + void updatePlaylistReturns200WithUpdatedData() { + // GIVEN an existing playlist + long playlistId = createPlaylist("Original", "Original description"); + + // WHEN the playlist is fully updated + Map updatePayload = Map.of( + "name", "Updated Set", + "description", "New description"); + + given() + .contentType(ContentType.JSON) + .body(updatePayload) + .when() + .put("/api/playlists/{id}", playlistId) + .then() + .statusCode(200) + .body("id", equalTo((int) playlistId)) + .body("name", equalTo("Updated Set")) + .body("description", equalTo("New description")); + } + + /** + * Verifies PUT on a non-existent playlist returns 404. + */ + @Test + void updatePlaylistReturns404ForMissingId() { + Map updatePayload = Map.of( + "name", "Updated", + "description", "Desc"); + + given() + .contentType(ContentType.JSON) + .body(updatePayload) + .when() + .put("/api/playlists/{id}", 9999L) + .then() + .statusCode(404) + .body("error", equalTo("Playlist not found.")); + } + + /** + * Verifies partial update (PATCH) only changes provided fields. + */ + @Test + void patchPlaylistReturns200WithPartialUpdate() { + // GIVEN an existing playlist + long playlistId = createPlaylist("Original", "Original description"); + + // WHEN only the name is patched + Map patchPayload = Map.of("name", "Patched Set"); + + given() + .contentType(ContentType.JSON) + .body(patchPayload) + .when() + .patch("/api/playlists/{id}", playlistId) + .then() + .statusCode(200) + .body("id", equalTo((int) playlistId)) + .body("name", equalTo("Patched Set")) + .body("description", equalTo("Original description")); + } + + /** + * Verifies DELETE returns 204 and the playlist is gone. + */ + @Test + void deletePlaylistReturns204() { + // GIVEN an existing playlist + long playlistId = createPlaylist("To Delete", "Will be deleted"); + + // WHEN the playlist is deleted + given() + .when() + .delete("/api/playlists/{id}", playlistId) + .then() + .statusCode(204); + + // THEN it no longer exists + given() + .when() + .get("/api/playlists/{id}", playlistId) + .then() + .statusCode(404); + } + + /** + * Verifies DELETE on a non-existent playlist returns 404. + */ + @Test + void deletePlaylistReturns404ForMissingId() { + given() + .when() + .delete("/api/playlists/{id}", 9999L) + .then() + .statusCode(404) + .body("error", equalTo("Playlist not found.")); + } + + /** + * Verifies adding a track to a playlist and then removing it. + */ + @Test + void addAndRemoveTrackFromPlaylist() { + // GIVEN an existing playlist and track + long playlistId = createPlaylist("My Playlist", "Test playlist"); + long trackId = createTrack("Test Track", "Artist", 128.0, "A minor", "3:30"); + + // WHEN the track is added to the playlist + given() + .when() + .put("/api/playlists/{id}/tracks/{trackId}", playlistId, trackId) + .then() + .statusCode(200) + .body("id", equalTo((int) playlistId)) + .body("tracks", hasSize(1)) + .body("tracks[0].id", equalTo((int) trackId)) + .body("tracks[0].title", equalTo("Test Track")); + + // THEN retrieving the playlist shows the track + given() + .when() + .get("/api/playlists/{id}", playlistId) + .then() + .statusCode(200) + .body("tracks", hasSize(1)); + + // WHEN the track is removed from the playlist + given() + .when() + .delete("/api/playlists/{id}/tracks/{trackId}", playlistId, trackId) + .then() + .statusCode(200) + .body("tracks", hasSize(0)); + } + + /** + * Verifies that deleting a track also removes it from the playlist + * (via ON DELETE CASCADE on the join table foreign key). + */ + @Test + void deletingTrackRemovesItFromPlaylist() { + // GIVEN a playlist with a track + long playlistId = createPlaylist("Cascade Test", "Testing cascade"); + long trackId = createTrack("Cascade Track", "Artist", 130.0, "B minor", "4:00"); + + given() + .when() + .put("/api/playlists/{id}/tracks/{trackId}", playlistId, trackId) + .then() + .statusCode(200) + .body("tracks", hasSize(1)); + + // WHEN the track is deleted directly + given() + .when() + .delete("/api/tracks/{id}", trackId) + .then() + .statusCode(204); + + // THEN the playlist no longer contains the track + given() + .when() + .get("/api/playlists/{id}", playlistId) + .then() + .statusCode(200) + .body("tracks", hasSize(0)); + } + + // ==================== Validation Tests ==================== + + @Test + void createPlaylistWithNullNameReturns400() { + given() + .contentType(ContentType.JSON) + .body(Map.of()) + .when() + .post("/api/playlists") + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty")); + } + + @Test + void createPlaylistWithEmptyNameReturns400() { + given() + .contentType(ContentType.JSON) + .body(Map.of("name", " ")) + .when() + .post("/api/playlists") + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty")); + } + + @Test + void createPlaylistWithDescriptionTooLongReturns400() { + String longDescription = "A".repeat(501); + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "Valid", "description", longDescription)) + .when() + .post("/api/playlists") + .then() + .statusCode(400) + .body("errors.description", equalTo("Description must not exceed 500 characters")); + } + + @Test + void updatePlaylistWithNullNameReturns400() { + long playlistId = createPlaylist("Valid", "Desc"); + + given() + .contentType(ContentType.JSON) + .body(Map.of("description", "New Desc")) + .when() + .put("/api/playlists/{id}", playlistId) + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty")); + } + + @Test + void patchPlaylistWithEmptyNameReturns400() { + long playlistId = createPlaylist("Valid", "Desc"); + + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "")) + .when() + .patch("/api/playlists/{id}", playlistId) + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty if provided")); + } + + @Test + void patchPlaylistWithDescriptionTooLongReturns400() { + long playlistId = createPlaylist("Valid", "Desc"); + String longDescription = "A".repeat(501); + + given() + .contentType(ContentType.JSON) + .body(Map.of("description", longDescription)) + .when() + .patch("/api/playlists/{id}", playlistId) + .then() + .statusCode(400) + .body("errors.description", equalTo("Description must not exceed 500 characters")); + } + + // ==================== Helper methods ==================== + + /** + * Creates a playlist via the API and returns its ID. + */ + private long createPlaylist(String name, String description) { + Number id = given() + .contentType(ContentType.JSON) + .body(Map.of("name", name, "description", description)) + .when() + .post("/api/playlists") + .then() + .statusCode(201) + .extract() + .path("id"); + + return id.longValue(); + } + + /** + * Creates a track via the API and returns its ID. + */ + private long createTrack(String title, String artist, Double bpm, String key, String duration) { + Number id = given() + .contentType(ContentType.JSON) + .body(Map.of( + "title", title, + "artist", artist, + "bpm", bpm, + "key", key, + "duration", duration)) + .when() + .post("/api/tracks") + .then() + .statusCode(201) + .extract() + .path("id"); + + return id.longValue(); + } +} diff --git a/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java b/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java index 5de8dd9..317c8b0 100644 --- a/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java @@ -1,78 +1,320 @@ -package com.jfontdev.trackstack; - -import io.restassured.http.ContentType; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; - -/** - * Integration tests for the Tag API. - * - *

These tests validate end-to-end behavior across HTTP, service logic, - * repository access, and the database.

- */ -public class TagControllerIntegrationTest extends BaseIntegrationTest { - - /** - * Verifies tag creation, retrieval by ID, and listing all tags. - */ - @Test - void createTagThenGetByIdAndList() { - // GIVEN a valid tag request - Map payload = Map.of("name", "House"); - - // WHEN the tag is created - Number id = given() - .contentType(ContentType.JSON) - .body(payload) - .when() - .post("/api/tags") - .then() - .statusCode(201) - .body("id", notNullValue()) - .body("name", equalTo("House")) - .extract() - .path("id"); - - long tagId = id.longValue(); - - // THEN it can be retrieved by id - given() - .when() - .get("/api/tags/{id}", tagId) - .then() - .statusCode(200) - .body("id", equalTo((int) tagId)) - .body("name", equalTo("House")); - - // THEN it appears in the list endpoint - given() - .when() - .get("/api/tags") - .then() - .statusCode(200) - .body("size()", is(1)) - .body("[0].id", equalTo((int) tagId)); - } - - /** - * Verifies a missing tag id returns a 404 with an error payload. - */ - @Test - void getTagByIdReturnsNotFoundForMissingId() { - // GIVEN a tag id that does not exist - long missingId = 9999L; - - // WHEN the tag is requested - // THEN a 404 error is returned - given() - .when() - .get("/api/tags/{id}", missingId) - .then() - .statusCode(404) - .body("error", equalTo("Tag not found.")); - } -} +package com.jfontdev.trackstack; + +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +/** + * Integration tests for the Tag API. + * + *

+ * These tests validate end-to-end behavior across HTTP, service logic, + * repository access, and the database. They cover creation, retrieval, + * full update (PUT), partial update (PATCH), deletion, and unique constraint + * violation handling. + *

+ */ +public class TagControllerIntegrationTest extends BaseIntegrationTest { + + /** + * Verifies tag creation, retrieval by ID, and listing all tags. + */ + @Test + void createTagThenGetByIdAndList() { + // GIVEN a valid tag request + Map payload = Map.of("name", "House"); + + // WHEN the tag is created + Number id = given() + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/tags") + .then() + .statusCode(201) + .body("id", notNullValue()) + .body("name", equalTo("House")) + .extract() + .path("id"); + + long tagId = id.longValue(); + + // THEN it can be retrieved by id + given() + .when() + .get("/api/tags/{id}", tagId) + .then() + .statusCode(200) + .body("id", equalTo((int) tagId)) + .body("name", equalTo("House")); + + // THEN it appears in the list endpoint + given() + .when() + .get("/api/tags") + .then() + .statusCode(200) + .body("size()", is(1)) + .body("[0].id", equalTo((int) tagId)); + } + + /** + * Verifies a missing tag id returns a 404 with an error payload. + */ + @Test + void getTagByIdReturnsNotFoundForMissingId() { + // GIVEN a tag id that does not exist + long missingId = 9999L; + + // WHEN the tag is requested + // THEN a 404 error is returned + given() + .when() + .get("/api/tags/{id}", missingId) + .then() + .statusCode(404) + .body("error", equalTo("Tag not found.")); + } + + /** + * Verifies full update (PUT) replaces the tag name and returns 200. + */ + @Test + void updateTagReturns200WithUpdatedData() { + // GIVEN an existing tag + long tagId = createTag("Original"); + + // WHEN the tag is fully updated + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "Updated")) + .when() + .put("/api/tags/{id}", tagId) + .then() + .statusCode(200) + .body("id", equalTo((int) tagId)) + .body("name", equalTo("Updated")); + } + + /** + * Verifies PUT on a non-existent tag returns 404. + */ + @Test + void updateTagReturns404ForMissingId() { + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "Updated")) + .when() + .put("/api/tags/{id}", 9999L) + .then() + .statusCode(404) + .body("error", equalTo("Tag not found.")); + } + + /** + * Verifies partial update (PATCH) changes only provided fields. + */ + @Test + void patchTagReturns200WithPartialUpdate() { + // GIVEN an existing tag + long tagId = createTag("Original"); + + // WHEN only the name is patched + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "Patched")) + .when() + .patch("/api/tags/{id}", tagId) + .then() + .statusCode(200) + .body("id", equalTo((int) tagId)) + .body("name", equalTo("Patched")); + } + + /** + * Verifies DELETE returns 204 and the tag is gone. + */ + @Test + void deleteTagReturns204() { + // GIVEN an existing tag + long tagId = createTag("ToDelete"); + + // WHEN the tag is deleted + given() + .when() + .delete("/api/tags/{id}", tagId) + .then() + .statusCode(204); + + // THEN it no longer exists + given() + .when() + .get("/api/tags/{id}", tagId) + .then() + .statusCode(404); + } + + /** + * Verifies DELETE on a non-existent tag returns 404. + */ + @Test + void deleteTagReturns404ForMissingId() { + given() + .when() + .delete("/api/tags/{id}", 9999L) + .then() + .statusCode(404) + .body("error", equalTo("Tag not found.")); + } + + /** + * Verifies that updating a tag to a duplicate name returns 409 Conflict. + *

+ * The {@code tags.name} column has a UNIQUE constraint in the database. + * Attempting to rename a tag to a name that already exists must trigger + * a {@code DataIntegrityViolationException}, which our global exception + * handler maps to HTTP 409. + */ + @Test + void updateTagWithDuplicateNameReturns409() { + // GIVEN two existing tags + createTag("Electronic"); + long secondTagId = createTag("Ambient"); + + // WHEN the second tag is updated to the first tag's name + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "Electronic")) + .when() + .put("/api/tags/{id}", secondTagId) + .then() + .statusCode(409) + .body("error", notNullValue()); + } + + /** + * Verifies that creating a tag with a duplicate name returns 409 Conflict. + */ + @Test + void createTagWithDuplicateNameReturns409() { + // GIVEN an existing tag + createTag("Electronic"); + + // WHEN another tag with the same name is created + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "Electronic")) + .when() + .post("/api/tags") + .then() + .statusCode(409) + .body("error", notNullValue()); + } + + // ==================== Validation Tests ==================== + + /** + * Verifies that creating a tag with a null name returns 400 Bad Request. + */ + @Test + void createTagWithNullNameReturns400() { + given() + .contentType(ContentType.JSON) + .body(Map.of()) // No name provided + .when() + .post("/api/tags") + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty")); + } + + /** + * Verifies that creating a tag with an empty name returns 400 Bad Request. + */ + @Test + void createTagWithEmptyNameReturns400() { + given() + .contentType(ContentType.JSON) + .body(Map.of("name", " ")) + .when() + .post("/api/tags") + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty")); + } + + /** + * Verifies that full update (PUT) with a null name returns 400 Bad Request. + */ + @Test + void updateTagWithNullNameReturns400() { + long tagId = createTag("ValidTag"); + + given() + .contentType(ContentType.JSON) + .body(Map.of()) + .when() + .put("/api/tags/{id}", tagId) + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty")); + } + + /** + * Verifies that full update (PUT) with an empty name returns 400 Bad Request. + */ + @Test + void updateTagWithEmptyNameReturns400() { + long tagId = createTag("ValidTag"); + + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "")) + .when() + .put("/api/tags/{id}", tagId) + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty")); + } + + /** + * Verifies that partial update (PATCH) with an empty name returns 400 Bad + * Request. + */ + @Test + void patchTagWithEmptyNameReturns400() { + long tagId = createTag("ValidTag"); + + given() + .contentType(ContentType.JSON) + .body(Map.of("name", "")) + .when() + .patch("/api/tags/{id}", tagId) + .then() + .statusCode(400) + .body("errors.name", equalTo("Name must not be empty if provided")); + } + + // ==================== Helper methods ==================== + + /** + * Creates a tag via the API and returns its ID. + */ + private long createTag(String name) { + Number id = given() + .contentType(ContentType.JSON) + .body(Map.of("name", name)) + .when() + .post("/api/tags") + .then() + .statusCode(201) + .extract() + .path("id"); + + return id.longValue(); + } +} diff --git a/src/test/java/com/jfontdev/trackstack/TestcontainersConfiguration.java b/src/test/java/com/jfontdev/trackstack/TestcontainersConfiguration.java index 9a8d327..95959cf 100644 --- a/src/test/java/com/jfontdev/trackstack/TestcontainersConfiguration.java +++ b/src/test/java/com/jfontdev/trackstack/TestcontainersConfiguration.java @@ -7,8 +7,6 @@ import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; -import java.util.List; - /** * Testcontainers configuration for integration tests. * @@ -23,30 +21,20 @@ class TestcontainersConfiguration { /** * Starts a PostgreSQL container and registers it as the test datasource. * - *

The container uses fixed connection settings to make it easy to inspect - * the database during a paused test run.

+ *

The container uses a random host port assigned by Testcontainers to avoid + * port conflicts when multiple test contexts start in the same JVM run. + * {@link ServiceConnection} automatically wires the dynamic JDBC URL into the + * application context.

* * @return a configured {@link PostgreSQLContainer} ready for test use */ @Bean @ServiceConnection PostgreSQLContainer postgresContainer() { - int hostPort = 54329; - String databaseName = "trackstack_test"; - String username = "trackstack"; - String password = "trackstack"; - - // Create and configure the PostgreSQL container with fixed settings - PostgreSQLContainer container = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")) - .withDatabaseName(databaseName) - .withUsername(username) - .withPassword(password) - .withExposedPorts(5432); - - // Map the container's internal port 5432 to a fixed host port for easy access - container.setPortBindings(List.of(hostPort + ":5432")); - - return container; + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")) + .withDatabaseName("trackstack_test") + .withUsername("trackstack") + .withPassword("trackstack"); } /** diff --git a/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java b/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java index 7b85c88..8290c5d 100644 --- a/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java @@ -1,83 +1,373 @@ -package com.jfontdev.trackstack; - -import io.restassured.http.ContentType; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; - -/** - * Integration tests for the Track API. - * - *

These tests exercise the full Controller -> Service -> Repository -> DB - * flow using real infrastructure via Testcontainers.

- */ -public class TrackControllerIntegrationTest extends BaseIntegrationTest { - - /** - * Verifies track creation, retrieval by ID, and listing all tracks. - */ - @Test - void createTrackThenGetByIdAndList() { - // GIVEN a valid track request - Map payload = Map.of( - "title", "Night Drive", - "artist", "Nova", - "bpm", 128.0, - "key", "A minor", - "duration", "3:45"); - - // WHEN the track is created - Number id = given() - .contentType(ContentType.JSON) - .body(payload) - .when() - .post("/api/tracks") - .then() - .statusCode(201) - .body("id", notNullValue()) - .body("title", equalTo("Night Drive")) - .extract() - .path("id"); - - long trackId = id.longValue(); - - // THEN it can be retrieved by id - given() - .when() - .get("/api/tracks/{id}", trackId) - .then() - .statusCode(200) - .body("id", equalTo((int) trackId)) - .body("title", equalTo("Night Drive")); - - // THEN it appears in the list endpoint - given() - .when() - .get("/api/tracks") - .then() - .statusCode(200) - .body("size()", is(1)) - .body("[0].id", equalTo((int) trackId)); - } - - /** - * Verifies a missing track id returns a 404 with an error payload. - */ - @Test - void getTrackByIdReturnsNotFoundForMissingId() { - // GIVEN a track id that does not exist - long missingId = 9999L; - - // WHEN the track is requested - // THEN a 404 error is returned - given() - .when() - .get("/api/tracks/{id}", missingId) - .then() - .statusCode(404) - .body("error", equalTo("Track not found")); - } -} +package com.jfontdev.trackstack; + +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +/** + * Integration tests for the Track API. + * + *

These tests exercise the full Controller -> Service -> Repository -> DB + * flow using real infrastructure via Testcontainers. They cover creation, + * retrieval, full update (PUT), partial update (PATCH), deletion, and + * tag relationship management.

+ */ +public class TrackControllerIntegrationTest extends BaseIntegrationTest { + + /** + * Verifies track creation, retrieval by ID, and listing all tracks. + * Also verifies the response includes an empty tags list for a new track. + */ + @Test + void createTrackThenGetByIdAndList() { + // GIVEN a valid track request + Map payload = Map.of( + "title", "Night Drive", + "artist", "Nova", + "bpm", 128.0, + "key", "A minor", + "duration", "3:45"); + + // WHEN the track is created + Number id = given() + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/tracks") + .then() + .statusCode(201) + .body("id", notNullValue()) + .body("title", equalTo("Night Drive")) + .body("tags", hasSize(0)) + .extract() + .path("id"); + + long trackId = id.longValue(); + + // THEN it can be retrieved by id + given() + .when() + .get("/api/tracks/{id}", trackId) + .then() + .statusCode(200) + .body("id", equalTo((int) trackId)) + .body("title", equalTo("Night Drive")); + + // THEN it appears in the list endpoint + given() + .when() + .get("/api/tracks") + .then() + .statusCode(200) + .body("size()", is(1)) + .body("[0].id", equalTo((int) trackId)); + } + + /** + * Verifies a missing track id returns a 404 with an error payload. + */ + @Test + void getTrackByIdReturnsNotFoundForMissingId() { + // GIVEN a track id that does not exist + long missingId = 9999L; + + // WHEN the track is requested + // THEN a 404 error is returned + given() + .when() + .get("/api/tracks/{id}", missingId) + .then() + .statusCode(404) + .body("error", equalTo("Track not found")); + } + + /** + * Verifies full update (PUT) replaces all fields and returns 200. + */ + @Test + void updateTrackReturns200WithUpdatedData() { + // GIVEN an existing track + long trackId = createTrack("Original", "Artist A", 120.0, "C major", "3:00"); + + // WHEN the track is fully updated + Map updatePayload = Map.of( + "title", "Updated Title", + "artist", "Artist B", + "bpm", 140.0, + "key", "D minor", + "duration", "4:30"); + + given() + .contentType(ContentType.JSON) + .body(updatePayload) + .when() + .put("/api/tracks/{id}", trackId) + .then() + .statusCode(200) + .body("id", equalTo((int) trackId)) + .body("title", equalTo("Updated Title")) + .body("artist", equalTo("Artist B")) + .body("bpm", equalTo(140.0f)) + .body("key", equalTo("D minor")) + .body("duration", equalTo("4:30")); + } + + /** + * Verifies PUT on a non-existent track returns 404. + */ + @Test + void updateTrackReturns404ForMissingId() { + Map updatePayload = Map.of( + "title", "Updated", + "artist", "Someone", + "duration", "3:00"); + + given() + .contentType(ContentType.JSON) + .body(updatePayload) + .when() + .put("/api/tracks/{id}", 9999L) + .then() + .statusCode(404) + .body("error", equalTo("Track not found")); + } + + /** + * Verifies partial update (PATCH) only changes provided fields. + */ + @Test + void patchTrackReturns200WithPartialUpdate() { + // GIVEN an existing track + long trackId = createTrack("Original", "Artist A", 120.0, "C major", "3:00"); + + // WHEN only the title is patched + Map patchPayload = Map.of("title", "Patched Title"); + + given() + .contentType(ContentType.JSON) + .body(patchPayload) + .when() + .patch("/api/tracks/{id}", trackId) + .then() + .statusCode(200) + .body("id", equalTo((int) trackId)) + .body("title", equalTo("Patched Title")) + .body("artist", equalTo("Artist A")) + .body("bpm", equalTo(120.0f)) + .body("key", equalTo("C major")) + .body("duration", equalTo("3:00")); + } + + /** + * Verifies DELETE returns 204 and the track is gone. + */ + @Test + void deleteTrackReturns204() { + // GIVEN an existing track + long trackId = createTrack("To Delete", "Artist", 100.0, "E minor", "2:30"); + + // WHEN the track is deleted + given() + .when() + .delete("/api/tracks/{id}", trackId) + .then() + .statusCode(204); + + // THEN it no longer exists + given() + .when() + .get("/api/tracks/{id}", trackId) + .then() + .statusCode(404); + } + + /** + * Verifies DELETE on a non-existent track returns 404. + */ + @Test + void deleteTrackReturns404ForMissingId() { + given() + .when() + .delete("/api/tracks/{id}", 9999L) + .then() + .statusCode(404) + .body("error", equalTo("Track not found")); + } + + /** + * Verifies adding a tag to a track and then removing it. + */ + @Test + void addAndRemoveTagFromTrack() { + // GIVEN an existing track and tag + long trackId = createTrack("Tagged Track", "Artist", 128.0, "A minor", "3:30"); + long tagId = createTag("Electronic"); + + // WHEN the tag is added to the track + given() + .when() + .put("/api/tracks/{id}/tags/{tagId}", trackId, tagId) + .then() + .statusCode(200) + .body("id", equalTo((int) trackId)) + .body("tags", hasSize(1)) + .body("tags[0].id", equalTo((int) tagId)) + .body("tags[0].name", equalTo("Electronic")); + + // THEN retrieving the track shows the tag + given() + .when() + .get("/api/tracks/{id}", trackId) + .then() + .statusCode(200) + .body("tags", hasSize(1)); + + // WHEN the tag is removed from the track + given() + .when() + .delete("/api/tracks/{id}/tags/{tagId}", trackId, tagId) + .then() + .statusCode(200) + .body("tags", hasSize(0)); + } + // ==================== Validation Tests ==================== + + @Test + void createTrackWithMissingFieldsReturns400() { + given() + .contentType(ContentType.JSON) + .body(Map.of()) + .when() + .post("/api/tracks") + .then() + .statusCode(400) + .body("errors.title", equalTo("Title must not be empty")) + .body("errors.artist", equalTo("Artist must not be empty")) + .body("errors.duration", equalTo("Duration must not be empty")); + } + + @Test + void createTrackWithInvalidDurationReturns400() { + given() + .contentType(ContentType.JSON) + .body(Map.of( + "title", "Track 1", + "artist", "Artist 1", + "duration", "5:5" // Invalid format, expects mm:ss + )) + .when() + .post("/api/tracks") + .then() + .statusCode(400) + .body("errors.duration", equalTo("Duration must be in mm:ss format")); + } + + @Test + void createTrackWithNegativeBpmReturns400() { + given() + .contentType(ContentType.JSON) + .body(Map.of( + "title", "Track 1", + "artist", "Artist 1", + "duration", "05:05", + "bpm", -120.0 + )) + .when() + .post("/api/tracks") + .then() + .statusCode(400) + .body("errors.bpm", equalTo("BPM must be positive if provided")); + } + + @Test + void updateTrackWithEmptyTitleReturns400() { + long trackId = createTrack("Original Title", "Artist", 120.0, "Am", "03:30"); + + given() + .contentType(ContentType.JSON) + .body(Map.of( + "title", " ", + "artist", "Artist", + "duration", "03:30" + )) + .when() + .put("/api/tracks/{id}", trackId) + .then() + .statusCode(400) + .body("errors.title", equalTo("Title must not be empty")); + } + + @Test + void patchTrackWithInvalidDurationReturns400() { + long trackId = createTrack("Original Title", "Artist", 120.0, "Am", "03:30"); + + given() + .contentType(ContentType.JSON) + .body(Map.of("duration", "abc")) + .when() + .patch("/api/tracks/{id}", trackId) + .then() + .statusCode(400) + .body("errors.duration", equalTo("Duration must be in mm:ss format if provided")); + } + + @Test + void patchTrackWithEmptyTitleReturns400() { + long trackId = createTrack("Original Title", "Artist", 120.0, "Am", "03:30"); + + given() + .contentType(ContentType.JSON) + .body(Map.of("title", "")) + .when() + .patch("/api/tracks/{id}", trackId) + .then() + .statusCode(400) + .body("errors.title", equalTo("Title must not be empty if provided")); + } + // ==================== Helper methods ==================== + + /** + * Creates a track via the API and returns its ID. + */ + private long createTrack(String title, String artist, Double bpm, String key, String duration) { + Map payload = Map.of( + "title", title, + "artist", artist, + "bpm", bpm, + "key", key, + "duration", duration); + + Number id = given() + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/tracks") + .then() + .statusCode(201) + .extract() + .path("id"); + + return id.longValue(); + } + + /** + * Creates a tag via the API and returns its ID. + */ + private long createTag(String name) { + Number id = given() + .contentType(ContentType.JSON) + .body(Map.of("name", name)) + .when() + .post("/api/tags") + .then() + .statusCode(201) + .extract() + .path("id"); + + return id.longValue(); + } +}