diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index 243b8d7..9f4d80b 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -64,7 +64,7 @@ class FilmRepository( override fun findByTitle(title: String): Film? { val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ?", + "SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1", filmRowMapper, title, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 6c70b4c..686899f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -86,7 +86,8 @@ class UserRepository( val entities = jdbc.query( """ - SELECT id, name, email, provider, provider_id, created_at FROM users + SELECT id, name, email, provider, provider_id, created_at + FROM users WHERE provider = ? AND provider_id = ? """.trimIndent(), userEntityRowMapper, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index c2d1eb2..3699e33 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -12,6 +12,7 @@ import com.project.movienight.application.ports.input.GetAllFilmsUseCase import com.project.movienight.application.ports.input.GetFilmByIdUseCase import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -75,11 +76,18 @@ class FilmController( @PathVariable id: UUID, ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + @GetMapping + fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + @GetMapping("/search") fun searchByTitle( @RequestParam title: String, - ): FilmResponse? = searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } - - @GetMapping - fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + ): ResponseEntity { + val film = searchFilmByTitleUseCase.searchByTitle(title) + return if (film != null) { + ResponseEntity.ok(FilmResponse.fromDomain(film)) + } else { + ResponseEntity.notFound().build() + } + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index b6731a9..998a330 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -13,6 +13,7 @@ import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +import com.project.movienight.domain.exception.EntityNotFoundException import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -67,9 +68,7 @@ class FilmLibraryController( getFilmLibraryUseCase.getLibrary( GetFilmLibraryQuery(userId = userId), ) - val film = getFilmByIdUseCase.getById(library.filmId) - return listOf(FilmResponse.fromDomain(film)) } @@ -107,13 +106,24 @@ class FilmLibraryController( @PathVariable userId: UUID, ): List { val userLibrary = - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) + runCatching { + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + }.onFailure { exception -> + if (exception !is EntityNotFoundException) { + throw exception + } + }.getOrNull() val allFilms = getAllFilmsUseCase.getAll() - val availableFilms = allFilms.filter { it.id != userLibrary.filmId } + val availableFilms = + if (userLibrary != null) { + allFilms.filter { it.id != userLibrary.filmId } + } else { + allFilms + } return availableFilms.map { FilmResponse.fromDomain(it) } } diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt index dd6bd33..033cccd 100644 --- a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -59,7 +59,7 @@ class FilmControllerSearchTest { } @Test - fun `search returns empty body when title is missing`() { + fun `search returns 404 when title is not found`() { val title = "Unknown Title" every { searchFilmByTitleUseCase.searchByTitle(title) } returns null @@ -68,7 +68,7 @@ class FilmControllerSearchTest { .get("/api/films/search") { param("title", title) }.andExpect { - status { isOk() } + status { isNotFound() } content { string("") } } diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt new file mode 100644 index 0000000..18a952a --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -0,0 +1,140 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.EditFilmRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc +class FilmControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `create film should return 201 CREATED`() { + val request = + CreateFilmRequest( + title = "The Matrix", + description = "A computer hacker learns about the true nature of reality", + ) + + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.title").value("The Matrix")) + .andExpect(jsonPath("$.description").value("A computer hacker learns about the true nature of reality")) + .andExpect(jsonPath("$.id").exists()) + } + + @Test + fun `edit film should return updated film`() { + val createRequest = + CreateFilmRequest( + title = "Old Title", + description = "Old Description", + ) + + val response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)), + ).andReturn() + + val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + val editRequest = + EditFilmRequest( + title = "New Title", + description = "New Description", + ) + + mockMvc + .perform( + patch("/api/films/$filmId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("New Title")) + .andExpect(jsonPath("$.description").value("New Description")) + } + + @Test + fun `search film by title should return film`() { + val request = + CreateFilmRequest( + title = "Inception", + description = "Dream within a dream", + ) + + mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + mockMvc + .perform( + get("/api/films/search") + .param("title", "Inception"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("Inception")) + .andExpect(jsonPath("$.description").value("Dream within a dream")) + } + + @Test + fun `search film by non-existent title should return 404`() { + mockMvc + .perform( + get("/api/films/search") + .param("title", "NonExistentFilm12345"), + ).andExpect(status().isNotFound) + } + + @Test + fun `delete film should return 204 NO CONTENT`() { + val request = + CreateFilmRequest( + title = "Film To Delete", + description = "This film will be deleted", + ) + + val response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andReturn() + + val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + mockMvc + .perform(delete("/api/films/$filmId")) + .andExpect(status().isNoContent()) + + mockMvc + .perform( + get("/api/films/search").param("title", "Film To Delete"), + ).andExpect(status().isNotFound) + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt new file mode 100644 index 0000000..560cf38 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -0,0 +1,204 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class FilmLibraryControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `add film to library should work`() { + val userRequest = + CreateUserRequest( + name = "Film Adder", + email = "adder@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Library Film", + description = "Film description", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + mockMvc + .perform( + post("/api/users/$userId/library/films/$filmId"), + ).andExpect(status().isCreated()) + } + + @Test + fun `remove film from library should return 204`() { + val userRequest = + CreateUserRequest( + name = "Remove Film", + email = "remove@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Film To Remove", + description = "Will be removed", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + mockMvc.perform(post("/api/users/$userId/library/films/$filmId")) + mockMvc + .perform(delete("/api/users/$userId/library/films/$filmId")) + .andExpect(status().isNoContent()) + } + + @Test + fun `get available films should exclude film in user's library`() { + val userRequest = + CreateUserRequest( + name = "Available Films User", + email = "availablefilms@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val film1Request = + CreateFilmRequest( + title = "Film In Library", + description = "This will be in the library", + ) + val film1Response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(film1Request)), + ).andReturn() + val film1Id = objectMapper.readTree(film1Response.response.contentAsString).get("id").asText() + + val film2Request = + CreateFilmRequest( + title = "Film Not In Library", + description = "This will not be in the library", + ) + val film2Response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(film2Request)), + ).andReturn() + val film2Id = objectMapper.readTree(film2Response.response.contentAsString).get("id").asText() + + mockMvc.perform(post("/api/users/$userId/library/films/$film1Id")) + + val result = + mockMvc + .perform( + get("/api/users/$userId/library/available-films"), + ).andExpect(status().isOk) + .andReturn() + + val responseBody = result.response.contentAsString + val films = objectMapper.readTree(responseBody) + val returnedIds = films.toList().map { it.get("id").asText() } + assert(!returnedIds.contains(film1Id)) { "Film in library should not appear in available films" } + assert(returnedIds.contains(film2Id)) { "Film not in library should appear in available films" } + } + + @Test + fun `get available films for user without library returns all films`() { + val userRequest = + CreateUserRequest( + name = "No Library User", + email = "nolibrary@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Available Film", + description = "Should appear in available films", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + val result = + mockMvc + .perform( + get("/api/users/$userId/library/available-films"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[*].id").isArray) + .andReturn() + + val responseBody = result.response.contentAsString + val films = objectMapper.readTree(responseBody) + val returnedIds = films.toList().map { it.get("id").asText() } + assert(returnedIds.contains(filmId)) { "Film should appear in available films when user has no library" } + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt new file mode 100644 index 0000000..7a2dca3 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -0,0 +1,108 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import com.project.movienight.adapters.web.dto.request.EditUserRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class UserControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `create user should return 201 CREATED`() { + val request = + CreateUserRequest( + name = "John Doe", + email = "john@example.com", + ) + + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.name").value("John Doe")) + .andExpect(jsonPath("$.email").value("john@example.com")) + .andExpect(jsonPath("$.id").exists()) + } + + @Test + fun `edit user should return updated user`() { + val createRequest = + CreateUserRequest( + name = "Old Name", + email = "edit@example.com", + ) + + val response = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)), + ).andReturn() + + val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + val editRequest = EditUserRequest(name = "New Name") + + mockMvc + .perform( + patch("/api/users/$userId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value("New Name")) + .andExpect(jsonPath("$.email").value("edit@example.com")) + } + + @Test + fun `delete user should return 204 NO CONTENT`() { + val request = + CreateUserRequest( + name = "User To Delete", + email = "delete@example.com", + ) + + val response = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andReturn() + + val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + mockMvc + .perform(delete("/api/users/$userId")) + .andExpect(status().isNoContent()) + } + + @Test + fun `delete non-existent user should return 404`() { + val nonExistentId = "123e4567-e89b-12d3-a456-426614174000" + mockMvc + .perform(delete("/api/users/$nonExistentId")) + .andExpect(status().isNotFound()) + } +}