From 06d98c8d0cf085e3a8843eb5de47ee2c9650ad83 Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 20:59:25 +0100 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20implement=20Phase=206=20=E2=80=93?= =?UTF-8?q?=20update,=20delete,=20and=20relationship=20management=20operat?= =?UTF-8?q?ions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the CRUD cycle with PUT, PATCH, DELETE endpoints for Track, Tag, and Playlist entities. Add relationship management endpoints for adding/removing tags from tracks and tracks from playlists. Key changes: - Entity update() methods for controlled mutation, HashSet initialization - V5 Flyway migration: ON DELETE CASCADE on join table foreign keys - Update/Patch request DTOs with validation per entity - Enriched response DTOs (tracks include tags, playlists include tracks) - @Transactional on writes, @Transactional(readOnly=true) on reads - @CacheEvict(allEntries=true) on all write operations - DataIntegrityViolationException handler (409 Conflict) - Integration tests for all new endpoints (27 tests, all passing) - Fix Testcontainers port conflict (random ports instead of fixed) --- .mvn/wrapper/maven-wrapper.properties | 5 +- docs/project-plan.md | 22 +- .../controller/PlaylistController.java | 178 +++++-- .../trackstack/controller/TagController.java | 152 ++++-- .../controller/TrackController.java | 178 +++++-- .../dto/playlist/PlaylistPatchRequestDTO.java | 17 + .../dto/playlist/PlaylistResponseDTO.java | 33 +- .../playlist/PlaylistUpdateRequestDTO.java | 19 + .../dto/tag/TagPatchRequestDTO.java | 15 + .../dto/tag/TagUpdateRequestDTO.java | 16 + .../dto/track/TrackPatchRequestDTO.java | 21 + .../dto/track/TrackResponseDTO.java | 38 +- .../dto/track/TrackUpdateRequestDTO.java | 23 + .../exception/GlobalExceptionHandler.java | 101 ++-- .../jfontdev/trackstack/model/Playlist.java | 174 +++++-- .../com/jfontdev/trackstack/model/Tag.java | 116 +++-- .../com/jfontdev/trackstack/model/Track.java | 247 +++++++--- .../trackstack/service/PlaylistService.java | 157 +++++-- .../trackstack/service/TagService.java | 129 ++++-- .../trackstack/service/TrackService.java | 158 +++++-- .../service/impl/PlaylistServiceImpl.java | 429 +++++++++++------ .../service/impl/TagServiceImpl.java | 334 ++++++++------ .../service/impl/TrackServiceImpl.java | 436 ++++++++++++------ .../V5__add_cascade_delete_to_join_tables.sql | 34 ++ .../PlaylistControllerIntegrationTest.java | 378 +++++++++++---- .../TagControllerIntegrationTest.java | 312 +++++++++---- .../TestcontainersConfiguration.java | 28 +- .../TrackControllerIntegrationTest.java | 363 +++++++++++---- 28 files changed, 2969 insertions(+), 1144 deletions(-) create mode 100644 src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java create mode 100644 src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistUpdateRequestDTO.java create mode 100644 src/main/java/com/jfontdev/trackstack/dto/tag/TagPatchRequestDTO.java create mode 100644 src/main/java/com/jfontdev/trackstack/dto/tag/TagUpdateRequestDTO.java create mode 100644 src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java create mode 100644 src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java create mode 100644 src/main/resources/db/migration/V5__add_cascade_delete_to_join_tables.sql 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/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java b/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java index 31b4027..ecaac95 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 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, @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..9c17a3f 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 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, @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..2d44253 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, @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..b50b98e --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java @@ -0,0 +1,17 @@ +package com.jfontdev.trackstack.dto.playlist; + +/** + * 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. + * + * @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( + String name, + String description +) { +} 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..07430f3 --- /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; + +/** + * 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 String name, + 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..f9c3646 --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/tag/TagPatchRequestDTO.java @@ -0,0 +1,15 @@ +package com.jfontdev.trackstack.dto.tag; + +/** + * 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. + * + * @param name the new tag name, or null to keep the current value + */ +public record TagPatchRequestDTO( + String name +) { +} 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..0e3f9de --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/tag/TagUpdateRequestDTO.java @@ -0,0 +1,16 @@ +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 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..e6201de --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java @@ -0,0 +1,21 @@ +package com.jfontdev.trackstack.dto.track; + +/** + * 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. + * + * @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(String title, + String artist, + Double bpm, + String key, + 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..2b11ab5 --- /dev/null +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java @@ -0,0 +1,23 @@ +package com.jfontdev.trackstack.dto.track; + +import jakarta.validation.constraints.NotBlank; + +/** + * 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 String title, + @NotBlank String artist, + Double bpm, + String key, + @NotBlank 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..636e586 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.put(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..41234d6 100644 --- a/src/main/java/com/jfontdev/trackstack/model/Playlist.java +++ b/src/main/java/com/jfontdev/trackstack/model/Playlist.java @@ -1,50 +1,124 @@ -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.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 the set of tracks in this playlist. + * + * @return the tracks belonging to this playlist + */ + public Set getTracks() { + return 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..e6f93d8 100644 --- a/src/main/java/com/jfontdev/trackstack/model/Track.java +++ b/src/main/java/com/jfontdev/trackstack/model/Track.java @@ -1,76 +1,171 @@ -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.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 the set of tags associated with this track. + * + * @return an unmodifiable view would be ideal, but JPA requires + * a mutable collection for relationship management + */ + public Set getTags() { + return 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..dde35b1 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,292 @@ -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.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. + * + * @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())) + .toList(); + + return new TrackResponseDTO( + track.getId(), + track.getTitle(), + track.getArtist(), + track.getBpm(), + track.getKey(), + track.getDuration(), + tagDTOs); + }) + .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..88f1d8c 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,202 @@ -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.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. + */ + @Override + @CacheEvict(value = "tags", allEntries = true) + @Transactional + public TagResponseDTO updateTag(Long id, TagUpdateRequestDTO dto) { + log.info("Evicting 'tags' cache. 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. + */ + @Override + @CacheEvict(value = "tags", allEntries = true) + @Transactional + public TagResponseDTO patchTag(Long id, TagPatchRequestDTO dto) { + log.info("Evicting 'tags' cache. 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. + */ + @Override + @CacheEvict(value = "tags", allEntries = true) + @Transactional + public void deleteTag(Long id) { + log.info("Evicting 'tags' cache. 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..928f24a 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,288 @@ -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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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} to ensure + * proper rollback on failure. Read operations rely on JPA's default + * transactional behavior. + */ +@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. + */ + @Override + @CacheEvict(value = "tracks", allEntries = true) + @Transactional + public TrackResponseDTO updateTrack(Long id, TrackUpdateRequestDTO dto) { + log.info("Evicting 'tracks' cache. 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. + */ + @Override + @CacheEvict(value = "tracks", allEntries = true) + @Transactional + public TrackResponseDTO patchTrack(Long id, TrackPatchRequestDTO dto) { + log.info("Evicting 'tracks' cache. 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 because + * deleting a track invalidates the list cache. + */ + @Override + @CacheEvict(value = "tracks", allEntries = true) + @Transactional + public void deleteTrack(Long id) { + log.info("Evicting 'tracks' cache. 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. + */ + @Override + @CacheEvict(value = "tracks", allEntries = true) + @Transactional + public TrackResponseDTO addTagToTrack(Long trackId, Long tagId) { + log.info("Evicting 'tracks' cache. 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. + */ + @Override + @CacheEvict(value = "tracks", allEntries = true) + @Transactional + public TrackResponseDTO removeTagFromTrack(Long trackId, Long tagId) { + log.info("Evicting 'tracks' cache. 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. + * + * @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())) + .toList(); + + return new TrackResponseDTO( + track.getId(), + track.getTitle(), + track.getArtist(), + track.getBpm(), + track.getKey(), + track.getDuration(), + tagDTOs); + } +} 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..3fc8dcf 100644 --- a/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java @@ -1,80 +1,298 @@ -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)); + } + + // ==================== 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..642f408 100644 --- a/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java @@ -1,78 +1,234 @@ -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()); + } + + // ==================== 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..ef0d260 100644 --- a/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java @@ -1,83 +1,280 @@ -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)); + } + + // ==================== 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(); + } +} From 64722030761ad49ade857ed720b8001b792f6b5d Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 21:50:46 +0100 Subject: [PATCH 02/19] feat: add JPA properties for Hibernate default batch fetch size, this fixes n+1 on some of the queries --- src/main/resources/application.yml | 5 +++++ 1 file changed, 5 insertions(+) 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 From 0cad66abf83047350ead6d275f9301d1d8351603 Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 22:04:07 +0100 Subject: [PATCH 03/19] feat: add validation annotations to TrackPatchRequestDTO and update patch method in TrackController --- .../trackstack/controller/TrackController.java | 2 +- .../dto/track/TrackPatchRequestDTO.java | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/jfontdev/trackstack/controller/TrackController.java b/src/main/java/com/jfontdev/trackstack/controller/TrackController.java index 2d44253..2732bc8 100644 --- a/src/main/java/com/jfontdev/trackstack/controller/TrackController.java +++ b/src/main/java/com/jfontdev/trackstack/controller/TrackController.java @@ -92,7 +92,7 @@ public TrackResponseDTO update(@PathVariable Long id, @Valid @RequestBody TrackU * @return 200 OK with the updated track, or 404 if not found */ @PatchMapping("/{id}") - public TrackResponseDTO patch(@PathVariable Long id, @RequestBody TrackPatchRequestDTO dto) { + public TrackResponseDTO patch(@PathVariable Long id, @Valid @RequestBody TrackPatchRequestDTO dto) { return trackService.patchTrack(id, dto); } diff --git a/src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java index e6201de..3c0373a 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackPatchRequestDTO.java @@ -1,11 +1,17 @@ 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. + * 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 @@ -13,9 +19,9 @@ * @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(String title, - String artist, - Double bpm, - String key, - String duration) { +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) { } From 1ccde1560ae87c1b7ac6f6e99c469ce08d9ca6ee Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 22:11:48 +0100 Subject: [PATCH 04/19] feat: improve TrackServiceImpl by enhancing tag management and ensuring deterministic API responses --- .../jfontdev/trackstack/service/impl/TrackServiceImpl.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 928f24a..f8a7427 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java @@ -57,7 +57,8 @@ public class TrackServiceImpl implements TrackService { * 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 + * @param tagRepository the repository used to look up tags for relationship + * management */ public TrackServiceImpl(TrackRepository trackRepository, TagRepository tagRepository) { this.trackRepository = trackRepository; @@ -267,6 +268,8 @@ private Tag findTagOrThrow(Long id) { *

* 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 @@ -274,6 +277,7 @@ private Tag findTagOrThrow(Long id) { private TrackResponseDTO mapToResponseDTO(Track track) { List tagDTOs = track.getTags().stream() .map(tag -> new TagResponseDTO(tag.getId(), tag.getName())) + .sorted(java.util.Comparator.comparing(TagResponseDTO::name)) .toList(); return new TrackResponseDTO( From 12efde02c49058d10bf422942fb49627f22c7291 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:13:13 +0000 Subject: [PATCH 05/19] fix: use imported Comparator in TrackServiceImpl mapToResponseDTO Agent-Logs-Url: https://github.com/jfontdev/TrackStack/sessions/6983bc94-1827-4fac-ab42-1d52ee178a62 Co-authored-by: jfontdev <62617184+jfontdev@users.noreply.github.com> --- .../com/jfontdev/trackstack/service/impl/TrackServiceImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 f8a7427..64876db 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java @@ -18,6 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -277,7 +278,7 @@ private Tag findTagOrThrow(Long id) { private TrackResponseDTO mapToResponseDTO(Track track) { List tagDTOs = track.getTags().stream() .map(tag -> new TagResponseDTO(tag.getId(), tag.getName())) - .sorted(java.util.Comparator.comparing(TagResponseDTO::name)) + .sorted(Comparator.comparing(TagResponseDTO::name)) .toList(); return new TrackResponseDTO( From b5b46d5786b767098b74665c68faf81ad1133d60 Mon Sep 17 00:00:00 2001 From: Jordi Font Vitores <62617184+jfontdev@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:17:10 +0100 Subject: [PATCH 06/19] Update src/main/java/com/jfontdev/trackstack/controller/TagController.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/jfontdev/trackstack/controller/TagController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jfontdev/trackstack/controller/TagController.java b/src/main/java/com/jfontdev/trackstack/controller/TagController.java index 9c17a3f..db2e06a 100644 --- a/src/main/java/com/jfontdev/trackstack/controller/TagController.java +++ b/src/main/java/com/jfontdev/trackstack/controller/TagController.java @@ -88,11 +88,11 @@ public TagResponseDTO update(@PathVariable Long id, @Valid @RequestBody TagUpdat * Only non-null fields in the request body are applied to the existing tag. * * @param id the tag's unique identifier - * @param dto the request body containing the fields to update + * @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, @RequestBody TagPatchRequestDTO dto) { + public TagResponseDTO patch(@PathVariable Long id, @Valid @RequestBody TagPatchRequestDTO dto) { return tagService.patchTag(id, dto); } From 354b69f71f22027d77334c197190c21c15d8251a Mon Sep 17 00:00:00 2001 From: Jordi Font Vitores <62617184+jfontdev@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:17:25 +0100 Subject: [PATCH 07/19] Update src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../jfontdev/trackstack/controller/PlaylistController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java b/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java index ecaac95..02e744e 100644 --- a/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java +++ b/src/main/java/com/jfontdev/trackstack/controller/PlaylistController.java @@ -88,11 +88,11 @@ public PlaylistResponseDTO update(@PathVariable Long id, @Valid @RequestBody Pla * Only non-null fields in the request body are applied to the existing playlist. * * @param id the playlist's unique identifier - * @param dto the request body containing the fields to update + * @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, @RequestBody PlaylistPatchRequestDTO dto) { + public PlaylistResponseDTO patch(@PathVariable Long id, @Valid @RequestBody PlaylistPatchRequestDTO dto) { return playlistService.patchPlaylist(id, dto); } From 74746ba6d986fdc71e77caa0dce13badd3caad97 Mon Sep 17 00:00:00 2001 From: Jordi Font Vitores <62617184+jfontdev@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:18:24 +0100 Subject: [PATCH 08/19] Update src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../trackstack/service/impl/TrackServiceImpl.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 64876db..97d4573 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java @@ -39,9 +39,11 @@ * return stale data. *

* Transaction Strategy: - * All write operations are annotated with {@code @Transactional} to ensure - * proper rollback on failure. Read operations rely on JPA's default - * transactional behavior. + * 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 { From 40ee49516a95f498583f301eea80de5699d8a209 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:27:58 +0000 Subject: [PATCH 09/19] fix: add @Size validations to TagPatchRequestDTO and PlaylistPatchRequestDTO; sort tracks/tags in PlaylistServiceImpl Agent-Logs-Url: https://github.com/jfontdev/TrackStack/sessions/f2297d8a-ece4-405a-be99-16af291f6da6 Co-authored-by: jfontdev <62617184+jfontdev@users.noreply.github.com> --- mvnw | 0 .../trackstack/dto/playlist/PlaylistPatchRequestDTO.java | 7 +++++-- .../jfontdev/trackstack/dto/tag/TagPatchRequestDTO.java | 7 +++++-- .../trackstack/service/impl/PlaylistServiceImpl.java | 7 ++++++- 4 files changed, 16 insertions(+), 5 deletions(-) mode change 100644 => 100755 mvnw diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java index b50b98e..999b89a 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java @@ -1,17 +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. + * 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( - String name, + @Size(min = 1, message = "Name must not be empty if provided") String name, 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 index f9c3646..f79f2d0 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/tag/TagPatchRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/tag/TagPatchRequestDTO.java @@ -1,15 +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. + * 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( - String name + @Size(min = 1, message = "Name must not be empty if provided") String name ) { } 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 dde35b1..915f23f 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/PlaylistServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/PlaylistServiceImpl.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -260,7 +261,9 @@ private Track findTrackOrThrow(Long id) { *

* 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, 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 @@ -270,6 +273,7 @@ private PlaylistResponseDTO mapToResponseDTO(Playlist playlist) { .map(track -> { List tagDTOs = track.getTags().stream() .map(tag -> new TagResponseDTO(tag.getId(), tag.getName())) + .sorted(Comparator.comparing(TagResponseDTO::name)) .toList(); return new TrackResponseDTO( @@ -281,6 +285,7 @@ private PlaylistResponseDTO mapToResponseDTO(Playlist playlist) { track.getDuration(), tagDTOs); }) + .sorted(Comparator.comparing(TrackResponseDTO::title)) .toList(); return new PlaylistResponseDTO( From 3875140ae013f8142b16b6b907923f22eec1daf6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:39:07 +0000 Subject: [PATCH 10/19] Initial plan From 7f69b40c1c6d4c46306337e02a8fd2ad9843c81e Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 22:54:12 +0100 Subject: [PATCH 11/19] feat: enhance cache eviction in deleteTrack method to include playlists --- .../trackstack/service/impl/TrackServiceImpl.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 97d4573..ea99cf6 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java @@ -15,6 +15,7 @@ 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; @@ -174,14 +175,19 @@ public TrackResponseDTO patchTrack(Long id, TrackPatchRequestDTO dto) { /** * {@inheritDoc} *

- * Cache Eviction: Evicts all entries in the "tracks" cache because - * deleting a track invalidates the list cache. + * 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 - @CacheEvict(value = "tracks", allEntries = true) + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) @Transactional public void deleteTrack(Long id) { - log.info("Evicting 'tracks' cache. Deleting track with id: {}", id); + log.info("Evicting 'tracks' and 'playlists' caches. Deleting track with id: {}", id); Track track = findTrackOrThrow(id); trackRepository.delete(track); From 6e93dae0add349d1b64ffeb9bf81dbb060d8ad8e Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 22:58:11 +0100 Subject: [PATCH 12/19] feat: update cache eviction strategy in TagServiceImpl and TrackServiceImpl to include playlists --- .../service/impl/TagServiceImpl.java | 34 ++++++++++---- .../service/impl/TrackServiceImpl.java | 44 ++++++++++++++----- 2 files changed, 57 insertions(+), 21 deletions(-) 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 88f1d8c..5075906 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/TagServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/TagServiceImpl.java @@ -12,6 +12,7 @@ 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; @@ -111,13 +112,18 @@ public List getAllTags() { * {@inheritDoc} *

* Cache Eviction: Evicts all entries in the "tags" cache because - * updating a tag invalidates both the individual entry and the list. + * 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 - @CacheEvict(value = "tags", allEntries = true) + @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' cache. Updating tag with id: {}", id); + log.info("Evicting 'tags', 'tracks', and 'playlists' caches. Updating tag with id: {}", id); Tag tag = findTagOrThrow(id); tag.update(dto.name()); @@ -132,13 +138,18 @@ public TagResponseDTO updateTag(Long id, TagUpdateRequestDTO dto) { * 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. + * Cache Eviction: Evicts all entries in the "tags" cache. It also + * evicts "tracks" and "playlists" caches because they both include tag names. */ @Override - @CacheEvict(value = "tags", allEntries = true) + @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' cache. Patching tag with id: {}", id); + 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(); @@ -153,13 +164,18 @@ public TagResponseDTO patchTag(Long id, TagPatchRequestDTO dto) { * {@inheritDoc} *

* Cache Eviction: Evicts all entries in the "tags" cache because - * deleting a tag invalidates the list cache. + * deleting a tag invalidates the list cache. It also evicts "tracks" + * and "playlists" caches because they both include tag names. */ @Override - @CacheEvict(value = "tags", allEntries = true) + @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' cache. Deleting tag with id: {}", id); + log.info("Evicting 'tags', 'tracks', and 'playlists' caches. Deleting tag with id: {}", id); Tag tag = findTagOrThrow(id); tagRepository.delete(tag); 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 ea99cf6..e03a82e 100644 --- a/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java +++ b/src/main/java/com/jfontdev/trackstack/service/impl/TrackServiceImpl.java @@ -130,13 +130,18 @@ public List getAllTracks() { * {@inheritDoc} *

* Cache Eviction: Evicts all entries in the "tracks" cache because - * updating a track invalidates both the individual entry and the list. + * updating a track invalidates both the individual entry and the list. It also + * evicts the "playlists" cache because tracked changes affect playlist + * responses. */ @Override - @CacheEvict(value = "tracks", allEntries = true) + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) @Transactional public TrackResponseDTO updateTrack(Long id, TrackUpdateRequestDTO dto) { - log.info("Evicting 'tracks' cache. Updating track with id: {}", id); + 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()); @@ -151,13 +156,18 @@ public TrackResponseDTO updateTrack(Long id, TrackUpdateRequestDTO dto) { * 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. + * Cache Eviction: Evicts all entries in the "tracks" cache. It also + * evicts the "playlists" cache because tracked changes affect playlist + * responses. */ @Override - @CacheEvict(value = "tracks", allEntries = true) + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) @Transactional public TrackResponseDTO patchTrack(Long id, TrackPatchRequestDTO dto) { - log.info("Evicting 'tracks' cache. Patching track with id: {}", id); + 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(); @@ -197,13 +207,18 @@ public void deleteTrack(Long id) { * {@inheritDoc} *

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

* Cache Eviction: Evicts all entries in the "tracks" cache because - * changing a track's tags invalidates cached track representations. + * changing a track's tags invalidates cached track representations. It also + * evicts the "playlists" cache because playlists include track tags in their + * representation. */ @Override - @CacheEvict(value = "tracks", allEntries = true) + @Caching(evict = { + @CacheEvict(value = "tracks", allEntries = true), + @CacheEvict(value = "playlists", allEntries = true) + }) @Transactional public TrackResponseDTO removeTagFromTrack(Long trackId, Long tagId) { - log.info("Evicting 'tracks' cache. Removing tag {} from track {}", tagId, trackId); + log.info("Evicting 'tracks' and 'playlists' caches. Removing tag {} from track {}", tagId, trackId); Track track = findTrackOrThrow(trackId); Tag tag = findTagOrThrow(tagId); From 8ea21aed20e88d14ccbbdce0bff1336940fa8c05 Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 23:15:21 +0100 Subject: [PATCH 13/19] feat: add validation constraints to TrackRequestDTO and TrackUpdateRequestDTO for improved data integrity --- .../trackstack/dto/track/TrackRequestDTO.java | 12 +++++++----- .../trackstack/dto/track/TrackUpdateRequestDTO.java | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) 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..64443e1 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+:\\d{2}$", message = "Duration must be in mm:ss format") String duration) { } diff --git a/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java index 2b11ab5..420050c 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java @@ -1,6 +1,8 @@ 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. @@ -15,9 +17,9 @@ * @param key the musical key (optional) * @param duration the track duration (required) */ -public record TrackUpdateRequestDTO(@NotBlank String title, - @NotBlank String artist, - Double bpm, - String key, - @NotBlank String duration) { +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+:\\d{2}$", message = "Duration must be in mm:ss format") String duration) { } From b85d9038c8e13b6b86dd195af5d358e8a67983f5 Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 23:23:07 +0100 Subject: [PATCH 14/19] feat: add validation constraints to Playlist and Tag DTOs for improved data integrity --- .../trackstack/dto/playlist/PlaylistPatchRequestDTO.java | 8 ++++---- .../trackstack/dto/playlist/PlaylistRequestDTO.java | 6 +++--- .../trackstack/dto/playlist/PlaylistUpdateRequestDTO.java | 6 +++--- .../com/jfontdev/trackstack/dto/tag/TagRequestDTO.java | 3 +-- .../jfontdev/trackstack/dto/tag/TagUpdateRequestDTO.java | 3 +-- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java index 999b89a..eb94dd6 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistPatchRequestDTO.java @@ -11,10 +11,10 @@ * 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 + * @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, - String description -) { + @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/PlaylistUpdateRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistUpdateRequestDTO.java index 07430f3..a0e4de2 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistUpdateRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/playlist/PlaylistUpdateRequestDTO.java @@ -1,6 +1,7 @@ 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. @@ -13,7 +14,6 @@ * @param description the new playlist description (optional) */ public record PlaylistUpdateRequestDTO( - @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) { } 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 index 0e3f9de..da04f56 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/tag/TagUpdateRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/tag/TagUpdateRequestDTO.java @@ -11,6 +11,5 @@ * @param name the new tag name (required, must be unique) */ public record TagUpdateRequestDTO( - @NotBlank String name -) { + @NotBlank(message = "Name must not be empty") String name) { } From 7355c045ea031d92deed9beb6b773435639a3223 Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 23:23:14 +0100 Subject: [PATCH 15/19] feat: add validation tests for Playlist and Tag APIs to ensure data integrity --- .../PlaylistControllerIntegrationTest.java | 82 +++ .../TagControllerIntegrationTest.java | 516 ++++++++++-------- .../TrackControllerIntegrationTest.java | 93 ++++ 3 files changed, 476 insertions(+), 215 deletions(-) diff --git a/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java b/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java index 3fc8dcf..faf0110 100644 --- a/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/PlaylistControllerIntegrationTest.java @@ -255,6 +255,88 @@ void deletingTrackRemovesItFromPlaylist() { .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 ==================== /** diff --git a/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java b/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java index 642f408..317c8b0 100644 --- a/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/TagControllerIntegrationTest.java @@ -11,224 +11,310 @@ /** * Integration tests for the Tag API. * - *

These tests validate end-to-end behavior across HTTP, service logic, + *

+ * 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.

+ * 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()); - } - - // ==================== 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(); - } + /** + * 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/TrackControllerIntegrationTest.java b/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java index ef0d260..8290c5d 100644 --- a/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java +++ b/src/test/java/com/jfontdev/trackstack/TrackControllerIntegrationTest.java @@ -234,7 +234,100 @@ void addAndRemoveTagFromTrack() { .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 ==================== /** From 5dae0b09e6394cfb7a7a697d16db18d064cba71b Mon Sep 17 00:00:00 2001 From: jfontdev Date: Sat, 28 Mar 2026 23:33:19 +0100 Subject: [PATCH 16/19] feat: return unmodifiable views of tracks and tags in Playlist and Track entities for better encapsulation --- .../java/com/jfontdev/trackstack/model/Playlist.java | 10 +++++++--- .../java/com/jfontdev/trackstack/model/Track.java | 11 +++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/jfontdev/trackstack/model/Playlist.java b/src/main/java/com/jfontdev/trackstack/model/Playlist.java index 41234d6..5425a94 100644 --- a/src/main/java/com/jfontdev/trackstack/model/Playlist.java +++ b/src/main/java/com/jfontdev/trackstack/model/Playlist.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; +import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -114,11 +115,14 @@ public String getDescription() { } /** - * Returns the set of tracks in this playlist. + * 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 the tracks belonging to this playlist + * @return an unmodifiable set of tracks belonging to this playlist */ public Set getTracks() { - return tracks; + return Collections.unmodifiableSet(tracks); } } diff --git a/src/main/java/com/jfontdev/trackstack/model/Track.java b/src/main/java/com/jfontdev/trackstack/model/Track.java index e6f93d8..fb873dd 100644 --- a/src/main/java/com/jfontdev/trackstack/model/Track.java +++ b/src/main/java/com/jfontdev/trackstack/model/Track.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; +import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -160,12 +161,14 @@ public String getDuration() { } /** - * Returns the set of tags associated with this track. + * 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 view would be ideal, but JPA requires - * a mutable collection for relationship management + * @return an unmodifiable set of tags associated with this track */ public Set getTags() { - return tags; + return Collections.unmodifiableSet(tags); } } From 687151f40c0feca1fb67054e730fb2f869812617 Mon Sep 17 00:00:00 2001 From: Jordi Font Vitores <62617184+jfontdev@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:39:35 +0100 Subject: [PATCH 17/19] Update src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../jfontdev/trackstack/exception/GlobalExceptionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java b/src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java index 636e586..73313a8 100644 --- a/src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/jfontdev/trackstack/exception/GlobalExceptionHandler.java @@ -47,7 +47,7 @@ public Map handleNotFound(NotFoundException ex) { @ResponseStatus(HttpStatus.BAD_REQUEST) public Map handleValidation(MethodArgumentNotValidException ex) { Map errors = new HashMap<>(); - ex.getBindingResult().getFieldErrors().forEach(field -> errors.put(field.getField(), field.getDefaultMessage())); + ex.getBindingResult().getFieldErrors().forEach(field -> errors.putIfAbsent(field.getField(), field.getDefaultMessage())); return Map.of("errors", errors); } From fdcd02325ad355b4101bf62c42d8ee3cdd0443db Mon Sep 17 00:00:00 2001 From: Jordi Font Vitores <62617184+jfontdev@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:39:47 +0100 Subject: [PATCH 18/19] Update src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java b/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java index 420050c..f1ea755 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackUpdateRequestDTO.java @@ -21,5 +21,5 @@ public record TrackUpdateRequestDTO(@NotBlank(message = "Title must not be empty @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+:\\d{2}$", message = "Duration must be in mm:ss format") String duration) { + @NotBlank(message = "Duration must not be empty") @Pattern(regexp = "^\\d+:[0-5]\\d$", message = "Duration must be in mm:ss format") String duration) { } From d8d3660bccb0cdde468727092f222adb6422c73c Mon Sep 17 00:00:00 2001 From: Jordi Font Vitores <62617184+jfontdev@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:39:56 +0100 Subject: [PATCH 19/19] Update src/main/java/com/jfontdev/trackstack/dto/track/TrackRequestDTO.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/com/jfontdev/trackstack/dto/track/TrackRequestDTO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 64443e1..1d23ef2 100644 --- a/src/main/java/com/jfontdev/trackstack/dto/track/TrackRequestDTO.java +++ b/src/main/java/com/jfontdev/trackstack/dto/track/TrackRequestDTO.java @@ -8,5 +8,5 @@ public record TrackRequestDTO(@NotBlank(message = "Title must not be empty") Str @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+:\\d{2}$", message = "Duration must be in mm:ss format") String duration) { + @NotBlank(message = "Duration must not be empty") @Pattern(regexp = "^\\d+:[0-5]\\d$", message = "Duration must be in mm:ss format") String duration) { }