From d69419b90dfc64ed8c5fda36b8bd2637609dd305 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 14:03:34 +0300 Subject: [PATCH 01/77] =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=B2=20=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=20/=20=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B1=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/jdbc/FilmRepository.kt | 9 +++++++ .../movienight/adapters/web/FilmController.kt | 17 ++++++------ .../adapters/web/FilmLibraryController.kt | 27 +++++++++++++------ .../ports/output/FilmRepositoryPort.kt | 2 ++ .../application/services/FilmService.kt | 4 +++ 5 files changed, 43 insertions(+), 16 deletions(-) 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 2883aca..a13f721 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 @@ -61,6 +61,15 @@ class FilmRepository( filmRowMapper, ) + override fun findByTitle(title: String): Film? { + val films = jdbc.query( + "SELECT id, title, description FROM films WHERE title = ?", + filmRowMapper, + title + ) + return films.firstOrNull() + } + override fun deleteById(id: UUID) { jdbc.update("DELETE FROM films WHERE id = ?", id) } 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 d4d52ef..35f963e 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -8,15 +8,9 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase +import com.project.movienight.application.services.FilmService import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -25,6 +19,7 @@ class FilmController( private val createFilmUseCase: CreateFilmUseCase, private val editFilmUseCase: EditFilmUseCase, private val deleteFilmUseCase: DeleteFilmUseCase, + private val filmService: FilmService, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -61,4 +56,10 @@ class FilmController( fun delete( @PathVariable id: UUID, ) = deleteFilmUseCase.delete(id) + + @GetMapping("/search") + fun searchByTitle( + @RequestParam title: String, + ): FilmResponse? = + filmService.findByTitle(title)?.let { FilmResponse.fromDomain(it) } } 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 59022b0..446e57a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -2,6 +2,7 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse +import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryCommand @@ -10,15 +11,9 @@ 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.application.services.FilmService import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -28,6 +23,7 @@ class FilmLibraryController( private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, + private val filmService: FilmService, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -80,4 +76,19 @@ class FilmLibraryController( filmId = filmId, ), ) + + @GetMapping("/available-films") + fun getAvailableFilms( + @PathVariable userId: UUID, + ): List { + val userLibrary = getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId) + ) + + val allFilms = filmService.findAll() + + val availableFilms = allFilms.filter { it.id != userLibrary.filmId } + + return availableFilms.map { FilmResponse.fromDomain(it) } + } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index 91d45b6..c0d3938 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -10,5 +10,7 @@ interface FilmRepositoryPort { fun findAll(): List + fun findByTitle(title: String): Film? + fun deleteById(id: UUID) } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 4166760..9410c3f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -62,4 +62,8 @@ class FilmService( filmRepository.deleteById(id) } + + fun findByTitle(title: String): Film? = filmRepository.findByTitle(title) + + fun findAll(): List = filmRepository.findAll() } -- 2.54.0 From b7b9061ba6e8181b17248db5a1292b31e58bc8bc Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 15:46:59 +0300 Subject: [PATCH 02/77] =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/FilmControllerTest.kt | 131 ++++++++++++++++++ .../controllers/FilmLibraryControllerTest.kt | 74 ++++++++++ .../controllers/UserControllerTest.kt | 98 +++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt create mode 100644 src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt create mode 100644 src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt 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..c28b5f5 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -0,0 +1,131 @@ +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.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* + +@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 empty`() { + mockMvc.perform( + get("/api/films/search") + .param("title", "NonExistentFilm12345") + ) + .andExpect(status().isOk) + .andExpect(content().string("")) + } + + + @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().isOk) + .andExpect(content().string("")) + } +} 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..39b795d --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -0,0 +1,74 @@ +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.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +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("Film Adder", "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("Library Film", "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("Remove Film", "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("Film To Remove", "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()) + } +} 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..df266fe --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -0,0 +1,98 @@ +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.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +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()) + } +} -- 2.54.0 From 88837d2e185ad0771f16839b3e9b80bb0a242cdd Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 16:06:44 +0300 Subject: [PATCH 03/77] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20get=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../movienight/adapters/web/FilmController.kt | 9 +++++++ .../adapters/web/FilmLibraryController.kt | 13 ++++++++++ .../movienight/adapters/web/UserController.kt | 24 ++++++++++++------- .../application/services/FilmService.kt | 2 ++ .../application/services/UserService.kt | 4 ++++ 5 files changed, 44 insertions(+), 8 deletions(-) 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 35f963e..045adf6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -9,6 +9,7 @@ import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase import com.project.movienight.application.services.FilmService +import com.project.movienight.domain.exception.EntityNotFoundException import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* import java.util.UUID @@ -57,6 +58,14 @@ class FilmController( @PathVariable id: UUID, ) = deleteFilmUseCase.delete(id) + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): FilmResponse = + FilmResponse.fromDomain( + filmService.findById(id) ?: throw EntityNotFoundException("Film", id.toString()) + ) + @GetMapping("/search") fun searchByTitle( @RequestParam title: String, 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 446e57a..0989dff 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -50,6 +50,19 @@ class FilmLibraryController( ), ) + @GetMapping("/films") + fun getAllFilmsInLibrary( + @PathVariable userId: UUID, + ): List { + val library = getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId) + ) + + val film = filmService.findById(library.filmId) + + return film?.let { listOf(FilmResponse.fromDomain(it)) } ?: emptyList() + } + @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun addFilm( diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index f270736..5bdbc58 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -8,15 +8,10 @@ import com.project.movienight.application.ports.input.CreateUserUseCase import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand import com.project.movienight.application.ports.input.EditUserUseCase +import com.project.movienight.application.services.UserService +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.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -25,6 +20,7 @@ class UserController( private val createUserUseCase: CreateUserUseCase, private val editUserUseCase: EditUserUseCase, private val deleteUserUseCase: DeleteUserUseCase, + private val userService: UserService, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -40,6 +36,18 @@ class UserController( ), ) + @GetMapping + fun getAll(): List = + userService.findAll().map { UserResponse.fromDomain(it) } + + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): UserResponse = + UserResponse.fromDomain( + userService.findById(id) ?: throw EntityNotFoundException("User", id.toString()) + ) + @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 9410c3f..6ed4748 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -66,4 +66,6 @@ class FilmService( fun findByTitle(title: String): Film? = filmRepository.findByTitle(title) fun findAll(): List = filmRepository.findAll() + + fun findById(id: UUID): Film? = filmRepository.findById(id) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index be32d6a..71fb6ca 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -57,4 +57,8 @@ class UserService( userRepository.deleteById(id) } + + fun findAll(): List = userRepository.findAll() + + fun findById(id: UUID): User? = userRepository.findById(id) } -- 2.54.0 From a8076594be559af6cf114f2952cffeb99c10c2dd Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 22:36:27 +0300 Subject: [PATCH 04/77] =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- buildPipeline.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 buildPipeline.yml diff --git a/buildPipeline.yml b/buildPipeline.yml new file mode 100644 index 0000000..031ce85 --- /dev/null +++ b/buildPipeline.yml @@ -0,0 +1,27 @@ +name: CI Build + +on: + pull_request: + branches: [ develop, main ] + push: + branches: [ develop ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build project + run: ./gradlew build -x test -- 2.54.0 From 910a3826794ba864cdafa9ff4c7ad1a85f638930 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 22:39:29 +0300 Subject: [PATCH 05/77] =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- buildPipeline.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/buildPipeline.yml b/buildPipeline.yml index 031ce85..da17798 100644 --- a/buildPipeline.yml +++ b/buildPipeline.yml @@ -25,3 +25,4 @@ jobs: - name: Build project run: ./gradlew build -x test + -- 2.54.0 From 49f74d1898b1da8aee3e0deebcdad9b04d2c6628 Mon Sep 17 00:00:00 2001 From: skettiks Date: Mon, 20 Apr 2026 19:45:55 +0300 Subject: [PATCH 06/77] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20User,=20Film,=20FilmLibrary=20-=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B5=D0=B2=20application-test.yaml:=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FilmLibraryRepositoryIntegrationTest.kt | 219 ++++++++++++++++++ .../jdbc/FilmRepositoryIntegrationTest.kt | 152 ++++++++++++ .../jdbc/UserRepositoryIntegrationTest.kt | 159 +++++++++++++ src/test/resources/application-test.yaml | 28 +++ 4 files changed, 558 insertions(+) create mode 100644 src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt create mode 100644 src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt create mode 100644 src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt create mode 100644 src/test/resources/application-test.yaml diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt new file mode 100644 index 0000000..232216a --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt @@ -0,0 +1,219 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class FilmLibraryRepositoryIntegrationTest { + @Autowired + private lateinit var filmLibraryRepository: FilmLibraryRepository + + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var filmRepository: FilmRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + private lateinit var testUser: User + private lateinit var testFilm: Film + + @BeforeEach + fun setup() { + cleanDatabase() + createTestData() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + private fun createTestData() { + testUser = User(UUID.randomUUID(), "Иван Иванов", "ivan@example.com", null) + testFilm = Film(UUID.randomUUID(), "Начало", "Захватывающий триллер") + userRepository.save(testUser) + filmRepository.save(testFilm) + } + + @Test + fun `should save new film library entry and return saved entry`() { + val entry = FilmLibrary( + id = UUID.randomUUID(), + userId = testUser.id, + filmId = testFilm.id, + comment = "Отличный фильм!", + isViewed = false, + ) + + val savedEntry = filmLibraryRepository.save(entry) + + assertNotNull(savedEntry) + assertEquals(entry.id, savedEntry.id) + assertEquals(entry.userId, savedEntry.userId) + assertEquals(entry.filmId, savedEntry.filmId) + assertEquals(entry.comment, savedEntry.comment) + assertEquals(entry.isViewed, savedEntry.isViewed) + } + + @Test + fun `should update existing film library entry`() { + val entryId = UUID.randomUUID() + val originalEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false) + filmLibraryRepository.save(originalEntry) + + val updatedEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true) + val result = filmLibraryRepository.save(updatedEntry) + + assertEquals(entryId, result.id) + assertEquals("Уже посмотрел, потрясающе!", result.comment) + assertTrue(result.isViewed) + + val foundEntry = filmLibraryRepository.findById(entryId) + assertNotNull(foundEntry) + assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment) + assertTrue(foundEntry?.isViewed ?: false) + } + + @Test + fun `should find film library entry by id`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false) + filmLibraryRepository.save(entry) + + val foundEntry = filmLibraryRepository.findById(entry.id) + + assertNotNull(foundEntry) + assertEquals(entry.id, foundEntry?.id) + assertEquals(entry.userId, foundEntry?.userId) + assertEquals(entry.filmId, foundEntry?.filmId) + assertEquals(entry.comment, foundEntry?.comment) + assertEquals(entry.isViewed, foundEntry?.isViewed) + } + + @Test + fun `should return null when film library entry not found by id`() { + val nonExistentId = UUID.randomUUID() + + val foundEntry = filmLibraryRepository.findById(nonExistentId) + + assertNull(foundEntry) + } + + @Test + fun `should find all film library entries`() { + val entry1 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false) + val entry2 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true) + val entry3 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + + filmLibraryRepository.save(entry1) + filmLibraryRepository.save(entry2) + filmLibraryRepository.save(entry3) + + val allEntries = filmLibraryRepository.findAll() + + assertEquals(3, allEntries.size) + assertTrue(allEntries.any { it.id == entry1.id }) + assertTrue(allEntries.any { it.id == entry2.id }) + assertTrue(allEntries.any { it.id == entry3.id }) + } + + @Test + fun `should return empty list when no film library entries exist`() { + val allEntries = filmLibraryRepository.findAll() + + assertTrue(allEntries.isEmpty()) + } + + @Test + fun `should delete film library entry by id`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false) + filmLibraryRepository.save(entry) + + filmLibraryRepository.deleteById(entry.id) + + val foundEntry = filmLibraryRepository.findById(entry.id) + assertNull(foundEntry) + } + + @Test + fun `should not throw exception when deleting non-existent entry`() { + val nonExistentId = UUID.randomUUID() + + filmLibraryRepository.deleteById(nonExistentId) + } + + @Test + fun `should save entry with null comment`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + + val savedEntry = filmLibraryRepository.save(entry) + + assertNotNull(savedEntry) + assertNull(savedEntry.comment) + } + + @Test + fun `should save entry with isViewed true`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true) + + val savedEntry = filmLibraryRepository.save(entry) + + assertNotNull(savedEntry) + assertTrue(savedEntry.isViewed) + } + + @Test + fun `should save entry with isViewed false`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false) + + val savedEntry = filmLibraryRepository.save(entry) + + assertNotNull(savedEntry) + assertFalse(savedEntry.isViewed) + } + + @Test + fun `should cascade delete entries when user is deleted`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false) + filmLibraryRepository.save(entry) + + userRepository.deleteById(testUser.id) + + val foundEntry = filmLibraryRepository.findById(entry.id) + assertNull(foundEntry) + } + + @Test + fun `should cascade delete entries when film is deleted`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false) + filmLibraryRepository.save(entry) + + filmRepository.deleteById(testFilm.id) + + val foundEntry = filmLibraryRepository.findById(entry.id) + assertNull(foundEntry) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt new file mode 100644 index 0000000..926177b --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt @@ -0,0 +1,152 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.domain.model.Film +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class FilmRepositoryIntegrationTest { + @Autowired + private lateinit var filmRepository: FilmRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + @Test + fun `should save new film and return saved film`() { + val film = Film( + id = UUID.randomUUID(), + title = "Начало", + description = "Захватывающий триллер о снах внутри снов", + ) + + val savedFilm = filmRepository.save(film) + + assertNotNull(savedFilm) + assertEquals(film.id, savedFilm.id) + assertEquals(film.title, savedFilm.title) + assertEquals(film.description, savedFilm.description) + } + + @Test + fun `should update existing film`() { + val filmId = UUID.randomUUID() + val originalFilm = Film(filmId, "Начало", "Оригинальное описание") + filmRepository.save(originalFilm) + + val updatedFilm = Film(filmId, "Начало (Обновлено)", "Обновленное описание с дополнительными деталями") + val result = filmRepository.save(updatedFilm) + + assertEquals(filmId, result.id) + assertEquals("Начало (Обновлено)", result.title) + assertEquals("Обновленное описание с дополнительными деталями", result.description) + + val foundFilm = filmRepository.findById(filmId) + assertNotNull(foundFilm) + assertEquals("Начало (Обновлено)", foundFilm?.title) + assertEquals("Обновленное описание с дополнительными деталями", foundFilm?.description) + } + + @Test + fun `should find film by id`() { + val film = Film(UUID.randomUUID(), "Матрица", "Хакер узнает правду о реальности") + filmRepository.save(film) + + val foundFilm = filmRepository.findById(film.id) + + assertNotNull(foundFilm) + assertEquals(film.id, foundFilm?.id) + assertEquals(film.title, foundFilm?.title) + assertEquals(film.description, foundFilm?.description) + } + + @Test + fun `should return null when film not found by id`() { + val nonExistentId = UUID.randomUUID() + + val foundFilm = filmRepository.findById(nonExistentId) + + assertNull(foundFilm) + } + + @Test + fun `should find all films`() { + val film1 = Film(UUID.randomUUID(), "Начало", "Сны внутри снов") + val film2 = Film(UUID.randomUUID(), "Матрица", "Реальность не то, чем кажется") + val film3 = Film(UUID.randomUUID(), "Интерстеллар", "Путешествие сквозь пространство и время") + + filmRepository.save(film1) + filmRepository.save(film2) + filmRepository.save(film3) + + val allFilms = filmRepository.findAll() + + assertEquals(3, allFilms.size) + assertTrue(allFilms.any { it.id == film1.id }) + assertTrue(allFilms.any { it.id == film2.id }) + assertTrue(allFilms.any { it.id == film3.id }) + } + + @Test + fun `should return empty list when no films exist`() { + val allFilms = filmRepository.findAll() + + assertTrue(allFilms.isEmpty()) + } + + @Test + fun `should delete film by id`() { + val film = Film(UUID.randomUUID(), "Начало", "Сны внутри снов") + filmRepository.save(film) + + filmRepository.deleteById(film.id) + + val foundFilm = filmRepository.findById(film.id) + assertNull(foundFilm) + } + + @Test + fun `should not throw exception when deleting non-existent film`() { + val nonExistentId = UUID.randomUUID() + + filmRepository.deleteById(nonExistentId) + } + + @Test + fun `should save film with long description`() { + val longDescription = "А".repeat(1000) + val film = Film(UUID.randomUUID(), "Тестовый фильм", longDescription) + + val savedFilm = filmRepository.save(film) + + assertNotNull(savedFilm) + assertEquals(longDescription, savedFilm.description) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt new file mode 100644 index 0000000..e1f0cd9 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -0,0 +1,159 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class UserRepositoryIntegrationTest { + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + @Test + fun `should save new user and return saved user`() { + val user = User( + id = UUID.randomUUID(), + name = "John Doe", + email = "john@example.com", + library = null, + ) + + val savedUser = userRepository.save(user) + + assertNotNull(savedUser) + assertEquals(user.id, savedUser.id) + assertEquals(user.name, savedUser.name) + assertEquals(user.email, savedUser.email) + } + + @Test + fun `should update existing user`() { + // given + val userId = UUID.randomUUID() + val originalUser = User(userId, "John Doe", "john@example.com", null) + userRepository.save(originalUser) + + val updatedUser = User(userId, "Jane Doe", "jane@example.com", null) + val result = userRepository.save(updatedUser) + + assertEquals(userId, result.id) + assertEquals("Jane Doe", result.name) + assertEquals("jane@example.com", result.email) + + val foundUser = userRepository.findById(userId) + assertNotNull(foundUser) + assertEquals("Jane Doe", foundUser?.name) + assertEquals("jane@example.com", foundUser?.email) + } + + @Test + fun `should find user by id`() { + // given + val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + userRepository.save(user) + + // when + val foundUser = userRepository.findById(user.id) + + // then + assertNotNull(foundUser) + assertEquals(user.id, foundUser?.id) + assertEquals(user.name, foundUser?.name) + assertEquals(user.email, foundUser?.email) + } + + @Test + fun `should return null when user not found by id`() { + // given + val nonExistentId = UUID.randomUUID() + + // when + val foundUser = userRepository.findById(nonExistentId) + + // then + assertNull(foundUser) + } + + @Test + fun `should find all users`() { + // given + val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com", null) + val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com", null) + + userRepository.save(user1) + userRepository.save(user2) + userRepository.save(user3) + + // when + val allUsers = userRepository.findAll() + + // then + assertEquals(3, allUsers.size) + assertTrue(allUsers.any { it.id == user1.id }) + assertTrue(allUsers.any { it.id == user2.id }) + assertTrue(allUsers.any { it.id == user3.id }) + } + + @Test + fun `should return empty list when no users exist`() { + // when + val allUsers = userRepository.findAll() + + // then + assertTrue(allUsers.isEmpty()) + } + + @Test + fun `should delete user by id`() { + // given + val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + userRepository.save(user) + + // when + userRepository.deleteById(user.id) + + // then + val foundUser = userRepository.findById(user.id) + assertNull(foundUser) + } + + @Test + fun `should not throw exception when deleting non-existent user`() { + // given + val nonExistentId = UUID.randomUUID() + + // when & then (no exception should be thrown) + userRepository.deleteById(nonExistentId) + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..9aabcb5 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,28 @@ +spring: + application: + name: MovieNight-Test + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driver-class-name: org.h2.Driver + flyway: + enabled: true + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + locations: classpath:db/migration + baseline-on-migrate: true + h2: + console: + enabled: false + +services: + user: + blocked-names: + - admin + - root + - system + film: + blocked-patterns: + - censored + - epstein + - python -- 2.54.0 From 8926e5b08c95fc5a475d5143988d92bff4188c7a Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Tue, 21 Apr 2026 00:19:14 +0300 Subject: [PATCH 07/77] =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/movienight/adapters/web/FilmController.kt | 9 +++++++++ .../movienight/adapters/web/FilmLibraryController.kt | 8 ++++++++ 2 files changed, 17 insertions(+) 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 35f963e..659fdf6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.web + import com.project.movienight.adapters.web.dto.request.CreateFilmRequest import com.project.movienight.adapters.web.dto.request.EditFilmRequest import com.project.movienight.adapters.web.dto.response.FilmResponse @@ -10,6 +11,14 @@ import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase import com.project.movienight.application.services.FilmService import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.* import java.util.UUID 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 446e57a..32937c6 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,14 @@ import com.project.movienight.application.ports.input.RemoveFilmFromLibraryComma import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.application.services.FilmService import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.* import java.util.UUID -- 2.54.0 From f927148897879803e6eab42925f45999a5541960 Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 21 Apr 2026 00:21:00 +0300 Subject: [PATCH 08/77] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=20=D1=8E=D0=BD=D0=B8=D1=82=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../services/FilmLibraryServiceTest.kt | 280 ++++++++++++++++++ .../application/services/FilmServiceTest.kt | 181 +++++++++++ .../application/services/UserServiceTest.kt | 155 ++++++++++ 4 files changed, 617 insertions(+) create mode 100644 src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt create mode 100644 src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt create mode 100644 src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..847a6f5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { testImplementation(libs.spring.boot.starter.test) testImplementation(libs.kotlin.test.junit5) testImplementation(libs.spring.grpc.test) + testImplementation(libs.mockk) testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt new file mode 100644 index 0000000..a5951d4 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -0,0 +1,280 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.AddFilmToLibraryCommand +import com.project.movienight.application.ports.input.CreateFilmLibraryCommand +import com.project.movienight.application.ports.input.GetFilmLibraryQuery +import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmLibrary +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class FilmLibraryServiceTest { + private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var filmLibraryService: FilmLibraryService + + @BeforeEach + fun setup() { + filmLibraryRepository = mockk() + idGenerator = mockk() + filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator) + } + + @Test + fun `should create new film library when user has no library`() { + val userId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") + val expectedLibrary = FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = "My Films", + isViewed = false, + ) + + every { filmLibraryRepository.findAll() } returns emptyList() + every { idGenerator.generateId() } returnsMany listOf(libraryId, filmId) + every { filmLibraryRepository.save(match { + it.userId == userId && it.comment == "My Films" && it.isViewed == false + }) } returns expectedLibrary + + val result = filmLibraryService.create(command) + + assertNotNull(result) + assertEquals(libraryId, result.id) + assertEquals(userId, result.userId) + assertEquals(filmId, result.filmId) + assertEquals("My Films", result.comment) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 2) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryRepository.save(any()) } + } + + @Test + fun `should return existing library when user already has one`() { + val userId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = UUID.randomUUID(), + comment = "Existing Library", + isViewed = false, + ) + val command = CreateFilmLibraryCommand(userId = userId, name = "New Library") + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + + val result = filmLibraryService.create(command) + + assertEquals(existingLibrary, result) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmLibraryRepository.save(any()) } + } + + @Test + fun `should add film to new library when user has no library`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId) + val expectedLibrary = FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = null, + isViewed = false, + ) + + every { filmLibraryRepository.findAll() } returns emptyList() + every { idGenerator.generateId() } returns libraryId + every { filmLibraryRepository.save(match { + it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false + }) } returns expectedLibrary + + val result = filmLibraryService.addFilm(command) + + assertNotNull(result) + assertEquals(filmId, result.filmId) + assertEquals(userId, result.userId) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryRepository.save(any()) } + } + + @Test + fun `should add film to existing library`() { + val userId = UUID.randomUUID() + val oldFilmId = UUID.randomUUID() + val newFilmId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = oldFilmId, + comment = "My Library", + isViewed = true, + ) + val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) + val updatedLibrary = existingLibrary.copy(filmId = newFilmId, isViewed = false) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { filmLibraryRepository.save(match { + it.filmId == newFilmId && it.isViewed == false + }) } returns updatedLibrary + + val result = filmLibraryService.addFilm(command) + + assertEquals(newFilmId, result.filmId) + assertEquals(false, result.isViewed) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryRepository.save(any()) } + } + + @Test + fun `should remove film from library successfully`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = "My Library", + isViewed = false, + ) + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + justRun { filmLibraryRepository.deleteById(libraryId) } + + val result = filmLibraryService.removeFilm(command) + + assertEquals(existingLibrary, result) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryRepository.deleteById(libraryId) } + } + + @Test + fun `should throw EntityNotFoundException when removing film from non-existent library`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) + + every { filmLibraryRepository.findAll() } returns emptyList() + + assertThrows { + filmLibraryService.removeFilm(command) + } + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } + } + + @Test + fun `should throw DomainException when removing film that is not in library`() { + val userId = UUID.randomUUID() + val libraryFilmId = UUID.randomUUID() + val differentFilmId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = libraryFilmId, + comment = "My Library", + isViewed = false, + ) + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + + assertThrows { + filmLibraryService.removeFilm(command) + } + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } + } + + @Test + fun `should throw EntityNotFoundException when libraryId does not match`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val actualLibraryId = UUID.randomUUID() + val wrongLibraryId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = actualLibraryId, + userId = userId, + filmId = filmId, + comment = "My Library", + isViewed = false, + ) + val command = RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + libraryId = wrongLibraryId, + ) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + + assertThrows { + filmLibraryService.removeFilm(command) + } + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } + } + + @Test + fun `should get library successfully`() { + val userId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = UUID.randomUUID(), + comment = "My Library", + isViewed = false, + ) + val query = GetFilmLibraryQuery(userId = userId) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + + val result = filmLibraryService.getLibrary(query) + + assertEquals(existingLibrary, result) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + } + + @Test + fun `should throw EntityNotFoundException when getting non-existent library`() { + val userId = UUID.randomUUID() + val query = GetFilmLibraryQuery(userId = userId) + + every { filmLibraryRepository.findAll() } returns emptyList() + + assertThrows { + filmLibraryService.getLibrary(query) + } + + verify(exactly = 1) { filmLibraryRepository.findAll() } + } +} diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt new file mode 100644 index 0000000..fcc5613 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -0,0 +1,181 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.CreateFilmCommand +import com.project.movienight.application.ports.input.EditFilmCommand +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.config.FilmServiceProperties +import com.project.movienight.domain.exception.BlockedValueException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.Film +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class FilmServiceTest { + private lateinit var filmRepository: FilmRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var filmConfig: FilmServiceProperties + private lateinit var filmService: FilmService + + @BeforeEach + fun setup() { + filmRepository = mockk() + idGenerator = mockk() + filmConfig = mockk() + filmService = FilmService(filmRepository, idGenerator, filmConfig) + } + + @Test + fun `should create film successfully`() { + val command = CreateFilmCommand(title = "Inception", description = "A mind-bending thriller") + val filmId = UUID.randomUUID() + val expectedFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + + every { filmConfig.isBlocked("Inception") } returns false + every { filmConfig.isBlocked("A mind-bending thriller") } returns false + every { idGenerator.generateId() } returns filmId + every { filmRepository.save(any()) } returns expectedFilm + + val result = filmService.create(command) + + assertNotNull(result) + assertEquals(filmId, result.id) + assertEquals("Inception", result.title) + assertEquals("A mind-bending thriller", result.description) + + verify(exactly = 1) { filmConfig.isBlocked("Inception") } + verify(exactly = 1) { filmConfig.isBlocked("A mind-bending thriller") } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating film with blocked title`() { + val command = CreateFilmCommand(title = "censored", description = "Some description") + + every { filmConfig.isBlocked("censored") } returns true + every { filmConfig.isBlocked("Some description") } returns false + + assertThrows { + filmService.create(command) + } + + verify(exactly = 1) { filmConfig.isBlocked("censored") } + verify(exactly = 0) { filmConfig.isBlocked("Some description") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating film with blocked description`() { + val command = CreateFilmCommand(title = "Good Film", description = "python") + + every { filmConfig.isBlocked("Good Film") } returns false + every { filmConfig.isBlocked("python") } returns true + + assertThrows { + filmService.create(command) + } + + verify(exactly = 1) { filmConfig.isBlocked("Good Film") } + verify(exactly = 1) { filmConfig.isBlocked("python") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should edit film successfully`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "Inception 2", description = "The sequel") + val existingFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + val updatedFilm = Film(id = filmId, title = "Inception 2", description = "The sequel") + + every { filmConfig.isBlocked("Inception 2") } returns false + every { filmConfig.isBlocked("The sequel") } returns false + every { filmRepository.findById(filmId) } returns existingFilm + every { filmRepository.save(any()) } returns updatedFilm + + val result = filmService.edit(filmId, command) + + assertNotNull(result) + assertEquals(filmId, result.id) + assertEquals("Inception 2", result.title) + assertEquals("The sequel", result.description) + + verify(exactly = 1) { filmConfig.isBlocked("Inception 2") } + verify(exactly = 1) { filmConfig.isBlocked("The sequel") } + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 1) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when editing film with blocked title`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "epstein", description = "Some description") + + every { filmConfig.isBlocked("epstein") } returns true + + assertThrows { + filmService.edit(filmId, command) + } + + verify(exactly = 1) { filmConfig.isBlocked("epstein") } + verify(exactly = 0) { filmRepository.findById(any()) } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should throw EntityNotFoundException when editing non-existent film`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "New Title", description = "New Description") + + every { filmConfig.isBlocked("New Title") } returns false + every { filmConfig.isBlocked("New Description") } returns false + every { filmRepository.findById(filmId) } returns null + + assertThrows { + filmService.edit(filmId, command) + } + + verify(exactly = 1) { filmConfig.isBlocked("New Title") } + verify(exactly = 1) { filmConfig.isBlocked("New Description") } + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should delete film successfully`() { + val filmId = UUID.randomUUID() + val existingFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + + every { filmRepository.findById(filmId) } returns existingFilm + justRun { filmRepository.deleteById(filmId) } + + filmService.delete(filmId) + + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 1) { filmRepository.deleteById(filmId) } + } + + @Test + fun `should throw EntityNotFoundException when deleting non-existent film`() { + val filmId = UUID.randomUUID() + + every { filmRepository.findById(filmId) } returns null + + assertThrows { + filmService.delete(filmId) + } + + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 0) { filmRepository.deleteById(any()) } + } +} diff --git a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt new file mode 100644 index 0000000..c54a909 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt @@ -0,0 +1,155 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.CreateUserCommand +import com.project.movienight.application.ports.input.EditUserCommand +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.config.UserServiceProperties +import com.project.movienight.domain.exception.BlockedValueException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.User +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class UserServiceTest { + private lateinit var userRepository: UserRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var userConfig: UserServiceProperties + private lateinit var userService: UserService + + @BeforeEach + fun setup() { + userRepository = mockk() + idGenerator = mockk() + userConfig = mockk() + userService = UserService(userRepository, idGenerator, userConfig) + } + + @Test + fun `should create user successfully`() { + val command = CreateUserCommand(name = "John Doe", email = "john@example.com") + val userId = UUID.randomUUID() + val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) + + every { userConfig.isBlocked("John Doe") } returns false + every { idGenerator.generateId() } returns userId + every { userRepository.save(any()) } returns expectedUser + + val result = userService.create(command) + + assertNotNull(result) + assertEquals(userId, result.id) + assertEquals("John Doe", result.name) + assertEquals("john@example.com", result.email) + + verify(exactly = 1) { userConfig.isBlocked("John Doe") } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { userRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating user with blocked name`() { + val command = CreateUserCommand(name = "admin", email = "admin@example.com") + + every { userConfig.isBlocked("admin") } returns true + + assertThrows { + userService.create(command) + } + + verify(exactly = 1) { userConfig.isBlocked("admin") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + fun `should edit user successfully`() { + val userId = UUID.randomUUID() + val command = EditUserCommand(name = "Jane Doe") + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) + val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com", library = null) + + every { userConfig.isBlocked("Jane Doe") } returns false + every { userRepository.findById(userId) } returns existingUser + every { userRepository.save(any()) } returns updatedUser + + val result = userService.edit(userId, command) + + assertNotNull(result) + assertEquals(userId, result.id) + assertEquals("Jane Doe", result.name) + + verify(exactly = 1) { userConfig.isBlocked("Jane Doe") } + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 1) { userRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when editing user with blocked name`() { + val userId = UUID.randomUUID() + val command = EditUserCommand(name = "root") + + every { userConfig.isBlocked("root") } returns true + + assertThrows { + userService.edit(userId, command) + } + + verify(exactly = 1) { userConfig.isBlocked("root") } + verify(exactly = 0) { userRepository.findById(any()) } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + fun `should throw EntityNotFoundException when editing non-existent user`() { + val userId = UUID.randomUUID() + val command = EditUserCommand(name = "Jane Doe") + + every { userConfig.isBlocked("Jane Doe") } returns false + every { userRepository.findById(userId) } returns null + + assertThrows { + userService.edit(userId, command) + } + + verify(exactly = 1) { userConfig.isBlocked("Jane Doe") } + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + fun `should delete user successfully`() { + val userId = UUID.randomUUID() + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) + + every { userRepository.findById(userId) } returns existingUser + justRun { userRepository.deleteById(userId) } + + userService.delete(userId) + + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 1) { userRepository.deleteById(userId) } + } + + @Test + fun `should throw EntityNotFoundException when deleting non-existent user`() { + val userId = UUID.randomUUID() + + every { userRepository.findById(userId) } returns null + + assertThrows { + userService.delete(userId) + } + + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 0) { userRepository.deleteById(any()) } + } +} -- 2.54.0 From 3cb52c019f23d24f8f177c2869d232dd165caaf4 Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 21 Apr 2026 15:30:58 +0300 Subject: [PATCH 09/77] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20V2=5F=5Fadd=5Foauth2=5Ffields.sql=20=D0=92=D1=81=D0=B5?= =?UTF-8?q?=20UserRepositoryIntegrationTest=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 +- gradle/libs.versions.toml | 2 ++ .../resources/db/migration/V2__add_oauth2_fields.sql | 9 +++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V2__add_oauth2_fields.sql diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..0adabc9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,7 +32,7 @@ dependencies { implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) -// implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.cache) implementation(libs.spring.boot.starter.data.jdbc) implementation(libs.spring.boot.starter.validation) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e91484..e43dfa6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ spring-grpc = "1.0.1" protoc = "3.25.1" grpc-java = "1.60.0" springdoc = "2.8.6" +mockk = "1.13.13" [libraries] spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } @@ -33,6 +34,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" } sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" } diff --git a/src/main/resources/db/migration/V2__add_oauth2_fields.sql b/src/main/resources/db/migration/V2__add_oauth2_fields.sql new file mode 100644 index 0000000..0db4084 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_fields.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.users ADD COLUMN provider VARCHAR(20); +ALTER TABLE public.users ADD COLUMN provider_id VARCHAR(255); +ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; + +CREATE UNIQUE INDEX idx_users_provider_provider_id + ON public.users(provider, provider_id); + +CREATE INDEX idx_users_email ON public.users(email); + -- 2.54.0 From acd8f9fb01e9403ecdeebbc7eb193d4b480bdf6e Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 21 Apr 2026 18:30:22 +0300 Subject: [PATCH 10/77] =?UTF-8?q?=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=20AuthProvider=20enum=20=D1=81=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=B0=D0=BC=D0=B8=20GOOGLE,=20YA?= =?UTF-8?q?NDEX,=20VK=20=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B0=20Us?= =?UTF-8?q?erEntity=20=D0=B2=20adapters/persistence/entity=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8F=D0=BC=D0=B8=20=D0=B4=D0=BB=D1=8F=20OAu?= =?UTF-8?q?th=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20e?= =?UTF-8?q?xtension-=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0=20toDomain()=20?= =?UTF-8?q?=D0=B8=20toEntity()=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20UserRepository=20=E2=80=94=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=D0=B5=D1=82=20UserEntity=20?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20findByProviderAndProviderId()?= =?UTF-8?q?=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D1=8B=20=D1=8E=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=20=D1=82=D0=B5=D1=81=D1=82=20-=20UserEntityMapping?= =?UTF-8?q?Test.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/persistence/entity/UserEntity.kt | 37 +++++++++ .../persistence/jdbc/UserRepository.kt | 64 ++++++++++----- .../ports/output/UserRepositoryPort.kt | 6 ++ .../movienight/domain/model/AuthProvider.kt | 7 ++ .../entity/UserEntityMappingTest.kt | 81 +++++++++++++++++++ 5 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt create mode 100644 src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt new file mode 100644 index 0000000..0beda74 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -0,0 +1,37 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import java.time.LocalDateTime +import java.util.UUID + +data class UserEntity( + val id: UUID, + val name: String, + val email: String, + val provider: String?, + val providerId: String?, + val createdAt: LocalDateTime, +) + +fun UserEntity.toDomain(): User = + User( + id = id, + name = name, + email = email, + library = null, + ) + +fun User.toEntity( + provider: AuthProvider? = null, + providerId: String? = null, + createdAt: LocalDateTime = LocalDateTime.now(), +): UserEntity = + UserEntity( + id = id, + name = name, + email = email, + provider = provider?.name, + providerId = providerId, + createdAt = createdAt, + ) 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 a6607e5..09d8f9d 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 @@ -1,6 +1,10 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.adapters.persistence.entity.UserEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @@ -11,58 +15,80 @@ import java.util.UUID class UserRepository( private val jdbc: JdbcTemplate, ) : UserRepositoryPort { - private val userRowMapper = { rs: ResultSet, _: Int -> - User( + private val userEntityRowMapper = { rs: ResultSet, _: Int -> + UserEntity( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), - library = null, + provider = rs.getString("provider"), + providerId = rs.getString("provider_id"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } override fun save(user: User): User { + val entity = user.toEntity() val updatedRows = jdbc.update( """ UPDATE users - SET name = ?, email = ? + SET name = ?, email = ?, provider = ?, provider_id = ? WHERE id = ? """.trimIndent(), - user.name, - user.email, - user.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email) - VALUES (?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), - user.id, - user.name, - user.email, + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.createdAt, ) } return user } override fun findById(id: UUID): User? { - val users = + val entities = jdbc.query( - "SELECT id, name, email FROM users WHERE id = ?", - userRowMapper, + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, id, ) - return users.firstOrNull() + return entities.firstOrNull()?.toDomain() } override fun findAll(): List = jdbc.query( - "SELECT id, name, email FROM users", - userRowMapper, - ) + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) } + + override fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? { + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", + userEntityRowMapper, + provider.name, + providerId, + ) + return entities.firstOrNull()?.toDomain() + } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index af8ef0a..e3c902c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.ports.output +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import java.util.UUID @@ -11,4 +12,9 @@ interface UserRepositoryPort { fun findAll(): List fun deleteById(id: UUID) + + fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? } diff --git a/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt b/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt new file mode 100644 index 0000000..4926b93 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt @@ -0,0 +1,7 @@ +package com.project.movienight.domain.model + +enum class AuthProvider { + GOOGLE, + YANDEX, + VK, +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt new file mode 100644 index 0000000..bec7f20 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -0,0 +1,81 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UserEntityMappingTest { + + @Test + fun `toDomain maps UserEntity correctly`() { + val entity = UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) + val user = entity.toDomain() + + assertEquals(entity.id, user.id) + assertEquals(entity.name, user.name) + assertEquals(entity.email, user.email) + assertNull(user.library) + } + + @Test + fun `toEntity maps User with OAuth provider`() { + val user = User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null + ) + + val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") + + assertEquals(user.id, entity.id) + assertEquals(user.name, entity.name) + assertEquals(user.email, entity.email) + assertEquals("YANDEX", entity.provider) + assertEquals("yandex456", entity.providerId) + } + + @Test + fun `toEntity maps User without OAuth provider`() { + val user = User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null + ) + + val entity = user.toEntity() + + assertNull(entity.provider) + assertNull(entity.providerId) + } + + @Test + fun `mapping is reversible for basic fields`() { + val original = User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null + ) + + val mapped = original.toEntity().toDomain() + + assertEquals(original.id, mapped.id) + assertEquals(original.name, mapped.name) + assertEquals(original.email, mapped.email) + } + + +} -- 2.54.0 From 4f0e8ccbd40250d0e5cca4ade8fc2d44c9e7bc03 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:28:03 +0000 Subject: [PATCH 11/77] test: fix ktlint multiline wrapping in JDBC integration tests Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/e7455e93-ed32-49f5-9b29-dabcdcc01e87 Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../jdbc/FilmLibraryRepositoryIntegrationTest.kt | 15 ++++++++------- .../jdbc/FilmRepositoryIntegrationTest.kt | 11 ++++++----- .../jdbc/UserRepositoryIntegrationTest.kt | 13 +++++++------ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt index 232216a..d880f02 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt @@ -61,13 +61,14 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should save new film library entry and return saved entry`() { - val entry = FilmLibrary( - id = UUID.randomUUID(), - userId = testUser.id, - filmId = testFilm.id, - comment = "Отличный фильм!", - isViewed = false, - ) + val entry = + FilmLibrary( + id = UUID.randomUUID(), + userId = testUser.id, + filmId = testFilm.id, + comment = "Отличный фильм!", + isViewed = false, + ) val savedEntry = filmLibraryRepository.save(entry) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt index 926177b..3c38a23 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt @@ -41,11 +41,12 @@ class FilmRepositoryIntegrationTest { @Test fun `should save new film and return saved film`() { - val film = Film( - id = UUID.randomUUID(), - title = "Начало", - description = "Захватывающий триллер о снах внутри снов", - ) + val film = + Film( + id = UUID.randomUUID(), + title = "Начало", + description = "Захватывающий триллер о снах внутри снов", + ) val savedFilm = filmRepository.save(film) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt index e1f0cd9..02b588a 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -41,12 +41,13 @@ class UserRepositoryIntegrationTest { @Test fun `should save new user and return saved user`() { - val user = User( - id = UUID.randomUUID(), - name = "John Doe", - email = "john@example.com", - library = null, - ) + val user = + User( + id = UUID.randomUUID(), + name = "John Doe", + email = "john@example.com", + library = null, + ) val savedUser = userRepository.save(user) -- 2.54.0 From c64fc35353fec000c6b2264f789cceef79801b59 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Wed, 22 Apr 2026 08:46:58 +0300 Subject: [PATCH 12/77] =?UTF-8?q?OAuth2=20(=D0=B1=D0=B5=D0=B7=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B0=20=D1=81=20java)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + gradle/libs.versions.toml | 1 + .../persistence/jdbc/UserRepository.kt | 10 ++++ .../security/CustomOAuth2UserService.kt | 57 +++++++++++++++++++ .../adapters/security/GoogleOAuth2UserInfo.kt | 16 ++++++ .../adapters/security/OAuth2UserInfo.kt | 9 +++ .../security/OAuth2UserInfoFactory.kt | 18 ++++++ .../adapters/security/UserPrincipal.kt | 42 ++++++++++++++ .../adapters/security/VkOAuth2UserInfo.kt | 26 +++++++++ .../adapters/security/YandexOAuth2UserInfo.kt | 20 +++++++ .../ports/output/UserRepositoryPort.kt | 2 + .../application/services/UserService.kt | 1 + .../project/movienight/domain/model/User.kt | 1 + 13 files changed, 205 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..609f72b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,6 +47,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) + implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) runtimeOnly(libs.postgresql) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e91484..28cf8d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ grpc-java = "1.60.0" springdoc = "2.8.6" [libraries] +spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } 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 a6607e5..8eee586 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 @@ -16,6 +16,7 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), + password = rs.getString("password"), library = null, ) } @@ -56,6 +57,15 @@ class UserRepository( return users.firstOrNull() } + override fun findByEmail(email: String): User? { + val users = jdbc.query( + "SELECT id, name, email, password FROM users WHERE email = ?", + userRowMapper, + email, + ) + return users.firstOrNull() + } + override fun findAll(): List = jdbc.query( "SELECT id, name, email FROM users", diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt new file mode 100644 index 0000000..189e927 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Service + +@Service +class CustomOAuth2UserService( + private val userRepository: UserRepositoryPort, + private val idGenerator: IdGenerator, +) : DefaultOAuth2UserService() { + + companion object { + private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) + } + + override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User { + val oAuth2User = super.loadUser(userRequest) + val registrationId = userRequest.clientRegistration.registrationId + + log.debug("Processing OAuth2 login for provider: {}", registrationId) + + return try { + val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) + val user = findOrCreateUser(userInfo) + UserPrincipal.create(user, oAuth2User.attributes) + } catch (e: Exception) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } + } + + private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { + val existingUser = userRepository.findByEmail(userInfo.getEmail()) + + return if (existingUser != null) { + log.debug("User found by email: {}", userInfo.getEmail()) + existingUser + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", // OAuth2 пользователи не имеют пароля + library = null, + ) + userRepository.save(newUser) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt new file mode 100644 index 0000000..dcff86e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -0,0 +1,16 @@ +package com.project.movienight.adapters.security.oauth2 + +class GoogleOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["sub"] as String + + override fun getEmail(): String = attributes["email"] as String + + override fun getName(): String = attributes["name"] as String + + override fun getProvider(): String = "google" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt new file mode 100644 index 0000000..5a155b9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -0,0 +1,9 @@ +package com.project.movienight.adapters.security.oauth2 + +interface OAuth2UserInfo { + fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt new file mode 100644 index 0000000..001b789 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -0,0 +1,18 @@ +package com.project.movienight.adapters.security.oauth2 + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User + +object OAuth2UserInfoFactory { + + fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + val attributes = user.attributes + + return when (registrationId.lowercase()) { + "google" -> GoogleOAuth2UserInfo(attributes) + "yandex" -> YandexOAuth2UserInfo(attributes) + "vk" -> VkOAuth2UserInfo(attributes) + else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt new file mode 100644 index 0000000..5fbbee1 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.domain.model.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.core.user.OAuth2User +import java.util.* + +class UserPrincipal( + private val user: User, + private val attributes: Map? = null, +) : OAuth2User, UserDetails { + + fun getId(): UUID = user.id + + override fun getName(): String = user.name + + override fun getAttributes(): Map = attributes ?: emptyMap() + + override fun getAuthorities(): Collection { + return listOf(SimpleGrantedAuthority("ROLE_USER")) + } + + override fun getPassword(): String = user.password + + override fun getUsername(): String = user.email + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = true + + companion object { + fun create(user: User, attributes: Map? = null): UserPrincipal { + return UserPrincipal(user, attributes) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt new file mode 100644 index 0000000..492e74c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class VkOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String { + val response = attributes["response"] as? List> + return response?.firstOrNull()?.get("id")?.toString() ?: "" + } + + override fun getEmail(): String = attributes["email"] as? String ?: "" + + override fun getName(): String { + val response = attributes["response"] as? List> + val first = response?.firstOrNull() + val firstName = first?.get("first_name") as? String ?: "" + val lastName = first?.get("last_name") as? String ?: "" + return "$firstName $lastName".trim() + } + + override fun getProvider(): String = "vk" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt new file mode 100644 index 0000000..2929389 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -0,0 +1,20 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class YandexOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["id"]?.toString() ?: "" + + override fun getEmail(): String { + val emails = attributes["emails"] as? List> + return emails?.firstOrNull()?.get("value") ?: "" + } + + override fun getName(): String = attributes["display_name"] as? String ?: "" + + override fun getProvider(): String = "yandex" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index af8ef0a..cfefe9b 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -8,6 +8,8 @@ interface UserRepositoryPort { fun findById(id: UUID): User? + fun findByEmail(email: String): User? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index be32d6a..5eb754e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -32,6 +32,7 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, + password = "", library = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..db9142b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,5 +6,6 @@ data class User( val id: UUID, val name: String, val email: String, + val password: String, val library: FilmLibrary?, ) -- 2.54.0 From bd7ecfcab5dc57f3e8ca11a688ef1f998093a728 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 06:36:27 +0000 Subject: [PATCH 13/77] fix(test): add mockk catalog dependency and format service tests Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/abb7242b-7ff5-4cde-9d34-5733a7f1172f Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- gradle/libs.versions.toml | 2 + .../services/FilmLibraryServiceTest.kt | 161 ++++++++++-------- 2 files changed, 93 insertions(+), 70 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e91484..7314bb3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ spring-grpc = "1.0.1" protoc = "3.25.1" grpc-java = "1.60.0" springdoc = "2.8.6" +mockk = "1.13.12" [libraries] spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } @@ -33,6 +34,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" } sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index a5951d4..1556f83 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -38,19 +38,24 @@ class FilmLibraryServiceTest { val libraryId = UUID.randomUUID() val filmId = UUID.randomUUID() val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") - val expectedLibrary = FilmLibrary( - id = libraryId, - userId = userId, - filmId = filmId, - comment = "My Films", - isViewed = false, - ) + val expectedLibrary = + FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = "My Films", + isViewed = false, + ) every { filmLibraryRepository.findAll() } returns emptyList() every { idGenerator.generateId() } returnsMany listOf(libraryId, filmId) - every { filmLibraryRepository.save(match { - it.userId == userId && it.comment == "My Films" && it.isViewed == false - }) } returns expectedLibrary + every { + filmLibraryRepository.save( + match { + it.userId == userId && it.comment == "My Films" && it.isViewed == false + }, + ) + } returns expectedLibrary val result = filmLibraryService.create(command) @@ -68,13 +73,14 @@ class FilmLibraryServiceTest { @Test fun `should return existing library when user already has one`() { val userId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = UUID.randomUUID(), - comment = "Existing Library", - isViewed = false, - ) + val existingLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = UUID.randomUUID(), + comment = "Existing Library", + isViewed = false, + ) val command = CreateFilmLibraryCommand(userId = userId, name = "New Library") every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) @@ -94,19 +100,24 @@ class FilmLibraryServiceTest { val filmId = UUID.randomUUID() val libraryId = UUID.randomUUID() val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId) - val expectedLibrary = FilmLibrary( - id = libraryId, - userId = userId, - filmId = filmId, - comment = null, - isViewed = false, - ) + val expectedLibrary = + FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = null, + isViewed = false, + ) every { filmLibraryRepository.findAll() } returns emptyList() every { idGenerator.generateId() } returns libraryId - every { filmLibraryRepository.save(match { - it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false - }) } returns expectedLibrary + every { + filmLibraryRepository.save( + match { + it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false + }, + ) + } returns expectedLibrary val result = filmLibraryService.addFilm(command) @@ -124,20 +135,25 @@ class FilmLibraryServiceTest { val userId = UUID.randomUUID() val oldFilmId = UUID.randomUUID() val newFilmId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = oldFilmId, - comment = "My Library", - isViewed = true, - ) + val existingLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = oldFilmId, + comment = "My Library", + isViewed = true, + ) val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) val updatedLibrary = existingLibrary.copy(filmId = newFilmId, isViewed = false) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - every { filmLibraryRepository.save(match { - it.filmId == newFilmId && it.isViewed == false - }) } returns updatedLibrary + every { + filmLibraryRepository.save( + match { + it.filmId == newFilmId && it.isViewed == false + }, + ) + } returns updatedLibrary val result = filmLibraryService.addFilm(command) @@ -154,13 +170,14 @@ class FilmLibraryServiceTest { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() val libraryId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = libraryId, - userId = userId, - filmId = filmId, - comment = "My Library", - isViewed = false, - ) + val existingLibrary = + FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = "My Library", + isViewed = false, + ) val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) @@ -195,13 +212,14 @@ class FilmLibraryServiceTest { val userId = UUID.randomUUID() val libraryFilmId = UUID.randomUUID() val differentFilmId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = libraryFilmId, - comment = "My Library", - isViewed = false, - ) + val existingLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = libraryFilmId, + comment = "My Library", + isViewed = false, + ) val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) @@ -220,18 +238,20 @@ class FilmLibraryServiceTest { val filmId = UUID.randomUUID() val actualLibraryId = UUID.randomUUID() val wrongLibraryId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = actualLibraryId, - userId = userId, - filmId = filmId, - comment = "My Library", - isViewed = false, - ) - val command = RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - libraryId = wrongLibraryId, - ) + val existingLibrary = + FilmLibrary( + id = actualLibraryId, + userId = userId, + filmId = filmId, + comment = "My Library", + isViewed = false, + ) + val command = + RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + libraryId = wrongLibraryId, + ) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) @@ -246,13 +266,14 @@ class FilmLibraryServiceTest { @Test fun `should get library successfully`() { val userId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = UUID.randomUUID(), - comment = "My Library", - isViewed = false, - ) + val existingLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = UUID.randomUUID(), + comment = "My Library", + isViewed = false, + ) val query = GetFilmLibraryQuery(userId = userId) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) -- 2.54.0 From d50e2e640cf7ee48c8771d41b5144dd298a76900 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 23 Apr 2026 18:47:30 +0300 Subject: [PATCH 14/77] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20V1=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BF=D1=8F=D1=82=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20email=20=D0=A0=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20saveWithOAuth2()?= =?UTF-8?q?=20=D0=B2=20UserRepository=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20findByProviderAndProviderId()?= =?UTF-8?q?=20=D0=B2=20UserRepository=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20CustomOAuth2UserService=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20OAuth2=20=D0=BC=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20package=20declaration=20=D0=B2=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=207=20OAuth2=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/jdbc/UserRepository.kt | 52 +++++++++++++++++-- .../security/CustomOAuth2UserService.kt | 37 ++++++++----- .../adapters/security/GoogleOAuth2UserInfo.kt | 2 +- .../adapters/security/OAuth2UserInfo.kt | 2 +- .../security/OAuth2UserInfoFactory.kt | 2 +- .../adapters/security/UserPrincipal.kt | 2 +- .../adapters/security/VkOAuth2UserInfo.kt | 2 +- .../adapters/security/YandexOAuth2UserInfo.kt | 2 +- .../ports/output/UserRepositoryPort.kt | 4 ++ src/main/resources/db/migration/V1__init.sql | 3 +- 10 files changed, 84 insertions(+), 24 deletions(-) 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 8eee586..420dbd0 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 @@ -26,22 +26,24 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ? + SET name = ?, email = ?, password = ? WHERE id = ? """.trimIndent(), user.name, user.email, + user.password, user.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email) - VALUES (?, ?, ?) + INSERT INTO users (id, name, email, password) + VALUES (?, ?, ?, ?) """.trimIndent(), user.id, user.name, user.email, + user.password, ) } return user @@ -50,7 +52,7 @@ class UserRepository( override fun findById(id: UUID): User? { val users = jdbc.query( - "SELECT id, name, email FROM users WHERE id = ?", + "SELECT id, name, email, password FROM users WHERE id = ?", userRowMapper, id, ) @@ -68,11 +70,51 @@ class UserRepository( override fun findAll(): List = jdbc.query( - "SELECT id, name, email FROM users", + "SELECT id, name, email, password FROM users", userRowMapper, ) override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) } + + override fun saveWithOAuth2(user: User, provider: String, providerId: String): User { + val updatedRows = jdbc.update(""" + UPDATE users + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + user.name, + user.email, + user.password, + provider, + providerId, + user.id, + ) + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO users (id, name, email, password, provider, provider_id) + VALUES (?, ?, ?, ?, ?, ?) + """.trimIndent(), + user.id, + user.name, + user.email, + user.password, + provider, + providerId, + ) + } + return user + } + + override fun findByProviderAndProviderId(provider: String, providerId: String): User? { + val users = jdbc.query( + "SELECT id, name, email, password FROM users WHERE provider = ? AND provider_id = ?", + userRowMapper, + provider, + providerId, + ) + return users.firstOrNull() + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index 189e927..968424a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort @@ -37,21 +37,34 @@ class CustomOAuth2UserService( } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - val existingUser = userRepository.findByEmail(userInfo.getEmail()) + // Сначала ищем по provider + provider_id (основной способ для OAuth2) + val existingUser = userRepository.findByProviderAndProviderId( + userInfo.getProvider(), + userInfo.getProviderId() + ) return if (existingUser != null) { - log.debug("User found by email: {}", userInfo.getEmail()) + log.debug("User found by provider: {}", userInfo.getProvider()) existingUser } else { - log.debug("Creating new user for provider: {}", userInfo.getProvider()) - val newUser = User( - id = idGenerator.generateId(), - name = userInfo.getName(), - email = userInfo.getEmail(), - password = "", // OAuth2 пользователи не имеют пароля - library = null, - ) - userRepository.save(newUser) + // Проверяем нет ли пользователя с таким email (связывание аккаунтов) + val userByEmail = userRepository.findByEmail(userInfo.getEmail()) + + if (userByEmail != null) { + // Пользователь существует, обновляем его OAuth2 данными + log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) + userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", + library = null, + ) + userRepository.saveWithOAuth2(newUser, userInfo.getProvider(), userInfo.getProviderId()) + } } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt index dcff86e..fa41de5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security class GoogleOAuth2UserInfo( private val attributes: Map diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt index 5a155b9..b6abf09 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security interface OAuth2UserInfo { fun getProviderId(): String diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt index 001b789..e2db545 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index 5fbbee1..a4d94ea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import com.project.movienight.domain.model.User import org.springframework.security.core.GrantedAuthority diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt index 492e74c..47c41d2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security @Suppress("UNCHECKED_CAST") class VkOAuth2UserInfo( diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt index 2929389..467aa85 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security @Suppress("UNCHECKED_CAST") class YandexOAuth2UserInfo( diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index cfefe9b..0e45c1e 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -6,6 +6,10 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User + fun saveWithOAuth2(user: User, provider: String, providerId: String): User + + fun findByProviderAndProviderId(provider: String, providerId: String): User? + fun findById(id: UUID): User? fun findByEmail(email: String): User? diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11017d1..a94ddaf 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,7 +1,8 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, - email VARCHAR(320) NOT NULL UNIQUE + email VARCHAR(320) NOT NULL UNIQUE, + password VARCHAR(255) ); CREATE TABLE IF NOT EXISTS public.films ( -- 2.54.0 From 312c18645b405d01a36ad6a2d3a5cdb8cb82baa0 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 23 Apr 2026 23:59:16 +0300 Subject: [PATCH 15/77] =?UTF-8?q?-=20=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=91=D0=BD=20OAuth2UserInfo.kt=20=D0=B8=D0=B7=20adapter?= =?UTF-8?q?s/security/=20=D0=B2=20application/ports/input/security/;=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20=D0=B2=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=D1=85=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D1=8B=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - В UserPrincipal.kt заменён star import java.util.* на явный java.util.UUID. - В YandexOAuth2UserInfo.kt и VkOAuth2UserInfo.kt убрана аннотация @Suppress("UNCHECKED_CAST") - В build.gradle.kts хардкод версии заменён на version catalog - Из CustomOAuth2UserService.kt удалены комментарии на русском языке --- build.gradle.kts | 3 +-- .../security/CustomOAuth2UserService.kt | 4 +--- .../adapters/security/GoogleOAuth2UserInfo.kt | 2 ++ .../security/OAuth2UserInfoFactory.kt | 1 + .../adapters/security/UserPrincipal.kt | 2 +- .../adapters/security/VkOAuth2UserInfo.kt | 20 +++++++++++-------- .../adapters/security/YandexOAuth2UserInfo.kt | 12 +++++++---- .../ports/input}/security/OAuth2UserInfo.kt | 2 +- 8 files changed, 27 insertions(+), 19 deletions(-) rename src/main/kotlin/com/project/movienight/{adapters => application/ports/input}/security/OAuth2UserInfo.kt (74%) diff --git a/build.gradle.kts b/build.gradle.kts index 2d2ed27..a5b9744 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,8 +46,7 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - - implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index 968424a..cc1cb8d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.domain.model.User @@ -37,7 +38,6 @@ class CustomOAuth2UserService( } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - // Сначала ищем по provider + provider_id (основной способ для OAuth2) val existingUser = userRepository.findByProviderAndProviderId( userInfo.getProvider(), userInfo.getProviderId() @@ -47,11 +47,9 @@ class CustomOAuth2UserService( log.debug("User found by provider: {}", userInfo.getProvider()) existingUser } else { - // Проверяем нет ли пользователя с таким email (связывание аккаунтов) val userByEmail = userRepository.findByEmail(userInfo.getEmail()) if (userByEmail != null) { - // Пользователь существует, обновляем его OAuth2 данными log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) } else { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt index fa41de5..c463ac6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -1,5 +1,7 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class GoogleOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt index e2db545..89d6d30 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index a4d94ea..c602211 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -5,7 +5,7 @@ import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.oauth2.core.user.OAuth2User -import java.util.* +import java.util.UUID class UserPrincipal( private val user: User, diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt index 47c41d2..e2c55c0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -1,22 +1,26 @@ package com.project.movienight.adapters.security -@Suppress("UNCHECKED_CAST") +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class VkOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { override fun getProviderId(): String { - val response = attributes["response"] as? List> - return response?.firstOrNull()?.get("id")?.toString() ?: "" + return (attributes["response"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("id") + ?.toString() ?: "" } - override fun getEmail(): String = attributes["email"] as? String ?: "" + override fun getEmail(): String = attributes["email"]?.toString() ?: "" override fun getName(): String { - val response = attributes["response"] as? List> - val first = response?.firstOrNull() - val firstName = first?.get("first_name") as? String ?: "" - val lastName = first?.get("last_name") as? String ?: "" + val response = attributes["response"] as? List<*> + val first = response?.firstOrNull() as? Map<*, *> + val firstName = first?.get("first_name")?.toString() ?: "" + val lastName = first?.get("last_name")?.toString() ?: "" return "$firstName $lastName".trim() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt index 467aa85..59bf3eb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -1,6 +1,7 @@ package com.project.movienight.adapters.security -@Suppress("UNCHECKED_CAST") +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class YandexOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { @@ -8,11 +9,14 @@ class YandexOAuth2UserInfo( override fun getProviderId(): String = attributes["id"]?.toString() ?: "" override fun getEmail(): String { - val emails = attributes["emails"] as? List> - return emails?.firstOrNull()?.get("value") ?: "" + return (attributes["emails"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("value") + ?.toString() ?: "" } - override fun getName(): String = attributes["display_name"] as? String ?: "" + override fun getName(): String = attributes["display_name"]?.toString() ?: "" override fun getProvider(): String = "yandex" diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt similarity index 74% rename from src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt index b6abf09..c081592 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security +package com.project.movienight.application.ports.input.security interface OAuth2UserInfo { fun getProviderId(): String -- 2.54.0 From 8120294d47ed445209d7010cbc8bb4b774f60e60 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Fri, 24 Apr 2026 22:55:56 +0300 Subject: [PATCH 16/77] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../movienight/adapters/web/FilmController.kt | 39 ++++++++++++------- .../adapters/web/FilmLibraryController.kt | 21 +++++++--- .../movienight/adapters/web/UserController.kt | 30 ++++++++------ .../application/ports/input/FilmUseCase.kt | 12 ++++++ .../application/ports/input/UserUseCase.kt | 8 ++++ .../application/services/FilmService.kt | 36 +++++++++-------- .../application/services/UserService.kt | 29 +++++++------- 7 files changed, 114 insertions(+), 61 deletions(-) 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 045adf6..12ffa79 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -8,10 +8,20 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase -import com.project.movienight.application.services.FilmService -import com.project.movienight.domain.exception.EntityNotFoundException +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.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController import java.util.UUID @RestController @@ -20,7 +30,9 @@ class FilmController( private val createFilmUseCase: CreateFilmUseCase, private val editFilmUseCase: EditFilmUseCase, private val deleteFilmUseCase: DeleteFilmUseCase, - private val filmService: FilmService, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, + private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -44,11 +56,10 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = - EditFilmCommand( - title = request.title, - description = request.description, - ), + command = EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -62,13 +73,15 @@ class FilmController( fun getById( @PathVariable id: UUID, ): FilmResponse = - FilmResponse.fromDomain( - filmService.findById(id) ?: throw EntityNotFoundException("Film", id.toString()) - ) + FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) @GetMapping("/search") fun searchByTitle( @RequestParam title: String, ): FilmResponse? = - filmService.findByTitle(title)?.let { FilmResponse.fromDomain(it) } + searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } + + @GetMapping + fun getAll(): List = + getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } } 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 0989dff..f5246cc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -7,13 +7,21 @@ import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +import com.project.movienight.application.ports.input.GetAllFilmsUseCase +import com.project.movienight.application.ports.input.GetFilmByIdUseCase 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.application.services.FilmService import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController import java.util.UUID @RestController @@ -23,7 +31,8 @@ class FilmLibraryController( private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val filmService: FilmService, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -58,9 +67,9 @@ class FilmLibraryController( GetFilmLibraryQuery(userId = userId) ) - val film = filmService.findById(library.filmId) + val film = getFilmByIdUseCase.getById(library.filmId) - return film?.let { listOf(FilmResponse.fromDomain(it)) } ?: emptyList() + return listOf(FilmResponse.fromDomain(film)) } @PostMapping("/films/{filmId}") @@ -98,7 +107,7 @@ class FilmLibraryController( GetFilmLibraryQuery(userId = userId) ) - val allFilms = filmService.findAll() + val allFilms = getAllFilmsUseCase.getAll() val availableFilms = allFilms.filter { it.id != userLibrary.filmId } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 5bdbc58..6c1a996 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -8,10 +8,18 @@ import com.project.movienight.application.ports.input.CreateUserUseCase import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand import com.project.movienight.application.ports.input.EditUserUseCase -import com.project.movienight.application.services.UserService -import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.application.ports.input.GetAllUsersUseCase +import com.project.movienight.application.ports.input.GetUserByIdUseCase import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController import java.util.UUID @RestController @@ -20,7 +28,8 @@ class UserController( private val createUserUseCase: CreateUserUseCase, private val editUserUseCase: EditUserUseCase, private val deleteUserUseCase: DeleteUserUseCase, - private val userService: UserService, + private val getUserByIdUseCase: GetUserByIdUseCase, + private val getAllUsersUseCase: GetAllUsersUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -38,15 +47,13 @@ class UserController( @GetMapping fun getAll(): List = - userService.findAll().map { UserResponse.fromDomain(it) } + getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } @GetMapping("/{id}") fun getById( @PathVariable id: UUID, ): UserResponse = - UserResponse.fromDomain( - userService.findById(id) ?: throw EntityNotFoundException("User", id.toString()) - ) + UserResponse.fromDomain(getUserByIdUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @@ -56,10 +63,9 @@ class UserController( UserResponse.fromDomain( editUserUseCase.edit( id = id, - command = - EditUserCommand( - name = request.name, - ), + command = EditUserCommand( + name = request.name, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 3622878..db3f4b0 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -27,3 +27,15 @@ data class EditFilmCommand( interface DeleteFilmUseCase { fun delete(id: UUID) } + +interface GetFilmByIdUseCase { + fun getById(id: UUID): Film +} + +interface GetAllFilmsUseCase { + fun getAll(): List +} + +interface SearchFilmByTitleUseCase { + fun searchByTitle(title: String): Film? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index c889946..b417525 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -26,3 +26,11 @@ data class EditUserCommand( interface DeleteUserUseCase { fun delete(id: UUID) } + +interface GetUserByIdUseCase { + fun getById(id: UUID): User +} + +interface GetAllUsersUseCase { + fun getAll(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 6ed4748..555655a 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -5,6 +5,9 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase +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 com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties @@ -21,7 +24,11 @@ class FilmService( private val filmConfig: FilmServiceProperties, ) : CreateFilmUseCase, EditFilmUseCase, - DeleteFilmUseCase { + DeleteFilmUseCase, + GetFilmByIdUseCase, + GetAllFilmsUseCase, + SearchFilmByTitleUseCase { + override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -30,19 +37,15 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit( - id: UUID, - command: EditFilmCommand, - ): Film { + override fun edit(id: UUID, command: EditFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -51,21 +54,20 @@ class FilmService( } var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - film = film.copy(title = command.title, description = command.description) - return filmRepository.save(film) } override fun delete(id: UUID) { filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - filmRepository.deleteById(id) } - fun findByTitle(title: String): Film? = filmRepository.findByTitle(title) + override fun getById(id: UUID): Film { + return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + } - fun findAll(): List = filmRepository.findAll() + override fun getAll(): List = filmRepository.findAll() - fun findById(id: UUID): Film? = filmRepository.findById(id) + override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 71fb6ca..4d16ea1 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -5,6 +5,8 @@ import com.project.movienight.application.ports.input.CreateUserUseCase import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand import com.project.movienight.application.ports.input.EditUserUseCase +import com.project.movienight.application.ports.input.GetAllUsersUseCase +import com.project.movienight.application.ports.input.GetUserByIdUseCase import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.UserServiceProperties @@ -21,19 +23,21 @@ class UserService( private val userConfig: UserServiceProperties, ) : CreateUserUseCase, EditUserUseCase, - DeleteUserUseCase { + DeleteUserUseCase, + GetUserByIdUseCase, + GetAllUsersUseCase { + override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") } - val user = - User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - ) + val user = User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + ) return userRepository.save(user) } @@ -46,19 +50,18 @@ class UserService( } var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = user.copy(name = command.name) - return userRepository.save(user) } override fun delete(id: UUID) { userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - userRepository.deleteById(id) } - fun findAll(): List = userRepository.findAll() + override fun getById(id: UUID): User { + return userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + } - fun findById(id: UUID): User? = userRepository.findById(id) + override fun getAll(): List = userRepository.findAll() } -- 2.54.0 From f563c3e7cfefdb898eae7250f40b5e680591516c Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Fri, 24 Apr 2026 23:47:56 +0300 Subject: [PATCH 17/77] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/jdbc/FilmRepository.kt | 11 ++--- .../movienight/adapters/web/FilmController.kt | 42 ++++++++++++++----- .../adapters/web/FilmLibraryController.kt | 37 ++++++++++++---- .../application/ports/input/FilmUseCase.kt | 12 ++++++ .../application/services/FilmService.kt | 36 +++++++++------- .../controllers/FilmControllerTest.kt | 8 +--- .../controllers/FilmLibraryControllerTest.kt | 38 +++++++++++------ .../controllers/UserControllerTest.kt | 22 +++++----- 8 files changed, 137 insertions(+), 69 deletions(-) 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 a13f721..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 @@ -62,11 +62,12 @@ class FilmRepository( ) override fun findByTitle(title: String): Film? { - val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ?", - filmRowMapper, - title - ) + val films = + jdbc.query( + "SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1", + filmRowMapper, + title, + ) return films.firstOrNull() } 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 659fdf6..df375e4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -1,6 +1,5 @@ package com.project.movienight.adapters.web - import com.project.movienight.adapters.web.dto.request.CreateFilmRequest import com.project.movienight.adapters.web.dto.request.EditFilmRequest import com.project.movienight.adapters.web.dto.response.FilmResponse @@ -9,17 +8,21 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase -import com.project.movienight.application.services.FilmService +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 import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -28,7 +31,9 @@ class FilmController( private val createFilmUseCase: CreateFilmUseCase, private val editFilmUseCase: EditFilmUseCase, private val deleteFilmUseCase: DeleteFilmUseCase, - private val filmService: FilmService, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, + private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -52,11 +57,10 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = - EditFilmCommand( - title = request.title, - description = request.description, - ), + command = EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -66,9 +70,25 @@ class FilmController( @PathVariable id: UUID, ) = deleteFilmUseCase.delete(id) + @GetMapping("/{id}") + fun getById( + @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? = - filmService.findByTitle(title)?.let { 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 32937c6..a4a6175 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -7,11 +7,13 @@ import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +import com.project.movienight.application.ports.input.GetAllFilmsUseCase +import com.project.movienight.application.ports.input.GetFilmByIdUseCase 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.application.services.FilmService +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 @@ -21,7 +23,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -31,7 +32,8 @@ class FilmLibraryController( private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val filmService: FilmService, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -58,6 +60,17 @@ class FilmLibraryController( ), ) + @GetMapping("/films") + fun getAllFilmsInLibrary( + @PathVariable userId: UUID, + ): List { + val library = getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + val film = getFilmByIdUseCase.getById(library.filmId) + return listOf(FilmResponse.fromDomain(film)) + } + @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun addFilm( @@ -89,13 +102,21 @@ class FilmLibraryController( fun getAvailableFilms( @PathVariable userId: UUID, ): List { - val userLibrary = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val userLibrary = try { + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + } catch (e: EntityNotFoundException) { + null + } - val allFilms = filmService.findAll() + 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/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 3622878..db3f4b0 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -27,3 +27,15 @@ data class EditFilmCommand( interface DeleteFilmUseCase { fun delete(id: UUID) } + +interface GetFilmByIdUseCase { + fun getById(id: UUID): Film +} + +interface GetAllFilmsUseCase { + fun getAll(): List +} + +interface SearchFilmByTitleUseCase { + fun searchByTitle(title: String): Film? +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 9410c3f..555655a 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -5,6 +5,9 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase +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 com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties @@ -21,7 +24,11 @@ class FilmService( private val filmConfig: FilmServiceProperties, ) : CreateFilmUseCase, EditFilmUseCase, - DeleteFilmUseCase { + DeleteFilmUseCase, + GetFilmByIdUseCase, + GetAllFilmsUseCase, + SearchFilmByTitleUseCase { + override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -30,19 +37,15 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit( - id: UUID, - command: EditFilmCommand, - ): Film { + override fun edit(id: UUID, command: EditFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -51,19 +54,20 @@ class FilmService( } var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - film = film.copy(title = command.title, description = command.description) - return filmRepository.save(film) } override fun delete(id: UUID) { filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - filmRepository.deleteById(id) } - fun findByTitle(title: String): Film? = filmRepository.findByTitle(title) + override fun getById(id: UUID): Film { + return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + } - fun findAll(): List = filmRepository.findAll() + override fun getAll(): List = filmRepository.findAll() + + override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) } diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt index c28b5f5..0784280 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -26,7 +26,7 @@ class FilmControllerTest { fun `create film should return 201 CREATED`() { val request = CreateFilmRequest( title = "The Matrix", - description = "A computer hacker learns about the true nature of reality" + description = "A computer hacker learns about the true nature of reality", ) mockMvc.perform( @@ -40,7 +40,6 @@ class FilmControllerTest { .andExpect(jsonPath("$.id").exists()) } - @Test fun `edit film should return updated film`() { val createRequest = CreateFilmRequest( @@ -58,7 +57,7 @@ class FilmControllerTest { val editRequest = EditFilmRequest( title = "New Title", - description = "New Description" + description = "New Description", ) mockMvc.perform( @@ -71,7 +70,6 @@ class FilmControllerTest { .andExpect(jsonPath("$.description").value("New Description")) } - @Test fun `search film by title should return film`() { val request = CreateFilmRequest( @@ -94,7 +92,6 @@ class FilmControllerTest { .andExpect(jsonPath("$.description").value("Dream within a dream")) } - @Test fun `search film by non-existent title should return empty`() { mockMvc.perform( @@ -105,7 +102,6 @@ class FilmControllerTest { .andExpect(content().string("")) } - @Test fun `delete film should return 204 NO CONTENT`() { val request = CreateFilmRequest( diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt index 39b795d..d24e60e 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -9,8 +9,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock 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.* -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @SpringBootTest @@ -24,46 +25,57 @@ class FilmLibraryControllerTest { @Autowired private lateinit var objectMapper: ObjectMapper - @Test fun `add film to library should work`() { - val userRequest = CreateUserRequest("Film Adder", "adder@example.com") + 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)) + .content(objectMapper.writeValueAsString(userRequest)), ).andReturn() val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() - val filmRequest = CreateFilmRequest("Library Film", "Film description") + val filmRequest = CreateFilmRequest( + title = "Library Film", + description = "Film description", + ) val filmResponse = mockMvc.perform( post("/api/films") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(filmRequest)) + .content(objectMapper.writeValueAsString(filmRequest)), ).andReturn() val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() mockMvc.perform( - post("/api/users/$userId/library/films/$filmId") + post("/api/users/$userId/library/films/$filmId"), ) - .andExpect(status().isCreated) + .andExpect(status().isCreated()) } @Test fun `remove film from library should return 204`() { - val userRequest = CreateUserRequest("Remove Film", "remove@example.com") + 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)) + .content(objectMapper.writeValueAsString(userRequest)), ).andReturn() val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() - val filmRequest = CreateFilmRequest("Film To Remove", "Will be removed") + 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)) + .content(objectMapper.writeValueAsString(filmRequest)), ).andReturn() val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt index df266fe..db2eecc 100644 --- a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -9,8 +9,11 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock 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.* -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +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 @@ -28,13 +31,13 @@ class UserControllerTest { fun `create user should return 201 CREATED`() { val request = CreateUserRequest( name = "John Doe", - email = "john@example.com" + email = "john@example.com", ) mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)), ) .andExpect(status().isCreated) .andExpect(jsonPath("$.name").value("John Doe")) @@ -42,18 +45,17 @@ class UserControllerTest { .andExpect(jsonPath("$.id").exists()) } - @Test fun `edit user should return updated user`() { val createRequest = CreateUserRequest( name = "Old Name", - email = "edit@example.com" + email = "edit@example.com", ) val response = mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest)) + .content(objectMapper.writeValueAsString(createRequest)), ).andReturn() val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() @@ -63,7 +65,7 @@ class UserControllerTest { mockMvc.perform( patch("/api/users/$userId") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(editRequest)) + .content(objectMapper.writeValueAsString(editRequest)), ) .andExpect(status().isOk) .andExpect(jsonPath("$.name").value("New Name")) @@ -74,13 +76,13 @@ class UserControllerTest { fun `delete user should return 204 NO CONTENT`() { val request = CreateUserRequest( name = "User To Delete", - email = "delete@example.com" + email = "delete@example.com", ) val response = mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)), ).andReturn() val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() -- 2.54.0 From c609bd1e3cbd72e9dbbe839a76c02c0922e5cf78 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 26 Apr 2026 19:25:43 +0300 Subject: [PATCH 18/77] ci(): added basic CI: build, test, docker build & push --- .github/workflows/build.yaml | 50 +++++++++++++++++++++++ .github/workflows/ci.yaml | 31 +++++++++++++++ .github/workflows/docker.yaml | 74 +++++++++++++++++++++++++++++++++++ buildPipeline.yml | 28 ------------- 4 files changed, 155 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/docker.yaml delete mode 100644 buildPipeline.yml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..4cb473d --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,50 @@ +name: Build & Test + +on: + workflow_call: + outputs: + artifact-name: + description: "Uploaded artifact name for downstream jobs" + value: ${{ jobs.build.outputs.artifact-name }} + +jobs: + build: + name: Build & Test + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + artifact-name: ${{ steps.meta.outputs.artifact-name }} + steps: + - name: Checkout source + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build + run: ./gradlew clean assemble --stacktrace --no-daemon + + - name: Test + run: ./gradlew test --stacktrace --no-daemon + + - name: Set artifact name + id: meta + run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.meta.outputs.artifact-name }} + path: | + build/libs/** + build/reports/** + build/test-results/** + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..f4a3f37 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI +run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}" + +on: + push: + branches: [develop, main] + tags: ["v*"] + pull_request: + branches: [develop, main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & Test + uses: ./.github/workflows/build.yaml + permissions: + contents: read + + docker: + name: Docker + needs: build + uses: ./.github/workflows/docker.yaml + permissions: + contents: read + packages: write + with: + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + secrets: inherit diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..21e61b8 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,74 @@ +name: Docker Build & Push + +on: + workflow_call: + inputs: + push: + description: "Push image to GHCR" + type: boolean + required: true + outputs: + image-digest: + description: "Pushed image digest (sha256:…)" + value: ${{ jobs.docker.outputs.image-digest }} + image-tags: + description: "Comma-separated list of applied tags" + value: ${{ jobs.docker.outputs.image-tags }} + +jobs: + docker: + name: Docker Build & Push + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + image-digest: ${{ steps.build-push.outputs.digest }} + image-tags: ${{ steps.meta.outputs.tags }} + steps: + - name: Checkout source + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Validate Dockerfile + if: inputs.push == false + uses: docker/build-push-action@v7 + with: + context: . + file: ./Containerfile + call: check + + - name: Log in to GHCR + if: inputs.push == true + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + if: inputs.push == true + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=ref,event=pr + type=sha,prefix=sha- + + - name: Build and push image + if: inputs.push == true + id: build-push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/buildPipeline.yml b/buildPipeline.yml deleted file mode 100644 index da17798..0000000 --- a/buildPipeline.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: CI Build - -on: - pull_request: - branches: [ develop, main ] - push: - branches: [ develop ] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build project - run: ./gradlew build -x test - -- 2.54.0 From 2525f2f959f7cdc4474acbd80dd532d3e6e0f39b Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 26 Apr 2026 19:59:17 +0300 Subject: [PATCH 19/77] chore(gitignore): added wrapper to git --- .gitignore | 3 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes 2 files changed, 3 insertions(+) create mode 100644 gradle/wrapper/gradle-wrapper.jar diff --git a/.gitignore b/.gitignore index ee59915..795dcc9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ gradle-app.setting *.tar.gz *.rar +# Gradle wrapper +!gradle/wrapper/gradle-wrapper.jar + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 zcma&NWmKG9wk?cn;qLD4?(Xgo+}#P9AcecTOK=k0-KB7X7w!%r36RU%ea89j>2v%2 zy2jY`r|L&NwdbC5&AHZASAvGYhCo0-fPjFYcwhhD3mpOxLPbVff<-}9mQ7hfN=8*n zMn@YK0`jk~Y#ADPZt&s;&o%Vh+1OqX$SQPQUbO~kT2|`trE{h9WQ$5t)0<0SGK(9o zy!{fv+oYdReexE`UMYzV3-kOr>x=rJ7+6+0b5EnF$IG$Dt(hUAKx2>*-_*>j|Id49Q3}YN>5=$q?@D;}*%{N1&Ngq- zT;Qj#_R=+0ba4EqMNa487mOM?^?N!cyt;9!ID^&OIS$OX?qC^kSGrHw@&-mB@~L!$ zQMIB|qD849?j6c_o6Y9s2-@J%jl@tu1+mdGN~J$RK!v{juhQkNSMup%E!|Iwjp}G} z6l3PDwQp#b$A`v-92bY=W{dghjg1@gO53Q}P!4oN?n)(dY4}3I1erK<3&=O2;)*)+_&gzJwCFLYl&;nZCm zs21P5net@>H0V>H2FQ%TUoZBiSRH2w*u~K%d6Y|Fc_eO}lhQ1A!Z|)oX3+mS``s4O zQE>^#ibNrUi4P;{KRbbTOVweOhejS2x&Oab?s zB}^!pSukn*hb<|^*8b+28w~Kqr z5YDH20(#-gOLJR&1Q4qEEb{G)%nsAqPsEfj9FgZ% z5k%IHRQk6Xh}==R`LYmK?%(0w9zI}hkkj|3qvo$_FzU9$%Zf>(S>m|JTn!rYUwC)S z^+V+Gh@*U(Za&jUW#Wh#;1*R2he9SI68(&DeI%UQ&0gyQ73g7)Xts{uPx^&U`MALc)G9+Y<9KIjR1lICfNnw_Ju8 z-O7hoBM!+}IMUYZr29cN{aHL&dmr!ayq7;r?`7M3z+L@~Fx4o}lk{l?0w3=rqRxpv z0Tp-ETUvB<*2vTh_dr%}Lfx)%pxlb$ch}yCCUz6k4)hyMJ_Lq$SS(Rd8aWG-K{8TD zDUtTM2SQ|y5F;}M&9eL-xGpj#vTy0*Egq$K1aZnGq3I^$31WARgcJUb0T*QaRo~*Q*;H_Jc_7LeyDXHPh?}Ick1s{(QZWni3%OL|i zJ7foQ%gLbU+dOZP7Z^96OoW5YbS=0%+#j3#o3bYsnB}Ztbu_KuFcBz9M~>z z{s?I|KWR0CJT6eqNlIj57Jq@-><8 zV&>W=5}GL`X|of9PiXwZaoKWOehcgaB1!y0@zY^+$YFgk3UB@$4#qATzJk?b^M#iL zKe}&w?|SGj<-3Z>pDd^+G3w_>76zq%EZGhqzOYx6YQgnb;vA^%6(Sx4?gytM=^m`C z@c+mG0LSQOqF$oK!j8-B4hG`=`%8Hp#$+IvanscDc42T#q4=v2YuoSZd{VS%kBNtx zLd6U%s>y+0*0?dDt&wJ`=F&iRWyJS1Y>kZds97Z^J?Kmeu!Fh-L+F9?o#ZILhhvI& zyE^o10y()W>x@1skNd<(ehL$G%S9yZ>AxGNktZ_$h9RD?hd_YxvNIeb?3~*XE*54b z;}9`U&d_XFzBbijUqrX}i?s24Ox?EOfTz$aTz;dtw~F)!(XK9voHS_ii|YmI?eRrX z%Gr=T-7Qx7eB&|iMk+jCw4x6X6Hae`0esw}b;uVy6ljeACOq{ZM6e`2k%XdE* zcZotR`H{lmO?;6sfMz|Xv|aJ!F2{Ucp1Y5HM68;}hw4h%ntF`pl0QNFk@W?2S67+W zF1AU5YS7<_7H6+NrwMJ)&D8^-Sgj_rttU*gt3dvWH^sG8W6BbhtT{Lm3VV5cSo;$3 zNuSXq<>-4y>$9__aC`0aka&~k=}#N;Co3O<6()7bWgAZuB~%E!lv`DCbEMM)G$IQ< z*b89{3RV{((?H&X1kBl8+K_XHL`Hc=25|M6Djk8YZUc&s3Ki&|KcOb&!$LVf5~6*K z>pgW7g-7ASM5ZZ5?Ah_e13r7Z98K>?leVWPNQs_MXx_&Ftg92|SR`xrt$4|%fVGS- zTNZt(a#pl7RaYzzJlX1vk0kt*Vpxw_{M%KG%Q}`scIVU

pVX@HRij*jw$g4?}Pn zE7RuaO3V!l_a{`|jsZVjZSR#tYwAffrvo3AAynZ^vzgSR#N_HZ6Ark)t{_hJ^zSa( zT@R*X#7rxlaj%ZVUZ1?7!Q9{bw(p9N;v)bZUqGgPC=O&mM zRy{1k%Hlr=aPWCif%s7!4cpn_cTyB1=#k?e8m}0C$)+&PD!&)F?>9;L&0Lpv)ZfP| zJxlb;PjKA4x^1R%?vIk=kv;C0Y*;|7*_mO)hTMlfPH5JcHa>0BR$wlt@&-wZufD82 z51*ufTeW5&M!0=a$FS@0MJRlk*~l8^Wl?2mzt}H8ae}hQ7tSz0sBJs+8lQ!`o(21B z@HNyMoH{;2l$8FopO-a)0DQ&f_jq)|ZPO}_AjDPtuOl4>R^0rLnok(Ezuu@$4lJ`w zQ6-4DQIk{FwQJspTlz!>L$CVj^cN<|)t^;jR~M^L^a=dr5aA!{qg3Ek9p;X{QRIg1 z1oE`2L#=6s6vh%=R(TI9Z5ReZy&?Jtj8aEcyCiP*YaYk5=!QbxQSz|aBk58{{@nCc zSY}$niG-_Uad_iRV56Ju8STIoe{*WWn3_?3>0V>z8)z@g_|dm5vKgxu`{>`)X}aw) zyd~I|(HFpmTO&3smRUnoB$VU&snAXEY(aq=te76JpanOdrwx}UD4D8MQ34z&zcD8z><`W?<_; zvO01*U(i7v7=EAJ@&YE- z4Cz5FWI`J^+_;Ez1p&jMET;4j<<0ymV(~ma*ooWab$s6DuWt>sP0$fuap>j|b@rOb zu^i4yE`d@_H>;F8*y;JfvhSY_o*1uZB+)0G+l{2nmbRR>POBwArWP}e z*`!BSjr`p73wW@iA~}h|mFJDOdP|bAlqD)jwN_vU{ z0ntkb0iphH{UY}N?H5%fR25`pw6s}OWdGYUvdqjNg|VZ<>;{luC*iGup0bRpG-1*u zLmD>P9mq$M!k->%T2{@Ea^ZR|8LZp2lzpBQFAfvFIUps_-Vxkm4ldisDdti7Bn(qo zAYco0<;Bu1tt6?z=(H_4yD~5qL+2##Hfo|6qRB-vFmQ}Xpo&Qc^GdrM6&iQtrIVT_ z6q)qyz^vmNwsqEnS6Vw6kZ1XSL;dx94s%n6>F=ht<9+@6=i_*PK35N0Hd_yKD<^9< zODB6aDOYD_a~CURdlzd74_j|%YZosWKTB&jFMC%PR!b*yPtX5;conr7MQ9H6g65XG z7EMw%FD|O_`*U$^ye1(o}oGT&v6r7mQ)iC|9t;%`Wt_`W`dAAT;#O+)Ge! zPY6Umf)7Er6YsZ!=pEz^$%f~wDcEbz?9OR@jjSa(Rvr03@mNYZ%uLF}1I$B4Hj~*g zWOL7pdu2IQtK=^>^gM(G`DhbFDLZd6_AD4bHKi+I<{kGj!ftcccz}667=-{}7`0~m z(VVjxK=8g9faw}91J}cSq7PrpJi3tMmm)~lowHDOUZfP++x{^vOUJjZXkhn7qE^N! zV)eH6A;SGx&6U&c1EFgS6CAwUqS$$N)odq!@3|yVs}Lv@HEcBe?UTqFr9Nyab-F_) zNOXxFGKa2*Z|&o&`_h+{qBoSkb^_~=yo&NYU~qe1|9&TE|8^(T{$GE;wbq8_qB^!o zWNUaUctH}Q+oBtk0YrkWOS_G@9aP2`<7DUWB~FndluuPn;S@}GiG2Iia25p++<(6C zea7mI68gN(*_{_OvF&*I?P;Q+ZzmWcYlw2__v`ENA>SnKs!v266LL&z9X9riJ-15i z?+VKr6gj*!-w2v^x)aO%fNEX5_4-u@zsW(~Hen6*9N_w{$})i6E2y4Z$h5?;ZS!i! z#Q>M4TTsuI9=p|iU9!ExS=~piozz{USJ)(nwWf1TYy0Ul2epIh)bcRZA|?PU!4VrJ z^E`vzA;ZAfgAm2#Tu0K-8E!~1iW6{oBl4lS-5Fc2%_saw>BKrIuW`^4za9w7veO)+ z)~?rp*f&V-xoXD~e%a9Df~ixzE@AMs{a8am6R+SXhXPfqv!>(-9^g7!X;m~14_ReuNF;J z{)~ysZBHLY*>ow*`^ie7bhc3H$N1qVxaGt6xFusWF%owkNrl|{nn?h~fjxFur;u%{ zPf10%f#iPYY|=!*HH!WbI~jskWo9 z%vV&6J9*nXeR4B9>xWboSk9Eo;%Rc=iE)t~UQbj~kZ}4=;KwNN^|%wM#RG(8q5C1k z>f6|ABKw4TzF_F&4eI{KI~)AqlIA;D%ZP^dwp;M?kIJM*Nn1jZu`KDt@GR-|U9|cI z1nW&P8r5WLE6a}#e-Ogslihm9#r{J2n@QFmcUAr#tQi)Hpw4ELC$U8t>j~4TVQMBeq1ZPK`deHgU!QY`%5H8F{fX}O}fV)= zw|oE_A51>pxJ5Kp`wcemi6jERtbEsty7FV`lJt6lR?dhxnyg>(GW9ZID_9Ii$2i#G zdN8@uX$m?D%-Eq1v57~V)v%f8Se#&b=gLhg@U ze$?D?oYb{i2w@tccty}{bKwjeaiTuuL?Y(;;{c#-8v&4O?%RgKiToLey0P8POL9Kwj|;h#ul~;=V1gq!oLVrP zlwx-xwyB=#A|5Bw>09TQ+~jkdmGnJ$YrZ%|h0VcBeiw@b^J+BlumSY_)*u&%R)>JW z7(0lRtg+C9u68--7Kw&9^AeL`o5cpi$Cy>&&kBT$@!Nt_@iuYI<_q4`b~7LsTn<38 z@q_=pRRz<8vLEbi`ICI> ztVoyd+|~B7*q`1YG&7_fPT`QJ3v;k-%itr5x!$sYj;Y?a>MMPep@UxVTF#+1EV!N> z_6H2hN=N0Xcd@IV%9NJvYR74G?Ru3xuB)BwZmD7Zq}qomtW}na^#(qbREUPzmYN6p ziyU)gFriO8NCoWQj0cX0evy`_iBWmXRAqjv1s zUZv#j5;NRuz6K0Q1#jyMzmijh*97>D-0HyQpPUWas$-Ay(?|{416{@{5KP2ka?PEc zP8oI%1X4Fzj3>}EjfCUk#(+zT!v(}iw3p$!^Q@S^2sG(pZFxXmvZD}i1S#$t^890< z{qTT~_hK@t_;8eCDm(0+KRWb6`iW#<@oqli&F&)ud!?o@d#&sm5DU${T#J~}D*(W+tb(BT9{p5*$hl>S5#Xso0)3^_UA8`Gf}moKyx7WW&Za0bEVdTef`-Tw?^P zr({3nnvcOQnn@C^v4ZlJ=yE#rD^h{bm(KZBy#fUGpq~?g>prt}JS^tFeS?=|m?BaE zJ@8ZH<}v0~>8VyqJvJ#}R!cY&OHr9QC&Le-`&+%tpxZJGbNA}s(-?PsV!b$q%&_0+ zC$k1nfCE(B(j~5wJeTrsc466K?t9o4ZikU!~82D-nTxfSLC5X_z)Z!-7`Mxl(>;hU& zwS|rLUmoy3J@!cI)A2T1H2*w45C!(c8--k%iCVGPe+S%NbpuMfDLuXR2R<(-Sw*)Q7->L{-s5w3mfX% z?>dwU|98h&rogmI~+Qsg&`Cy24+@ zI~yTIuWMrcD~v&N)2vQrT9SR!dG`fB?z&e!-|lV$LSR7AG(bHzQ_;o8Ks!klRZlHs z@5q$YVtIP|a<0ze&Q5FD#f;Ht7tgR7)XE`-e2 z5vVHX7yNJH@VDzGGCwD3&Cv(4HA~0rre@MyJY3FgVyd_{ea3O;yVeEQJ4*-)5qs33 zN70F!zWStyRS@NYDW+6gDxGw=`~nt08}PMWhCD6!_JVcmsBLH{IV-gSc^LgclTkID z#*&}F&%i9%MP&SES zMzGEc)ZNPy=Pe~PxMIJEGf}r)daA7PevJ z9~2FSl=99aB`|MZDS^cR*40E>X4EU#m6FHPsurfX_nA42aR38WBr`!09eh=CTMTU4 zl~%%^;KR5%NlSXF?X@|}Nzv4dcNN+y5A)(8=UF7z_hF-i$MKDqj$UVS0g-WPyV6OL zuL{5wAthWbw>!-gJc}jYTscv0L})-yP{rUPfv+k9P(53RgvQc{t83(%8=TWEnJ)wh!#>`}qP_=0d( zpXBD5ujnfd8S4dSaF&g4qmxD%ZcDIqHsbGQdogW$0;r7pe{%LxZvJL` z)Sw{e>}9oM@k=(Jszzv1@-s+_s(2(wE3G)fjDXHCM`v_@jV67e?bV5N-QD0$C3zKK z-N)guBD&o&G#=>Pdw8OLjXj44&;h>!YZkRl>@noB4|)5}Ii9GhIkpa4&kWOcOhyRr zYx5XE6Z?9%mXL=$4#3A_%wWajqR1kAHqKxmm$x5@7@e3hWo_MNdf6MM9_$VgpoL*$ z(q{CFrM2<>{&S6Y`Toe=szf)7`jYyq-w&el6W+@arE9)tXY|B9U+jR~$~pq1W1&4( zf1+!D9CG<}H;#`2V#UaNc~{l_5Ivd<$=ro0i`rjH&%*uOT(BN-<|^pgFE!NF@KU5* zj~NZ;r9SIE?q%=3o+iJq==Y@ncGrYy%J1c~_suJ-ISHZ8;}7Ze!05^VW#JnSZ{I*& zIh*vqjYFYI!RPlGne6eHPoDm#*a$UbxXeR}t=rDi%u@AYv^@enQ$TaphrriwAw^mOF=o zL4X{Io~71KNrW8qCZt1ZAB`G432Db(WnJIQ9Xk;|poyayjFsO+K(=F|m6yMLxTfq2 zhmA&U#r#NiiRz~z8p#Dq)Z<0#?5fl-h3c zk>UdIdslOZew?=b_};J6j3dtba-*VcI`qcbk;`^8>kFo9S}}Tt9TLu=Z1ztD2YHPu zSZgnhwj72$6Yfmz|3b25Ha>8oD1+a}*z1w7`#@Py95vVcvT9dWRWBso7}3^OX!<5J zFcKmCk8_mJw*DB@`1;2cs z{yw*z5cIMwIsSwBJT&y%JBO71bq8VD$xeovL@et#f6tiC#UiA3`K|1TtQDghPWN8P zEdjNjpM*NYM&Wyck2a`6H)|X}!r?3)uN- zo_>B9W*}-{yshhLL1%rV{8BzHnQYJXCX7}POY9l?MPqbvfq+{Hef^*yK&|jtpz=8H z_xgmW~dlvT_#3qXgYW<(+du)1J=XdbY5|3?mgBC!dit@|i1pYvZ=t));Ws^GhP?7etFJ#A8#?jg99r^mOhBAF0jXRypO-&E7a&sa$~AcYYwYm|HmNboB84e)(T zMbK`=mwl{EXTkYc^^u;wdYm$I2%i?8R^+Xf1%XhS$iBcj=n`dTA0<<%tBGKw#pH_< z7yYlWMvJ8ygFM>pK6F^?P(R_40w80B#^gTpEC+Vb&&-!6^q&-vYPz)}``@sQ%YNR_ zNOaXl*@?QG{lR#3Gsel}$Q`3G)^I1q+oN;@z?#FkR0;YMyIDh(oqHLUT< zk%gnOLPl=j+HtG?g_Bx{A*S_^p$TG^ut?Hm$v?F`vMkXn_0D5fYW{-H;0MI!vWi7E zW&b|5>`<5JSg1K8FkRW`QJo!YzAX9xSr!^0mZUEfk+e_~Hmy%77CP-~XCFy_R*4Ny_`rntN5nAV}SQ6N8Kqw_8j7b%7ZDR?e^>X8K<8bXzAdC{U zbZE%9m#;pqPn(rbEIJk19@n!JN~SaxS$`yFfwM#h&6bLdZ|{BnweivPwU}5iB>tH2 z(DDBM^0Zt_|Dy<)@T|GowT3~5P4IWdOi;~Y6(Z-Ao7$ppc<*sKv0DE2 zQ7fJ1S??EtK+|tfC`0&UMEUqs_0z_`Tr-_=AzULJshV->?K>ppr+5%W&=*Se!)<}1 zK+gBXZb=Qr43OMnp>Vd>VvP)(DB)hLH~_LNbUK&g#Uu=wSZ1f)8T(5(=Gf2ks`Qa{xr90g&RZXd!6JA1Aw zH~bvvn5N$5qQCvfR*XVJ6iySM_p3Q6jj2|AA&s@!J8y>W`{M#gi1*@29nCFLvMWUb5-6g;Dkqe-W%-k<t{j$y~ zZ7Jv-AR3~g)EWPXi8B5gmP=?)iT9XMa^Qn@Af zcoYxd6o}pTBdGwc$_4n>X5-}pENro_;kLbQq#Dhu>sziG^)7u&Xr2tw>{M4F<>)%h z*d@4(v_5g`Ak*QtHlqz^vB9PvwxsxB4q`LjQ9BXRa9v*#!u0RuEzlJ)ycVg!jAzM< zYV{~*@!zH&U&Ky~T$-R{;HFjsr=cfwi1SeDIht|kx#-D|XfF8RB4qEs!reEjM<8hv zU=xYuWa`j&_=@NplwLBteU%fmX+IHI4fhNhJ(9zDJt6~n@mvvoH+3AG!+P>6J zoG)X6Iw7fjttAl^B_}-c(@4+*+h?Ha7Qe8QVJ}i!j`ualoyv4$& zTM5iU^f(^;K#s+&Qy=p_&aT6e@joE3-5OeTOqCbNH~Pmb+&wu*+Uz_5&+87~+0ARQ z-azQa1RfyT*cjWoYYQtMYJ{x=QO^7#VGg+K^X1L>lgQSiibOYd!ftWVlqi~aDO=o- z+b(cjHc_b9&hB%0moVs3e~5e42#vIrUbmI)E&zIrg7U)iRg@&c_Im;P!V|MaVmROn z?(JpEilGtTNb(aa@@UfeGqinFWh)iFm#LwOlE)&3%1~3TQSZ6O+$L@Lu`y7R^%~B7 zE}woyC&?yDU{|jD)NRh;$_FhR(|uJmsygG?T>{I2e56P`okogpWz{AU=73=yy67$ zcC?$q5B2xzV+^K8>>@tTcR2t~S#l77fpjIs0i$7=-9#ZS6mO&XpEqzg&DE)guyYm} zBoC;IEiNnv+0Qh}gVI%z<>#T09$#O%uyxfmobpOu2;?=Z-aZz6=B6kz5tC@rCfGX) zm<}1)3w~Ak;sJLFb4YQ8qVXCvDPZy^^(`&U1ynG$w4j!T$Pp2^f@mf0->j*ie}?xL z7WKMq_bK0TX!EyC5YGREoBl@HlmF3q9iv-mHLP2?PR$&VVlu(2lhn8^qDPP!iGg?h zzIDo*qoU|zggy^{%OZ?O8VEtAn78x`78Z~9{lSORlH*gcFFj!%J4HSZEP6Hzx`^H{LQLn>9BZE|(h!O@#5EOOBZcF z6-BayPVRUt0FB1~Gxql91k3tCxa8S(1yF5Zj?JXj^bmd60?)O(ng`Cu$~PW3dr}X8 zN0(%@SE59PaYtS_2R@rPDH1?-YAk&U%Bs#Z=4V}EIOnPTm}=;NWXJ80W5v^rP&yNw zOx@d(3Cb6uuitL3y+uFwv9=7EN!DQ1^%`EH2`&8D?HfvbAJ)#-iI= zlk*%1isoKmj-Lz`F!S+fW>x2w%1EB67abZ-T~^X9AReExl7sV@p9J8-1MZ>)VHZIm z?34yV$eyp&Kd(_of|WxGRb7B97~_HOR0NM;!K-gm@lH*%e@jhb{|Ov)Tpa(CBr;v= zQWZ-BT_m#=dlD(b6$e{ysnx3s0iOvUi<*Owh`j_qD!OBrQgpybQ~6jcbMp(ZWJK7{;R~r`CMiT z=_TjMgTlunNtE_VbG3eEqBqYns zV(n9T5S)pHyxSo=K-cG|D4z%`iKj@6P=$8kBid9^p^eMkn)3_HY4ENhpZ_?y#~&^q zTK>Z47dR=-AKZP##bkI~@>DexVZ9&9*vlk_BG!oJL1Ei#M3yJM(huR0QN0~M65s`i#`o=sciY?Ti;BPs;rIZ*Nq zOLVct7)Utdh%@Wu>TOw>M#Qu?*$o%i<8yo3KN|t0Y>nlq@cvM>s=!?CtyXsp#$?kii@j51YSaSHmqcD8K`ZPt{xYoH2h@X=f^)X&z zFqmL5sjK4cP8)@&nR2(wmzuA-zqIjoejdoZgD@i7SZ=glz76thfPhX~?i}^91xVVqU=pyesPK|Ax?EHnf z1O&K~Eu-T7cXLWl?UmAoE&TI@5*p(q*457~$mxu0e ze`?(Db8+hu9<5=8UiJ0_XK>hNA3^o12oCJ9D3=tOW);qG~lGfzo**>Xb&J}^Sz2Xu@*zcJSZM$@pHRhL$(%F)^$XaQro=Z}n;Ggf(0%SH%kli*5S`#7~u z*M<7&V*x48gsm0 zVUA_fXxXOx(k@c{oqGAp@b;izt}*_E2Yg|KJCV#CU6bcBo;72f!e%Kp2cO{V?3Fe; z>*8^i3-tkB7afkzC=wr4lTZ7o zsztT)HP5h$sNA@YlZtsRl=e&#Gl(QCszU{lpV(7~#vo^tR@oKk+x_vA>{9osLFsoy zS5)cL5glpM(sKT?8kN0^6 zqO7i<4UJYoF+rGw z)XET!cC!7sc9=ADGaCx}ewNH2F=eNn6mB&U6ll_bUDLk`21UpO#-y7->yTKIaI zZ~FG@O%6h9oJ%<1*TaXGsoji}?}tFbJVcwX1M=*aN60z#{5kg0_Z5>0uI~9vyp@R? zF(fli_tW(z(;EZXwIv(En9K(yAIs5~r2#tmIeG283az@`SA{HRf(#eVG=i!Po8$Iy z#~C&U@?B#rxgN=)qPzmQiPeE@&*|`S5~|rUOhc~rg0=`*x~v)Buyu}`;_64P7&B&; zX}AjY06Y@6)a?YSm-GRO%6f6ePC<^5w#0~Z_^LUu8VNnm)Q3^EfJ!W!p_0zgloie21K}^yuphA{ zr#G-tJ(dn|L()_VxUEim`lAM%-uW*Go?6X}k%Et&h0-V;ux`rvnYSm0U3mpf# z+auH5I<7}3GpsB~X9ldCt!$yBe5gUfraC6~=t%kSWLP(~_J=rU7 zR0Q{HWo|me08i&@@E?wZ^*zdJ45^LAG8Q_~NJ{>u5p<^$TyN3Jlg9x4;5;yoq*mdt znlDg8QcrIE?D?N2zrl!;+>Y>FoKcq~I;7>68J(W(V~*7VJ8M>A7|^ zP{=lk!0_Pc{oOSi0(6+_oJ9L%mJ~cV#qP_l8Vt2^s(wW|U9d@L5YO|Dx&W(SYB6TU zVvSt;VL?E|24F%SW$}4LUc`Ej;2X*s~%}Zs}ENa;}C`S-lWhTf07(0-sp+ntHd% zLgeH>7(T&*a9hy2z`|}sD;WmXD(L#Ye@teC#@?WZzZ0D1-x3`2|8_+Gi{Sp5)%*+1 zIjc`84vAxnSUN7Q{Hj{6i)EG`!EZ(?k0FQU!(~L0%v?O+CCR6@re%maiG0RmEi2lE zf7aM@9>~v~`Z&|Ub^m&Q3%iR?1l7RC##cw@OCAQVDA{%iC*`|?vfx+SJguGM=T3-u z4&+u)a!M$B48?#&<4vsFAXRj>-yxCvz&uuv;~frmzdtFPFj)L0BsSe*Gmuc`JD!#z zPa`c$gHeOUnc>^CEoevD+?_;w1|J|%L z0*cBks6lMxj!yTto>uK;kL4>$Rwc49p87NFU#fJO*KMo$Zewfzc8K|35;l96_aROf zb0;<%`}g5;b#pH}Z4YxFYY$IzCn-B?OGj&uf7v^4ohe@|9sECA73_=L5t!SW<_J&} zGg9=4nxsgO+&Q?^;wai+ACFW({&aY@f|5)>U$2{*-o+YYL29T-j8bB!`?2O6xB*mp z+m+gyhKbikZ(C3UnQv?1h^n0mCoT zG-)F7l#@A`)%bDwv}82PRoxo`N5Pnpx%LXG{7CBroox5+1)Lo^iuuGn%wB2(nvydI ztf;oYgnZ&zj>dZcMJ8SZ48a}_QZq|V&|c;}^%S&F0gedlP8tIO2R$<l0~Y0BWA( zSV|vwDB)Es1cO6Dq94jGL!#akBeCo}wGTYxbkfJ?HaSvNHU5IAga=PON?4nYe?HDt zz9--xcJ4mr8Hv&`-Pnm^es?x-zu-vqF}@0PQrw$uUTGzZBaPo_tZ|6?!%1$GddLfb z&CC(L)r?4F1VbnFJS~-H-m6mvRWiyVG7iI1-yhTnxW4%V62OxrjwT1wPAq-1?xeY3 zu97J`a#Uz!v#4y|8fjcuT@@ZuCUGYg&E_#?+;;)qd`m!jTA)%IOpQ?9;F-FQO+qXt z`z_Rj1`W8JS5BQCAb;9L#~CR4kV2p@K8BW=osN~CdGpmvj1%vXp(m8PJO<8E-uO|H zKjAQ+ABcrLNeMYreKI)BLzK*JDkHnzBMT7j%B~n`y*HS(P#=B2&2l4Yt`TF4VLhS- zM)_I2ct`%#d7>=lTbk<`4dD_xu)G)9RkK(@s;*&S^S251p!_$ZZHu)B7$M7?lHr-W zF%kEdYSwBGCi?dAMjwuuQl25^@qvB7`K+O3hKRZSSMK$|L=-#52Xfh0(%of7Slg56 z){|NTc7J~inp2I8F?ICJGS>rwP`NzKI!b0&NV!ysj-Z+@6E5SKuOjh|9@9KmC)Sq6 zc2*b44y~m+U);H434xpz7!4(t+WhIxA+fx@Aj-?SGo2BfY$dv=n1dS9rJ3*GA|GM7 zEsHJ%0?m=(MMtZJM`;;ImPA#DeXRr&oCH3CK^`x-Th#6RZ%;(*j_1a+w{&)aShu7r{tdXdk?WJ-bapM0|s?&8F+kibcI;Z z9Z-UtlJw?oG&;&NZSB9IEi;x5-qJKjWQrGy5d$ARAQ$wA@+G`d4m>e;Mm1sNfBDuX z;AlPXi|TGm(BpnE8T-ZXf{W~0Wx0qQ923F!n=H|$ktTp_<36%e?#jZTR%lsE?s`|G z_T*G`Yot#9M-G?e$E8&Z4^~CZQy!|3PN*F zDNfkD=^5SkBe6Yl_Le?z-ds^Xu zUGK3)J3ER-q{i5xeH_LQ#opHd`kzkZ8OR$wXuGOI0S9!4$bxd9rX#XpZE1rr4^nlI z%#Ifniqpe2QUU|_*1hla_WJzF5>$w}YuHz!Bn7$|L3T1o(*;+m?~4zM+b*Rf`2F@C zFENS_$mw8?Q|%@8ZDthiuM{w~NTxxb&VSsRle7&MYMAtnOu9n!RY4X8?EYiSeikH9 zOZndU(*0WjmH3|m`aikY$<@;Fy}`luezV8P+tc3XeMs5KTEf!O+S60T+{N7Xe=)PQ zhKd@t1bWcS73alQs#@~xV;CYJB5Mi?KBm+I_4{>vPgk`|r*9%;rv=}|<6hAJe6m%Q zMI{z_E?vq&91RPqy7IqXu2FoPGxhxefqJ98J2f-&`?k`IayjoSKR?nE_Zo_J0q**^ z=CMK65eJ9MM3UF=fpVw%jQosAdgrbkV|?jWk^G=GZgIWH-m}@m#m}e~pO>~^LxQ1C zxf5=MT9cUh7zX(?ajfHlS0m4UuFZU?mWD8edgL(v#~-b6dRBli37)yq(dkXa^0qYJ zm2>PSwXHmOY->)I(>c=@V=H#cH4iqkr>!Jcq>Rj7HCe5!sF`+DSryVrGhj1JPn0w1 zpz1F3V?}jAmjhC2W=WIhi1|62^IeKs_Vuu>tvlSbf{BEZssNH}YC!RXPf5va8 z&*O3h@9IqZw?VV$|3rnim%S6)e?vph!`#iy+C$pj^S%9L@&1{si;jnrl&j0TX1^=> zzle3jf3?G?B1XQFBaK`)JeJ#K>clF%=Vunm%H)`gIijk*u5HkZTQe8UY_h>oeW8^p z@_RMWVv0Q*F@)Uisoy6=JZF1;Y-Ts?hz7wmqN?rggTXHQJ*&xJNSfp}aD++2QG~si zmZ4!fZLnB;l)F@pm1^KxY6sa9z3@2v>*mIZV!qbQltmvKmnn`wiCxdz|KaPMqC?x7 zcHP*vZQGc!ZQHh!8QZpP8#A^sW7~FevVL5gZ|}V>M(b@{_p08j-tp8sUL>;HOB^b$ z;hIbdt|h(^Lz4!n2$`tDF>w>d+R^r-o8L4CV$Dx{(t;5vTIc;CPmAYCX2oT221P|P z0{m6DMhT zWW~*jfZ!{&jQk}73p}09Tf0mmdonALDG0GIE_*DY+Wdy$#(|jSR0=Mb{Usmq-&*Ok zCsP?iLH+L;SJ7sgXGBvgEBzL9X!Z;RdYm;+&8*;3+WY7|s0-y?RN9E6UFwIYEl&bu=-nMHo)d+Jw_>@v)eZkY$8$E+&w}~w$k+G*`#;JKQIBmWvt^#A{Oa{KQHq8GHYbN&e;1A7?*3)>&I>Ywl-Vf>E( zvQe0@{Tbw`B8+7nj^iMN)JBJMJ$R(z5LXRwgg`1KAfa*irOnlN`N+}PSeahWNpMH# zEkxJ;d(a<#rx3vg97J5ZWNArdiIsWV&-)W>2LT?HPe->0&o^vFLa%OWuTVX9U$?5V zfejQ?X|e?mz-n;a^uZt!@!@!QsCW=UAs?r zRTQ8XNK)|mhN);1*Wsgp=~a(a(w92^6ZpiaKY(SMu4&}wp%6OfyRLceC%f=xCKu3qzu@%oq+s|rI$JfnjjEiSl-yJ5 z&C_g*h8aF>XB<2ZUUb{fwE}K_wFQI*pmFoiWa1jwhB&aZpsjDf4n@s1PUvh=bKk*C zWaM%?xyG~!JU)K8UUYy2;p+0qDDAGskPGj)v*r6B2BAdWoLy{KH(Q7IIJhB130S>3 z=toe;P-9s7>Z@J+)~YG92JKow7C3C^J#6P|jnPB1!Rwqme_ipn11EyPmc@XS1EHFS zS%uv?Mosl{H8JrKN{f#G3;|qewLxT%X4^u_i>Fz}0Hd|^pCXn#=wA=R&w#{rDMJtI z*&o^M#SswkL;ycEj3FkB7P<59R9AXVo&TlI*!q9-F5_N$gO7st4#Kn4&qAwL1 ziF<%!Jg8Ee%Rr3Xvo9C&K|l*sRM(}efz`Gqe8mXaZaT$^<)VsFETikCE&uTWs3DGx zWx*Lp8pM_RVHS=@z8CgPNe)#U0t7Cd*wLtMBn#x}*}i7VPbu=sc9D}X;CdTPQJEKU z!`+jf%KLMi%F^;EZHM}qMQrSTOF?GVb_N7Y78K-1DWMeAJ>V^4{!G4ONMXe2mDhTE ztfTP05-4YxaNL=mTV9CBs$FRCk1*7;x1MMBZA(u3mM@oLRj89xoBa&8j~L+0i4)9o zcMIDE8-zVDve({jxwMBH6bZ;3Ry)bqL&Tz= zr-@}D>{Bm)oHD}UXpeSii4H8ck>-&k!B3XxBH|wa`0R6goeadkwK+w{@eWW`ozPTz zzJLC7khb;B?P!NKLSN9B>Rz>=rGQr;-4d34g-lkICG_Jdz1TZ|lQkU1`Q4g#k%5~G;DFt|mKYil=Ox%gkz zp}sQ~xzrDPfb_3y6wCkp-2UH`CHcu&cMky{iBt&{()hB;6kkw zP%0{lE%Zg3{OX9*0C#^X-QU03FtG7P>$saD*EhL3LBoIG*uYr6$~h!fMm~$ZSj8Df zMjOUCvdwJHWA0<`<4N}S{o_)406L?D-NU0J>!bFb$tm*w<_CjK?KyDg1?m**Q1F&x zvdA3LQMzE_Hu_PG9p8Bxi2HCoy0^C*C^v7$ywtlfB6`wGhENk7ye?;xxH_gr^j<|* z9Htl0oGx*#-6I<{2#ZdSh8oCICE5lv#lUjuc_gd1ND7QVuH)ol%3&KZh9aJHxnt5+ zoOs>TE@dPppAjuL+*mCi=6SCcMol=Vepu^7@EqmY(b?wl756n%fsW~wNrZd$k6$R1 z2~40ZH<(;xt+$7LuJcM=&e{1MgRYl5WJ0A1$C3PoVHme!Sjy&9C`}e&1;wB;C;A*2 z=zn0IKV9TBRf@}HLUf7wUPD*51(Z2OF-?aS8g9aGK19RG^p(MvSr*j-yJ~g`;DWQ@ zm>)jnf&y$qO43(PM>s>AzO@c0JT>h>Ml46?)9EG?S`3$r#{^%HIWQBrhVoRrP_hin zVZq6|`SdmdBU2ZIF_f< zwOk+eoCuOx{1Oa;*J8>1Dl~7xLUBf6U_0=tUBS`8K9P_XEDZ__5)FBJmf^FGg^9|3 z7|XM(3>NJ_OR62QE9Rz;RVXlwP1m!3l_XJ$;1bqgLzKSb;sdl;R{JK<+HjH+>=;|FgE)pRVZyy&y+fp6Kz6EOsS$nAil z)E&T0mU+z)s-ApBI_Q_!C)H$*TISc^zyE3l^#U6l=}c0y5DD6)m*t(~#`F$L5~=+; zg*v_EHOw_QcuQ?Ts3llUFA)Px%c8WdIf`U zwUs%DhS#-f$|o>`$MVsSLO%b>+YKvP9P6G4uKjRIlL29b%ULV zI;vtJ@0n`UcH@wNJC$W&9aQSf7Mw1(!(D8Iv#XggE8yhCXAO#R_FNiAtyG)W>@23? zS06PE--S7ya|$~!9cJKcg=H4nFtFurLci5Aq&A|RW5KWK6$LedAgKz--ouWjF;h2O zO?Mw&UeLh9uYdH;S-*W;4oh!-Xad3?2+(<}!<#uXCG#EYqswtbU1VA`t(Fd1C)rjJ z5lGFlCf@C`F|oel&7v6G+dNI|(d_Y;7 zIi!q0l$vFh7UBgcB(r~4Eszx?0!TAx7?N0Vs%j4vI4-k-CuPr6S5xoEY}gFyK$QZ5 zFl+%sE}f}p&ozcc*XpuDluDOFwyv<32n0)?8=9J*L&)N#`-cfEIBsP?OvmE!P#`P3 z@hBfK8ir4)L5}LY<`;lPOrAuQm8m+%)bj*e7&2v8JU`RM<$;kv7VYw|1KjF`CZyVq zQ;BY@l&6}Z3ILSqf+o^-g&8zYn3_A3W{LkCvcjxn$+1Y77M2+{SEkY<%ki!^B6Y-O z#IVs$I}{ez4=MCS2PZhR(SBp3gCLMa(6h|k^ocL8Ru{kfV3fX}Z|ww-Ig2O^a6ed+ zEigF}zE_#K%Od!Z7f<;&t0^|7nzl_Sh=Z84@<+;o2z#58Vz7S@*s{ZR6!Vaj%ya)v ziD~E^ClRVkP@NrNNF_?nJ4-HFQp97PVu(${w&6`I3 zAW}a~985bsE5sI6;-TNDBABp0QvlV1Lh;9`O=G7FXFF4lUdXVr@Yr;16ZKR+z$6;s zQ{9fUi9P|=&}ABh>jOeYeaE$}q>!#8Y%q?NM`0>>$kHHns3;l3sL2Rb z(3U|}J8`38Zwn!GrD>W0$t&Zp&F@&`D0KBYcDDgo*>h1|Ey3XydVqC~=G>q?L=edX zYFS8;47MB01Zsn`BMbKA>XvnjT71yfSLXwMPF7ayG|4ys(iA@%HNTFlpC{x6-}p6N zdhg{jk}pM3y?5#SItjDi5fCpE$>L`Qz#d^$pbC)=a%-NPHba*}>H#$&qo+jtvaTP)7PZStk*}35F|8HEoRnQRx;jguRohf(tGkLHrk{!MSDsI)YnZ^Pmmznq*))B<4J{?O=ge?P*=qdBr{SKk#JNQ z1vgFWb%qfIs)OzT;P!f_Pm$ru;d8nl8!A*+rGd(*$~T-9ll}1tW3xAU@}#MAuJC*L z0C;@^N&3czV9X-jWPjeFb+fOJoUQv$L{yq=a*L}Kd#At~5Bl0l{n zeH7>=^jr!`6Nz1t9E+x7hBY&EexVHXhIK%)k^qwsA*-id;Eark(C~&aV{~M|8FCKT zs0-mMgoGl>k#)iwf)-{t+Rg}68E}9kyIc=JP9+ezx{<7D4+gJ4$?_qsidkan7Hng9 zCqfv+1O!7he>OP?3up_hldSIDw+YYT+o!27ZtoW)_?spE>F+a%KZwEIS6_DqxSRs7 zGXTm=$d=h}<8TDfk%G@F4U>8n`pAr=6;CR%Ba>`9?1y|H4-O%sJ2%!5vA(7=JO&kk zX?ly;ss17g(X=9#nUWglspHq?j@f+YBG)GsQWG8CjK|mXGVC=3R zYy&BsP#C~;wC;oA{He+UWRN8A6vEWVGmaC&AtL|^>nR=S*@8mg_m-SSYh4o7h|5Rh z+5N2&1DIo0wnNW{IFH4fo70@u5TUL~e89t6qm;8njBvLCT0ODrN-b1qqwkByTP2d= z3u#x0Pu-GERkw}IAr@lU{IL_~viIH95L;=?Y4=(fUQbepY_C_Lo6EzVpM~N7wC48E zLHp>NA>#Mo3d}Fzy_x@bDfx6Ljk*Ot#qKu}-ktw3ZdgLkpxC?5r(fpz4J?9V`54+m zb5i>fCc7NelR{wncg9?ka!+E9YRr79{cE;0@@0$YTQU) zVH8x+&_YB1`T%(VJMj*;J3XT{mpNZc^^#0C*}^mP>=g<6Pl1l(q_P$Q2H6-Vr~qOV4Pn%(I>R>u8CrAVRH-FgLgmrn^!-+%wmWS zBI%O;v{5DdT?>bb1PlWdck;m& zG?8;NCa#=2oqHYKT0<~i3BRC?0{+JzM~g-D_D`yp+4N*OC-bxK``0V=Zxki%+)mDkS^pQ12u&|6wk0VNGM#$u+&mlTun2ByQ0crVttGAJx(LP92Vq6y3XSE|2J*}wga zKXbePGRmVA1~wR|#9mGR4wIkl+84^>OFy8}$=ce2qG0gZ=Sh{}4_e&=D03~pL5m{i zP(Ngin(dtf&?oVg55RB}PA>B3f9tXpk^5+?KN4NTze;pe{}w#|qx1ix&HhK^6l;Kc zYb~{Z_f$I6)+UnOFZ%7=*qzDvFsj)$nSTQGY00&)bYD$Vh z=Mp?E7@#elofl?nL+Ajyl*%veOj_a9#V>ZA19kX5)*frI<}B(>&E4Jdntt{df;j|DzDUxwq?|n{Hu!vR*H~>cCI&l7T$GeNk=Ng+1XBe( zfcX6q^Uq*Nu~&LYR2AFsz-f~tS7PbJ=!JATCIVojOo>QggJro0v5jy;xq3;fEzKkt zdb@do>>*3K#aFR`O2#+~Bsi;}M#`YH(+DnO1N5Hl-3d!{3G-A2gk&+M^dSK@3-NrK zytKdh{OIE4Dk@06#=(*W*_5ec^p=7JT_Um3)#?%xTs5fqy@kK*{is^ha)BbL66UmZ zXe+q8B`4Gc}VfQj zqdGkRB6Xjx*!hG7Eoh$%B)ih-SpfU!A)At?X5w7?>Lgj=RC!XmqJ@$`xkm$)&O{NE z7zj9>Wu5a1glJ6+sZqL&ku&qfJe_696xY%M+5{Q*03~s{gF+;MyxclXfz58vZb4r2 zGE@P$l^sMWnne@vmeP766QV|XTKw{f$_};3!{7iBk&;E3vrf2^l)d6O@R~&{!#Z9G zX{wlTM57#oM>Z;L3WuNo-J0C_&@>>~b{P#~_y_`gxG)DMEYUUqq0O(}&>ch-wC({e z9XT=mDtjJVyzNAu43=1Ow}&uu{|Uy8%0MEM-#-nIRG}=!CehVQKuYhrbe~6OK5OF$ zRDCn)f|R{sP1QnPJoZW14w{7rk!oBpOY@y=ix1R7IJkZobR>D$bv$aig~U4 zE<`A;fm7SCA4*XkiKemy+mlvxm*S7%=(0V0j2Cye5XTtz2x5PWHMEV}+>G zy7}=iU+iJQC?(sRT=??`!Z&fkLdo@J<0$1eA(GZuCJV;fWJV>y zia99Dv05Qs{8G83g^{w@@*~vZ2E5C3d$0$76^_=h0?Ay_FCq2?)2z|apx^r6Fq?X^ z&vU>OQWEXj+C6t)M+Gx;fk0RHH!H$ztpj}$<&!a8p{dft1imSbT$@s#(h=LWb3)Qz zYA8iL$QMWV@sfc=0CZ}{u_q6po+wOjpWrpy?q!;VBRBC7X7cF^bZ-eeB^f^> zQB`Z?1o{tEQvXOXqRY*(yLcw_fLf}o6r~WSG{{vGOiUVgD%J# z$j&gdK=e~U|J1hOZS(>U8Kj4rAvGrF1IWBx{2^Mp9Wk$g$C!xeTz`5gS{vz0 z-chgg;3v&I5-}eaJyclm^@TSC4tN8eor7K-uEcUJfuimwaZ64BEb%Suheq-h@Da~g zErZ@oft7xIYR7=)2~so^;HmQf-=SxIl&g3yZzQ)dn&;*|#&kWgLlX0cWP!F35QY=v zSB2>$;h|~6)Z{ZLT?-`a_JrYVoHNvsxvZ$p1q$y_cNN-mV}o;rcFMJONM=PnsDZIr zVC2MVapQDikYN5vCH)BZut{M2Q$T3})eTDtH9fqT2|SXZy|lnI`d{w$f~eB_D8UsS zn7lih>~118IeOB}ai<+1Y}Oohfff{nLFk}6M*X;93@U5h)p}SnK3uuK2q=fvx`Xyn zN>T9xkcy8E4;oi|>Ch|032-OHs zbh>nVJ8-&$cS0SUbBU)ew^T3qUYLo&ytrP?yM~iUh6a~yUEJE{s&}4%{tkwJ%I3pE z@~ClA0k^%03=gV<=L}RkZE7(7;dIzR{69fMY zU^Jt{-4CVPngMr)yA@ywB%OxN(9zlZeJ(P$YIo})tKSEG2nnWbN889d)`f#J(fV;cEu7)J%aN%~_$)Z>(fMP3Vw? zZ1PJCp0N}}5gDw$4Kt=g~m$O6&y+Kq$rbyR;oM+-R`+eqIfUr?P z^Tnv<)ZPK(iuebbZzaRTC4*x2up0rczT;GrI&O00wgD>Oq)Jp(5T~R}D0eh(ImW^V zq^(nk#P--V8q_ccE2YtLD|<`Rffk5wZr3k^DEXG3Po?}a=HOQVEB(M)*a!!fve8!z!Jf@HMHG$ z$9EKahtctY!Uf43{Inms%oP%|N{r%Wl8AXQreHG|%SgOX+R3KZ z^lNIxqQqP9lFtAjcNl}c`z!qTg|S|01BvwIC@gati68424l$8oM_w_9+~Bq9_mT)V#S**~fdp z@BLo^`s#=L`T%mcD=)EJ{Nzv_bWJw?j5-ReXPRv&KIY%_A8P(@L|Gh(XQ;v=Tp18@ z7r>|2AMn|^W-$2JU--UNcT(oY2iZbK8`9XdNGl$Xm&V*)@uAMX8u*)wDN`!HVV7d?xvknpLesf+@g5{Jqk@X&e0;gw;%` zRVef*D2U!@3ZuId8&n;3n2I&kYrq1EhU6q}s*ux(T+P&EymJ&Q7a<=G?M>9H*tV%h z23C!Wus=JN-k`lK#w861^^cSm_tZ{S?O=>Ak^9A(vodXxfpoNh_yg}l zM3JR4aSdggXNv$ftxyAIk0-;5u%ivhS2Q3>Fs1OA;)wuh>KVpmy;!!JQz+Fa)GQ^- zK!uQq2@hsSSp;nlsLM!C5tlR5`MNS6;IIr1_*gST6*BcvnIG;YyYGmmuR#K*= zW{uWUoEW*&=I0`Hp&gN!RL%z+39N<~#$AUFb$6G54ADoC(v^yC)==1-043o{yYRJP zyu`f4gc@N2j9u_+SNa&F=X+x+p#=hz8Lc@+1ki6W8YaIRTIemmIfy7dp&X{fj~8A5 z%MqUqz^ucP8mK;Nv?k6THibm?hKYU&l+RPs?&Z z1TK|`k~q+aFp8HT)feqXLhxS*m?YjEC#KtJaU7mYr$g!uMq%M1bm;dJ2e&Y7Q#L)5 zG4CQ59$X@{@~7_bQn`oLt_|6Bi~^4)#TQ}_xI$wrYB{JZq{uj9P__r4Tob6IC=Q}q zyu>Ec6-bEPsLB?pwBd4QBos#AOpVQ<=Ih6#w51-ET{XQ)KLY4HA`top_#AApi$CTs zpW(1RE-Yv4G@SK6yMC-3ZJll<7j}Q5jL!+2({qTggu>xjpO@Bs(qP7jm2sgow0Evu zUa5Pf zB$L4|q6bjR%lVO1em~M5oluvKL9?Kad-PZ0P0t16@Z#D(z;1?qUXOli*7Lg<#rW2V z0;mE!U_v+b8}Jit=ZwzDfy_G)d`c6&f+YBWELL)f^||ti_jW~^0=}#u{aqD1418FZ z=l{IshzcY0XC z`P8}4`8~_|wqkLI0@D1q?S++|j}8nchE+58NX4mY!|AqaMInDR7D9rWh0^j@qH!}( z0~#|rFu<)PAi@bY7dSWO(4;O(sW90AHT*0AgX0ClwN;lZ!_XRloGo^d(oR=yX`7eR z1>XR(6OY&6+M=Sd75vQ1EowgN+9r$4?EOtY4*lv1`$Lmj#GZ-`YDS!BGyYhnrmf$W z75wW^{L&R&KDp~P_kfF`!J&oab3foYFq|9uvJhbD!7kN%bw7DktjkmEy!5W?OT(c% zaGJp4Lp{#`F8Kj@Z>Ss0O%0@L z=_o3AS=j7D=%871sN3^>4%ZY_={S7NJKB5BZ|4RR zQ$Q7UxvnAL0uU9+9>1QsfJ}Vsk*j!!RFk+XflYjCk7$vTJ_2SjeXY~bvXqblWkH)8 zm_H8Xf6>cR-*W{BN_PLc7{{{Hc%%?Kj)Xka%N}5vxmf{!6{I)`F4FaaRen>B>7{M7 zFH;#D`{Vs0{<=mIehp`2#J!lZkG~;8{n4Mp0vT&&EO`ri*GTBE<@9%eA2EM~pMK|a z52w|kkFT#ceY#i1{l$%ZzzP>fzWZ#yiM*F4I6Ykr^6QAfqcIma+F$($yxTbswfDlgY zjgc~blW_GD#X`_8!LVXh#jx=VfgxneOSO`fgCvdo<$IRqBZc=+iQ4*V>q}zr*5$0y zCjk@J6MX~(C&%#*)pueRdgDq9e0j9PB zH6wwc{sz}!wSk_j`47%~w)U<~RoFV(39zI~L8E>5;}$1S)B!fUVwJTcH%^mMu~pJ2 zZPlV%ldph=kh!imgV=`k@d!MVYlsVmU#lPh>!3kmtG!ivoX)l=Bdj|w_Wt{f2|>{3 zNSJBa$L3sEA!C~DNco&iVHGD>@4!!uXNlu3Pk`?puU-1z@$Ouu+{YYp2%M>$YNN-R zX21B@IoT(UP0b=3v1js}LcOnCb?I|)r)^)mhCCFjNA8R6vyr}%?s@mhmn#KcH}bC% zW;QKLy@waI1`|<0|FQ+D!u#`z6h~9hlBk|$5N2e3gRK(2L6k3test;wIlH<@Hv+Qn92fx zxYGjYk#gV)nx5wDl36YZW|c(eQM1iTFxD$M4EWQ#@Ikmnos zgpO#tUHZE`YJGE~gbEs=MG9M`5m7I=qR>=1V z|2UtTmrRK@T1SpqX-PKPSeeIE#~-b^&hu!oPqmU-_+LgJG;WHj{q2!SZb7%m-xQ6! zprUP&%cs7y)ikUvpz?yHZLTdbd1_X+sV&8NcR6UqFVOS~I=djZX#X^7>faKhzJ#Bp zdXF`4{uJpL|DxC2*VjB(7e2@F)x1`h1r&p}vA@Wx#D!ct;SkNl>2{9Z_i?V?2dr?D zEd@K)v~=zX&B$_7XuJ*Q=;ZT)|s#?fm3jniC9CpukXut5IW=yN2N`|3UW`k#rI*J(Xog2^D)Y~x%W47}h`A5$ zmsV?ZyTV#5oJSmcHHL$rGkvPMqbhJO9T!=1UlzT!b*#&pQAD1fXRNT)LXTW-KH9P5 zqX6mHvf(zeb3x zEXeM>NHfb5+$HJGc+3)(nv@x8IBm+l(_C|(TuZNmP2*`>m!y$tW2AOSXO2r{YZStF z+Ccj=qg;lR(Uy42#$^$lL6qX^YC5E}J|Aurs@Ss9U?as1KZVF7dFk@jU~#Dse2ANf zF`pf3Q(VNOxBJMQUQBKAVH^sz485r#JAS)NU4%V+&Wow4Y{!*St3Gm=3c?7!luRLJ zg8-;Jw$eoq@LDU6z|5f3BMW1QW;(GV0rdsOsTMc{h*73QQFwmZi;R`xCLKjs4V{8z zpkLk}#kb!1H{sV&A#105ow)@<>CPfRO1^->7RCgfoa0qjRbtq>1#mQA6~Zmps*9$C zR{@xZBNKF?Mq2ai!d{@VHsOXn&+e@mbit@0s%m5tD@)I6_xzwH=z`O|vOpFckg9%m ze}V)thirtajxb6>mow9(IM=w0UNx?l27;MU_eGA7OLmk!q@j@SDNnEli|fF2ROYDX z(@@F^{@`$zOC}1MbT$&$^l@;LAtU!dl=fKGg;g3`;8!l{0*2`6io3n)3Z1lwW)qSMX&&H6B6op0BOsY^48CdE9CD;j|AytFc#uUQ^dVqKV zwPRM8q8!llV^uFELm7t;3^3M_RLO)8_Y+j<6@LtI9XsF1+}4a!SAPqcNLFg9^)`Fj zSgEmL4kjDU(UC-~)XR&&6b*YRSK8_SzPffPc3;=6(lfX%ve2OsF|@(LglrJAy6j&3 zQ53Gan!U=F)Di8RkReOBn>zer+=(TSwGnTf z*Rnzm*U6Wo*mtLhu4%hSke^_>nlU7&JcYPyEYiWY@cQ^DiF~Q?auFs3K@+K8;kuMg zwuV5kYV-V`8Pa0Rn8E0n?XNhH*Pzdpue#m!P-{kDo9Kc7o!U8?)FJFJY5DV=Q*K*H15|zoaeZ z;gxIT%0tMEjrEbAVn)F1EeL*5dWRT{nl;)MIguR%znlTsrb@ryC{?py2EGI|CFryT z!uC0_J2yACqMsk976rAxFnx|V^q+Qn7Iu;++gH158K^3#bC1z_krqGEZP2cH2SaAd zbWdZR#Bmx_1o4@I!Q%W3n9Tep>w1BA*_y zE*4?as4ov0?r$f9#I~7;2el*Mt(EV+zC5+-Le^6`%OR@XZ!})>Bn}{U%S&l75_70R zb>YYVd*B6-9;SVen?o4vme^s{;3Lh@2$FpuId@#!0V5XGt_n?Q?>0Aj{qI_?>+^xw zpWFpX8(TKSTB&wjom%A@uC4MfE>)(Z4|)#^vatul3d|Q&;^cbIOB)Ncc@bD-%Z)*b zPq1FtofUV>ei{WDtc7W$-qg(JrT|N}TkwuR+3~h=h~$sN2i|q+rc#10nyXjPFTte^ zX{QLKnDAZ)>$oJT&c$sbSl&ZaSmvY;Hy(U_{137EqvMIR4Tz3wJ*XZVoe?g>F+901 zYd1hLOzdEDvb{a#imlA+k7IPm1n=9%CPPZiV~iRw30G35qwSMmnzx? zIb+c;+iZk_2SHQzZBl&ygxB(x$tptwTl(*r^Cng#Z?J6bC#<$TK!Gh8s*s1u;;pQX zvRHWJVDysYrJS95YnW<`E0@-JJe=tSHzbs13RN2hQt&+7Ng;#3e^8-n6v{%EEkz8t7b~IQ zE0;F@wojhK9vK%HemcA8cBMI&s4v@}lHkJhXfrM1xj8Ej3nMj}xoUbosn^ObCdY7b ztp_(h)oP%ekys;b$wHPtmL%paSC_hQ*ReRSJSSzB+0-?Cy` z5(TS>p0S~tJG>R~%V(`qVL47z>BzEAo2^%wsckeF*O7_tEk%rL^AH+1}ZpX?fat+c#`9u{zqNInLk*PD-r4NK?HTgbbEW`hdk!^+)OerVxh}0<5*_sCkD)>jE>PECJ(`rs&vQSqiBi5#XrQ+l@&S1Yd zW~|6Kcs&JHx%qg0uNT5t*sdKbwI=mIMyH0=l~^7n4%Gx9Hr0&5HEkKzFe~Ccz#3>T z8x~`%;_^u&p%ch^L3|%V4fmqvp&jfpm{lcT_z+Z6sX{br`z*-z**l( zV*al|m~_3NXsFj%c&dvLtk<>Lzb&cp_>bRZ93&_w^(yYX=jDDbQn73PDp7cdU?aL*BL*VK;Q1cou@ z<%G;A5a@!4(@Hfo`NlXWafmoES8>Q#r+J<2e z(k-d+ZwTe`VlkbBAvPyD3t3`rz9J*x2ndxGh-PCkPFw{eMk~JwiK1`nq$^QlOp$CYm2hBso=rlg&n>nQl`gxTL!*$p%b2}P zBf8is+YZF7+2?v68)+4;J*=8pE|v(|x5qBE#a{YZEy5HT&i4U?GLdWzRHt;hud(O2N=D&%P3w#yDOqn~`& zeDzN3*cbj*P`#yuR3A_4HXNW$%i^6B_B8n4*HeP8ZuEu>)A(~TY$dutg3yjiq9{YiZ?V#Nt_LA)uWe9>rq zOHY``mM3W=EdOW_B57D+$7}l9V%T!+IC(oHe|atxeT|j1b1hi?4K?{V!Z>rS-^1@8 z=l5&k_Pl=J`@e>J5(Dl*2Vs8TAB=x%j{YCy*#9<1|Fiy=1;>BzKPK_(|NPN0lh*jjF#w9UmGnIgJ0%yOuB27j%sZCTS;t8-sn)vVC0#XPY$6p_koe4npSvG-=%AfGn*3X6--%4AUZ@@3_ahu(H#@uo&n zxre;2?qg+#zsr$OUQ@T-en-C`fQbw@O5YhpsEn&jzpAVR6zusmS^ltOlApN`RY_X~ zI;3&Oo?-f&#_gWM0U)t5HI+V1(@V7aD=M8lFE-^3tyu1#!4b=jvwO=Qleo`7FcV~*8oYO?n`U&ennfyJk^xQJE)AJRf`t%;S^ z`rFA&buF1xT+8q4X}bOSXMlwFm_N31W$SwnTG%Fk`{R(@-(`}(Hg{QC6mo|3uNnK`R*%TkSiL}N;=X8pxjI>x~k?l`hvnV_S^&7%)r-bq$H-gKFPQ1 zbPE7d;16MAoZJ~ZmW9r&iK%as6H9IJyyvmI?!@7Px0&B^L$k9cVQn6%oB2rdbW;lM zzlccZ`yY zb%o6E6xNkO*s7dVe9GAbbpt0G z#S(Rq!VJ14{_28x!6FY~v;`#sqGFDj(~AhsBH(PoQ(QJD5bF{JS}}>MFJl;{^0(8u z<~p337P0WT1+Z1U!t9=g6%jgQa-J~nW5YY*0L)x{M6)!a9E8i-C{Jf zC1qZ3Ju4q~Ov~+1ZN8NUe_VT+rbDnTLJ`I?T#rteXL)goXPMmWCA-9R870GE^e&K= zpw5b6wUSbaZMnvRYNF}#a#U4?33=bqiSdbQXve-VTu_dpjnWS-N2$V}PkQ+f)M1ce zS3vxWdnXr>Id@KfzEX=`WNer7%8^nn%(fsia8dL#VEHqwPSO0AywiDTzw+?k8iFB< zR)SiSjbbU1$53GloU_PXxbqpPwCAKk3%xQEsvusX%Z|>Y8 z$hFs9_1*nu9z7Q<)-#+=`|YAUlQPQTQDIKJ~`Bq9o{GoiVlM9 zks8$P!tjc6^$GbkdQ^iYJfTIohMEsb10N8G%WXpn@j)e)({uf8Z0=1zgBp*K#O1^u zX68l$9vUC+Hvsb1>qZ1096EvnKakT5X-ph$RjPebuUt|6!%uOq_mEeA5%}5C*LtvGPt2nN(CQ4$k*B4OxOsx=&{*8s}f87Kq>Ke&M;dh zo&PMi*My#^X$UgQM1Xz)M|lxbX0k8gq*DtnBErf`R9lR-7$cw59vzICBcG+YYO961 z@K&yAg4M?gGu!?(!lhm1W9BwIV6NaTS$&yXa!Jk%9cB?8mnUqLojR1UZX#C>ItR%; zG)_#*l;PTNF=kHof?cXZ*z}OqDTAckDzNk@I~rz$A&Yfttt9qf4rI|khDIwDkaCU0 z^{&56PF>BFbE~99Gu7d=+;EmYkd`~1b2M6~b&`{6A-5PHL|v%pwC}5f(ZX%K%v#z! zEg6NIPO&ZISs-$A9CmDoSN8Gr?>36*Qv;JNW5GxA`VKRyHULY~tkcJnk=aXVvn93a zv^?!_jh4r?GSp|#s|CM$XP*rVPo9;XwTDm!OcXxUzDIJ28bV)ZzH~feD?t22ytG@BiG0tF|Jr48RYwfkyUTe-hzpu0+vcJD^ zm1jDyZ`nlkG~eZbK*YsgFr2dmlDOKBhqZ?k=7km~+p9rBS&rhDAs$Hv&e(WQ!e00V zlb%AQAZBv$2TUq;OdBu26sDHtep#r@$42JkMaSdG(>!|=k-GdYZ$&d{JuBTtHSPns zcE^hIssoLqm!8pOT>gS;G0lDr0!OWbLxQurlvb}W9ogPdRow||T_}I_kmBf8)5d6O z(YyBp>hTvGD%o=7(~un0z*A_m(7@?eqIj9_Z7CWaJQiz9s3cyFpNShe9?ItFK`?E5 zpXL0a95Vq^BQ_oMGCLWT@+$t4Li(ln%P#6H^nKH?4A)P(S4}cJGs3C#d>NI@tW81s zij75YC|**UN#rEut6%X-TbDj=VoNPFvSB&m5^?dl#GcBbPZ=!m=GC6JODb|pSgZCw ztCg5B9PuE~OIR27yM(kMkQ(!Ayb3B97aDLpUe2mTmH^RYbkLF!W-<*pORgM&3RY5s zg->y6VNScDnxd0{AC*!28f+z{V4QhQq4&4FVZ3*R41Ar5Um(?ezKG+&&%9bfIA?M} zA9{i@<~yk3Dfs~1n4 z^@R26Nve`GN)Up+_acpcQyB{nAx4RYRdc8S$QIP7c?E7%!}0X$^5X zswW}mTFr6Z)wAfR#4*LC@Zr(ZX24543MFZLaO51*p(z*}G4P-52sT^khk#jOeWpzl2o!2Cc=buDucQ-a)H(-<0~A zgN{F!bDw%2A?63Ua6WjgUi-*deC;(kwk#Q$uy_N+Jq8TN*`sG#8s2XOELS-*0rZQF zre$(Nucb127C-ncK<7NfF#}p4#eG9J*|x=lDFdOoevYABGpHWRu>Le6p{46>jjd0G z7CwmzOJ-9=OmJlAfYKD!tWE4Q+Rn^}SYHVd>R6lyQ;$Dj-f}?qp3S~~{1VBz_iK1c z*2dOew4A+bma@?hLk1IUwYvdR&Bj&>_7yn$jeN%c>XPhYlwwjL&1|2^Df!~kgnolz zpp)zZcqrt1p}b#g8uGp$$8}a_Es*1sb4Y2m-fmwylOT!MukmT~H0658{#zf6@VAP@ z{HxGp_0wN$i4->&2cq)QAF(TC=XqA-%_F%|KF^+54?=Oy601KXeQEjTa->iF2*>${6U zNfJ7=tf9ndv)#TaYscj|kiq2aYO%3%V1#Pb#&v_gt})q~3Rhftzo*zb__9d)<;-T` z-WTuTJoD#xS~Ds1?$oh1JNulMim_Y7f#0$#naXiiT}_Xdp-MF|)K_C9wdvXyv%5-y zv=&BXwHKT?bgA13%ay~PkCV5H@RGHY+XLaK2QaYt!y;+hp#!6L8qp*MOeFNW{mIzH-2sTmXPW$mhoITa79;3sj0B`5yVnXsAFeC z9ZDFq4NNqb7#1P`fpMSN`T z*uXRg|6DEmNOyQtiG8>m#6Kv9V}lC`@K`{D=j&kMqDx=%RXm5Cs#?}NZ&Nckw0cO`W^Oc`hPtDT{_5b0WTY)dZ;8 zJ#&KTM2)%{3rt1enE@N&5v4?_1@OdUZn?U*`66nqHR|Gb>0h!<3W-O90hbQ&k# zOFNEtSV!X$Z0I^S&g*i3_`pPWc{K&*>4!C%EUetBw<7yuo5gc9T$B!axCqb{QTy(W z^#1NanWKZ7@1Me^J7Tqd!?spXS5Q#58l7Q`+!XVcPq|l#-8ws1?x?w0nkYHrBUNot z&gf=wtU(uMWI=R+;ukx_=|b$b&(09eFfUVAu=K8v`NO*k8p&oa2Sswj#TxpIf{Fr@ z(tViq2@(`F5I&mkMM>FQ7+j=3>gNofYMj8*I`Z#9&fih;50<=kIcAgLo|~R{pf)v` z$|oWmF>-GO%Lm=Vp`&b&hkP(X-7I+NEov>r*oQCfLrW#06P5=1aM%8QwzJWxUUgbM zd}6z`kDyFi6nnV*%hcf4OOdN_E2=Vk9sBCvKZB25VJPb7f`2PeB0RwFjZHLbsud>B z1dyZbAs+;_;)8!^A2&*6PLx0dJi9(t8H{=T&na_6*MA1*2zFChxe$C}qtkh{STX`B zAK>Atx8R3aPNf|W1L>EQBb0Yx*1inT$`Ow9$`*F&^q*O*EBGvZHcP`M3CH>lva- z)+;y$Y&K1gBDaAnEYFcRf`f>`N>F46K07E3qQx;O8zzS-d$r5*U%HQG9ydU0Gy|IZ zXJ_|zwLg4$B`^zKYg%l)LC*h63~KaHpa(1l2QE)&L-BX#saHBovuf~dm$X;TWgZ3^z|^;enzj_vgsX28+P== z1g#k33Mdl;W)o_+5MbR=1kQpO4B;wz`dnuYH;y6291Uu!S|jLym8>25G^ns+C`|i zU8?IW9*CTp+=#b1v3;Y^#gnj$#!+9~-|sxPtwrGTnms&B|#kyO6t`q~ZN) z-8vvD?Ni@K@@%2GwR4uD&%*w#xr>S@m~0^g3?_xG3yIyrQ6CRV_fuPnl-F=d`^?AX zqN8(~H)ERx><1xs6#_(7nFZ`Zn_$C<#Z#QKAMgjK6vXqkHN7lIM;2$a1`)G#dsp%3MXqQ{wZ zwi49qr;`zM68#yL*fzn`Zy;0UBVsAP5wjv8#}+Jr6m95Y0IfCV>V@ zbvtmr^LW8tUX$RWhiO>rp3Pf?u+B`GXp!>LMLVc9;05>a2 zJg&o$#;ZRz!6o zM+aOFeHgyi|3y;1HT~s)0vwjT4$uB`XqNHkGX|JE3rwSFZ*FXNO{*$x@XYAHF9euB zOPxR!tj6$=>Vc>ncnWFF6=Cu99TnveWvY;dB}fO*=jz$8^2oqZvCVhm(a3G)qhAId ziV&ZT=VdcI9fO~7JK{PfaAVnG(*ZCt_Gm>VlrhcJCtGjNTzP;?wh=9v`JIn#X!msA zrLV3}(zQ`NaiNV3U3C~@kypU2h{+$9cwifsq_f9O3rdU|0O>qFI?u;RqBqZNk7CJ7 z&bN5b6@lA2*K)iFnm1ZEIXsuEH-G)9!0fG@{es$9F}EXXf&2jKmJ2XsA)#caL_WWR z%TUPo6YkgK%^KbYtN3KnXElrVV?)7Iiq_SM^EO=WBOg{NQMP1~G<(Q$3etTtTooqz z269cn+^c>ZMaZxzD5hOH3l;p01qzD($UBz$R-@*KY#gO_`+f$w%N(Y`qyzct>8$qn z(+{*ZcOuU)#rtx|LZeXJ6=uvQ*lAgZmS|T@5O(s(D-a@Q?ayr@5L|2|Tg~@b_c>L2 z__306iq%m+V~qF|ACYkfKw@2R_x8;s&L%G&lTqswsbbZVW)adc+qf&Yk}xvc$5*Hs zagVTD?4VmRkx@0Huq5{>Ow41}GC-pn#uq1j{9>W!C#!^^&O#Qorn9Wg!-y6qM@Hue zltD~1T;WZB6p^cj=UtOntm|I}@3!o)2xEg7*X)Edk0Ky-fK zlJUBV+WA!)1|scHcmS1IS2+dMSbQ}7NBA4QZRYmjr15bEDB4JAnZ6yNQiy?}GU=8m z_LO*ACAVB!>ot4aZyUb(31GXc726pp{V9T{ZRe%vRC6#z(=tk)TL`C@5^K44rw?Rc z8~V=G3jbs~jxAArcF7d=(p)!m3ZHE@(5)^HA(K&E$5purbnHLtrd+b1-SlP`yS-_; zs(gPp);eC|BcB<--$ZA`Au9>%nZ%-H1n=5LuR*yuxjlpLK*OW~vo;pieYmOMNo8z< z+{>&h_|o*b5d+!4{Bv@D%CMklf!yP%?_o%UGk~!?^Q!^RMVLaTwYAdnjP;IzQ{C?c zuv>6|@i^+h&RwZ;u|OiYaI_~Y6sX_jGX0em)A^-l%B=R6_r`ejX4>>UJlGQyzhV~7 z7UEBjwMkz-AT;7Xgt~{a*NJoNIm<$|I*%{rk>Q^tFv!s@@a#Mxb9>7Mb?>Az3}5i# z!9W1HO)g>Q5n&fA5aAvP*WA(9Y(Kf6g1{H5*0SPOUN7o z%p2P2;4o09l~86ea|C^7znvop!ESRRyq*>}tr7vf(QOR$_V6riVv1WZZMV_ zKij&hvKF1vkP+LX!sPq`E!kNfBc7y$#~taz9UtA^7UgprsF_)y1;~Ry_)q*ZW1d$u zqTCy4I+?UI;f#B&DRznrAxfgrw=NkepspfGl1l)dh|){D2A1IphvFkWOeauvL9~n2 z{o`fCZZJ)G^evX4-41DP47S>$`O!em#-`S{Y8;T=5#(93h%qaig2 zNmzuYSAr{EEKnEE-X33eLrh`|7yCHEB8*K7K*Cun0!UEEj<%37yhOGHNSO6mpYAIp5NPaVSc9C{I!#62fF6mIEQ4?8sMEpE(o=9mky-V=L8TK-b^EV2!m+2m4c zE`)fOy&l!gie&EN`Ek<@>`rXD)UmsnW@E`k7%Gp$r;^e0*w*1J)T{t5)P{BLE`2p` z&RBkKZr)Qg@}QG7xp=00&A9}j zX{i}A7m@cV8btO(?xp&b;}E^r2}nJz3h8y8pJx=@4l>nsYb5BcKF*{ToSh4=-9g0Z zb)Ji2yc{J+v)`fAIQ*0+$Ty4SWD6T^=&0j{mFn`11?MH)Q@yG|joP^5P4BJ0GU{b9 zgG5``R2p!< zw1h!cv@m@@tjbOb-RiMdHA%4np26r3-GoG1E02X?W2~^SdUx)7d>7iq+4=HpfWm5R zCpo!$I^k@p-O+Tb`|;KJE}tjIvCr&A$&(u1aB=^IeS{I#$b(3GPC!WZft!euv0VQL zC%s;qM6RkX^&1BcQrKyq7b0%POVNLs7aEl%;X^dLxIf53jKVU zglZ0=okrM<2-%2jaNEZWGoD1kMSq!kv-+|pFQiQQo2AI5-1Si|v-Q{q+>$bF{R5vZ z0C>c{yy0gt>F|T%0-#sV5Bu=zmfMSY#~DmRI;%W*QyMF`fy?`8FxHofRh8L(pd9#& zb#iol1;`+wfFl3JT0dU7-!|pTa}F#4QlkMg*>x?oPL}e6FZUHIvy|EIqrsYGWzr5$ zp@6iWZVrWKSuy$KeXz2Iuw(8;M-&mgRI~;xo%M(6LqJY4BfqL*fgm;sdhZ8$%%bha zV1l61PHI34+lfw>Ys^~&4_$@Gbyk96Fef~;C{I}nK^DJG4XR|F)VJX&^V9dQZ-0oF zs6F8V+NWkvnni`AZ{LI}_J-hjhS~u)LLWEdY%H7*2{Dd=6*hs#TVU(J{fIq;An{!+ zn2E9-@ zZegpT_rXE8G#>nRy1^`PFscA@zvj@9dGerv1~1twD#bfWccCk}f9M(4R{{G+Xdpid z4xBBuZILxf;B5LMn~+%BC-~XsWfrFfI9JkG)0Ea%6w{014m)B|PL90ub8p2(2DX-m z8?3bf3dwMt1y(-_Q2g5?ZKI)b{kntGy^O zp23Ri;p0|TF733ZsFj*xQr3P(ET~^qr-%Ob<#$0~iCatY$H(a5T^5l6?ZBtp{7vXQ zswhdYscNN2y}nq5&+3AbZR>Vge}&Z;H@7ju4fN-=R2H-N%(&1+D#e>ru!x5(jVW>-HDcn3e*n zX1htG12i+^(gW&O{DdEi>_@-j^(U z5T3QjimlU@`B}qoK9=p6o#<6w?iB(~(kClUtuxD(6}y;MFESngI9m=Us@f$T%|J3o zaoL+0g0JBW&jdJMa~}E=kv)HGzSH0Lgd#`o(Qq3ifipq)M6qS)7`H8v+*#2#r>--C zY?X#Q0X!EvL9bjjNDeQq0*V^6J7^wA%Y*+*DXL{8cs1lFa466*l`Nh`wO$%hdBqOg^;OhX_VF} zQ6#S&_o-~%bm(%qpZ1v2$Y;I{dKilI)ZE)G*vKq9Pqb613ivS`X=&7f3>Zj- zKSd~}t{_w6Q!b&AvGTg_Wb@uJRrO;}Dx1|NiU&@Kn;TRk$|Y!rQcdH=8}F4%Uin(t z7W2uCLUq1ke+IBGzen))VEU<<)I-U z0r4L<3L+0=Bqfwp7!@S{(bc_0k~d^v5F7A^<(4Z9bO;D*TT>>}zxdIZo>-bQ-Oxf5 zu{C{R1?I8_3!WI;{AA&Kx8;|*Sxc|L%Yq3oukW?i;txy2_!Z7iCCTnOhujvVxsL8s zfLHR@l372@_uj9Z|0RHCOCe$cR#W&Fklmg2`(30gFlmnpxCv3<{R00jBpGmt)jxOF z-$7!m3g&ipU^Se7bt!nHfCVe;jepb31OcpxVKAgDnDqH}GqWiE0P=4v zM*~~qfA#gBV5Y@bA7+3DzB?F~`&QR(f^X2@Ud?}D{yE%DCHvdM^n&(};grErGS5tZ z)0sC#(phgcEQtOOkp8?$H#Mq-ZUMzJ{sGV*DzM)jo;M|3Z%-!PEWbznP2b&=Q@riG zlk>lv|J75!(1^Wz<~L>kt`!-7SU%tHo&RgV{pS2{s#)D0Wse1JLHtLi=ug!I?>6S9 zLejN_$q!o>{RPthtd(^a_okAL;4NH8iCeh;A2p`Cpf{CVu0?u&n3B{j(0^wQ{z$Ut zF3L@@iQ8Q&Df3g5{|HR{ZyGUoac@%YUrSm1Fhqr4PyPM@@$21lzgbIt%?SF#R&{=X@po9`C;Xsy0dCeKT$g13uui+5 z0{puM;jR|cUB@?HjlbPHOP;@U{EOm-yBIgK!q+d^|FClJUt#>_!rsi?U8j_P7-95J z-TpMeeD`E;CZujp^Iu|r>h)Jyz`M?GhLx{#T0cxN{^!pBAj5SRyKy50$qLSTURK|Fca-~JC(R-+UE literal 0 HcmV?d00001 -- 2.54.0 From 6890b07c3e93dc9c20426e4281ede1197ba0e8ea Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 26 Apr 2026 19:59:53 +0300 Subject: [PATCH 20/77] fix(ci): fixed build commands --- .github/workflows/build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4cb473d..9561312 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -29,7 +29,7 @@ jobs: uses: gradle/actions/setup-gradle@v6 - name: Build - run: ./gradlew clean assemble --stacktrace --no-daemon + run: ./gradlew clean bootJar --stacktrace --no-daemon - name: Test run: ./gradlew test --stacktrace --no-daemon @@ -43,7 +43,7 @@ jobs: with: name: ${{ steps.meta.outputs.artifact-name }} path: | - build/libs/** + build/libs/*.jar build/reports/** build/test-results/** retention-days: 7 -- 2.54.0 From 26dd04916e4e25df9443fd983a91ee3bb251ce2b Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Mon, 27 Apr 2026 23:22:26 +0300 Subject: [PATCH 21/77] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20release.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yaml | 54 ++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..76f09cc --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,54 @@ +name: Release & Notify + +on: + workflow_call: + inputs: + version: + description: "Tag name, e.g. v1.2.3" + type: string + required: true + image-digest: + description: "Docker image digest from docker job" + type: string + required: false + default: "" + secrets: + TELEGRAM_BOT_TOKEN: + required: true + TELEGRAM_CHAT_ID: + required: true + +jobs: + release: + name: GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh release create "${{ inputs.version }}" \ + --generate-notes \ + --title "${{ inputs.version }}" + + - name: Send Telegram notification + env: + TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + VERSION: ${{ inputs.version }} + REPO: ${{ github.repository }} + DIGEST: ${{ inputs.image-digest }} + run: | + RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" + MSG="*${REPO}* - released *${VERSION}*" + MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" + if [ -n "${DIGEST}" ]; then + MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" + fi + curl -sf -X POST \ + "https://api.telegram.org/bot${TOKEN}/sendMessage" \ + -d "chat_id=${CHAT_ID}" \ + -d "parse_mode=Markdown" \ + -d "text=${MSG}" -- 2.54.0 From 401a556c54fd68fb0ec86bd713e244421c2a5386 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Mon, 27 Apr 2026 23:27:55 +0300 Subject: [PATCH 22/77] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20ci.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yaml | 50 +++++++++++++++------------------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4a3f37..ffe61a7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,31 +1,19 @@ -name: CI -run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}" - -on: - push: - branches: [develop, main] - tags: ["v*"] - pull_request: - branches: [develop, main] - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build & Test - uses: ./.github/workflows/build.yaml - permissions: - contents: read - - docker: - name: Docker - needs: build - uses: ./.github/workflows/docker.yaml - permissions: - contents: read - packages: write - with: - push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} - secrets: inherit +- name: Send Telegram notification + env: + TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + VERSION: ${{ inputs.version }} + REPO: ${{ github.repository }} + DIGEST: ${{ inputs.image-digest }} + run: | + RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" + MSG="*${REPO}* - released *${VERSION}*" + MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" + if [ -n "${DIGEST}" ]; then + MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" + fi + curl -sf -X POST \ + "https://api.telegram.org/bot${TOKEN}/sendMessage" \ + -d "chat_id=${CHAT_ID}" \ + -d "parse_mode=Markdown" \ + -d "text=${MSG}" -- 2.54.0 From 17950647106ee526ce9c67db6449364c0359a3bc Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Tue, 28 Apr 2026 18:34:15 +0300 Subject: [PATCH 23/77] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20ci.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yaml | 78 +++++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ffe61a7..4ea1ee6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,19 +1,59 @@ -- name: Send Telegram notification - env: - TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} - CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} - VERSION: ${{ inputs.version }} - REPO: ${{ github.repository }} - DIGEST: ${{ inputs.image-digest }} - run: | - RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" - MSG="*${REPO}* - released *${VERSION}*" - MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" - if [ -n "${DIGEST}" ]; then - MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" - fi - curl -sf -X POST \ - "https://api.telegram.org/bot${TOKEN}/sendMessage" \ - -d "chat_id=${CHAT_ID}" \ - -d "parse_mode=Markdown" \ - -d "text=${MSG}" +name: CI +run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}" + +on: + push: + branches: [develop, main] + tags: ["v*"] + pull_request: + branches: [develop, main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & Test + uses: ./.github/workflows/build.yaml + permissions: + contents: read + + docker: + name: Docker + needs: build + uses: ./.github/workflows/docker.yaml + permissions: + contents: read + packages: write + with: + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + secrets: inherit + + telegram-notify: + name: Send Telegram Notification + needs: docker + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Send Telegram notification + env: + TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + VERSION: ${{ github.ref_name }} + REPO: ${{ github.repository }} + DIGEST: ${{ needs.docker.outputs.image-digest }} + run: | + RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" + MSG="*${REPO}* - released *${VERSION}*" + MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" + if [ -n "${DIGEST}" ]; then + MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" + fi + curl -sf -X POST \ + "https://api.telegram.org/bot${TOKEN}/sendMessage" \ + -d "chat_id=${CHAT_ID}" \ + -d "parse_mode=Markdown" \ + -d "text=${MSG}" -- 2.54.0 From 28eb823f98166571026db6f201f9c4ceea1eaa62 Mon Sep 17 00:00:00 2001 From: ITQ Date: Thu, 30 Apr 2026 09:44:37 +0300 Subject: [PATCH 24/77] deps(idea): kotlinc plugin update --- .idea/kotlinc.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 88cf87c..9b361e3 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -2,6 +2,6 @@ \ No newline at end of file -- 2.54.0 From a41c3de0df319a8467806eade71065f87a4e1a51 Mon Sep 17 00:00:00 2001 From: ITQ Date: Thu, 30 Apr 2026 09:49:25 +0300 Subject: [PATCH 25/77] build(): added AOT for spring --- Containerfile | 11 +++-------- build.gradle.kts | 2 ++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Containerfile b/Containerfile index 7e1867b..1fda1fa 100644 --- a/Containerfile +++ b/Containerfile @@ -26,13 +26,7 @@ RUN --mount=type=cache,target=${GRADLE_USER_HOME} \ COPY src src RUN --mount=type=cache,target=${GRADLE_USER_HOME} \ - ./gradlew --no-daemon build \ - -x test \ - -x detekt \ - -x ktlintCheck \ - -x ktlintKotlinScriptCheck \ - -x ktlintMainSourceSetCheck \ - -x ktlintTestSourceSetCheck + ./gradlew --no-daemon bootJar RUN mkdir -p ${APP_HOME}/dist \ && cp ${APP_HOME}/build/libs/*.jar ${APP_HOME}/dist/${JAR_NAME} \ @@ -60,6 +54,7 @@ COPY --from=builder --chown=app:app /workspace/dist/app.jar /app/app.jar USER app ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \ + SPRING_AOT_ENABLED=true \ SERVER_PORT=8080 \ PATH="/app:$PATH" @@ -70,4 +65,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ STOPSIGNAL SIGTERM -ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"] +ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dspring.aot.enabled=${SPRING_AOT_ENABLED} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..7f24f7e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,8 @@ plugins { jacoco } +apply(plugin = "org.springframework.boot.aot") + apply(from = "$rootDir/gradle/docker.gradle.kts") group = "com.project" -- 2.54.0 From 1e2625a4602976689eb9fc3d85a4b0c2f024d434 Mon Sep 17 00:00:00 2001 From: skettiks Date: Sun, 3 May 2026 12:04:29 +0300 Subject: [PATCH 26/77] =?UTF-8?q?chore(ci):=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20CI/CD=20=D0=BF=D0=B0=D0=B9?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yaml | 14 +++++++++----- .github/workflows/docker.yaml | 9 ++++++--- .github/workflows/release.yaml | 10 +++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9561312..8cd8d59 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,6 +12,8 @@ jobs: name: Build & Test runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read outputs: artifact-name: ${{ steps.meta.outputs.artifact-name }} steps: @@ -28,23 +30,25 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 - - name: Build - run: ./gradlew clean bootJar --stacktrace --no-daemon - - - name: Test - run: ./gradlew test --stacktrace --no-daemon + - name: Run CI quality gate + run: ./gradlew clean check bootJar --stacktrace --no-daemon - name: Set artifact name id: meta run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" - name: Upload build artifacts + if: always() uses: actions/upload-artifact@v7 with: name: ${{ steps.meta.outputs.artifact-name }} path: | build/libs/*.jar + build/reports/detekt/** + build/reports/ktlint/** + build/reports/jacoco/** build/reports/** build/test-results/** + build/jacoco/** retention-days: 7 if-no-files-found: error diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 21e61b8..0854d36 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -30,13 +30,16 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Validate Dockerfile + - name: Build image (no push) if: inputs.push == false uses: docker/build-push-action@v7 with: context: . file: ./Containerfile - call: check + push: false + provenance: false + cache-from: type=gha + cache-to: type=gha,mode=max - name: Log in to GHCR if: inputs.push == true @@ -65,7 +68,7 @@ jobs: uses: docker/build-push-action@v7 with: context: . - file: ./Dockerfile + file: ./Containerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 76f09cc..0fdfcb1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -29,9 +29,13 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} run: | - gh release create "${{ inputs.version }}" \ - --generate-notes \ - --title "${{ inputs.version }}" + if gh release view "${{ inputs.version }}" >/dev/null 2>&1; then + echo "Release ${{ inputs.version }} already exists. Skipping creation." + else + gh release create "${{ inputs.version }}" \ + --generate-notes \ + --title "${{ inputs.version }}" + fi - name: Send Telegram notification env: -- 2.54.0 From 517de4d3721e31ee1b98c054f8d8e9c34282d380 Mon Sep 17 00:00:00 2001 From: ITQ Date: Thu, 30 Apr 2026 09:41:36 +0300 Subject: [PATCH 27/77] ci(): added trufflehog, release notification improvements --- .github/workflows/ci.yaml | 47 ++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ea1ee6..ece7eb1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,15 +13,30 @@ concurrency: cancel-in-progress: true jobs: - build: - name: Build & Test - uses: ./.github/workflows/build.yaml + trufflehog: + name: TruffleHog Secret Scan + runs-on: ubuntu-latest permissions: contents: read + steps: + - name: Checkout source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Run TruffleHog + uses: trufflesecurity/trufflehog@main + with: + extra_args: --results=verified,unknown + + build: + name: Build & Test + needs: [trufflehog] + uses: ./.github/workflows/build.yaml docker: name: Docker needs: build + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') uses: ./.github/workflows/docker.yaml permissions: contents: read @@ -30,10 +45,10 @@ jobs: push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} secrets: inherit - telegram-notify: - name: Send Telegram Notification + notify-main: + name: Notify Main Build needs: docker - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read @@ -42,13 +57,13 @@ jobs: env: TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} - VERSION: ${{ github.ref_name }} REPO: ${{ github.repository }} + SHA: ${{ github.sha }} DIGEST: ${{ needs.docker.outputs.image-digest }} run: | - RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" - MSG="*${REPO}* - released *${VERSION}*" - MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" + COMMIT_URL="https://github.com/${REPO}/commit/${SHA}" + MSG="*${REPO}* - main branch CI succeeded" + MSG="${MSG}%0A🔗 [Commit](${COMMIT_URL})" if [ -n "${DIGEST}" ]; then MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" fi @@ -57,3 +72,15 @@ jobs: -d "chat_id=${CHAT_ID}" \ -d "parse_mode=Markdown" \ -d "text=${MSG}" + + release: + name: Release + needs: docker + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + uses: ./.github/workflows/release.yaml + with: + version: ${{ github.ref_name }} + image-digest: ${{ needs.docker.outputs.image-digest }} + secrets: inherit -- 2.54.0 From b7a246e0b40389ba6c21f720fd54ed13a70faaf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 12:02:31 +0000 Subject: [PATCH 28/77] fix: address copilot review issues - explicit imports, trailing commas, 404 for not-found search, tests for available-films endpoint Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/60cd9b1b-e3e1-46a1-bfd9-04a75bd0d569 Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/adapters/web/FilmController.kt | 15 +- .../adapters/web/FilmLibraryController.kt | 33 +-- .../application/services/FilmService.kt | 22 +- .../controllers/FilmControllerTest.kt | 137 ++++++------ .../controllers/FilmLibraryControllerTest.kt | 202 ++++++++++++++---- .../controllers/UserControllerTest.kt | 82 +++---- 6 files changed, 317 insertions(+), 174 deletions(-) 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 df375e4..3699e33 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -57,10 +57,11 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = EditFilmCommand( - title = request.title, - description = request.description, - ), + command = + EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -73,12 +74,10 @@ class FilmController( @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): FilmResponse = - FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) @GetMapping - fun getAll(): List = - getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } @GetMapping("/search") fun searchByTitle( 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 a4a6175..e4e000d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -64,9 +64,10 @@ class FilmLibraryController( fun getAllFilmsInLibrary( @PathVariable userId: UUID, ): List { - val library = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) + val library = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val film = getFilmByIdUseCase.getById(library.filmId) return listOf(FilmResponse.fromDomain(film)) } @@ -102,21 +103,23 @@ class FilmLibraryController( fun getAvailableFilms( @PathVariable userId: UUID, ): List { - val userLibrary = try { - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) - } catch (e: EntityNotFoundException) { - null - } + val userLibrary = + try { + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + } catch (e: EntityNotFoundException) { + null + } val allFilms = getAllFilmsUseCase.getAll() - val availableFilms = if (userLibrary != null) { - allFilms.filter { it.id != userLibrary.filmId } - } else { - allFilms - } + val availableFilms = + if (userLibrary != null) { + allFilms.filter { it.id != userLibrary.filmId } + } else { + allFilms + } return availableFilms.map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 555655a..f775de3 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -28,7 +28,6 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -37,15 +36,19 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -63,9 +66,8 @@ class FilmService( filmRepository.deleteById(id) } - override fun getById(id: UUID): Film { - return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - } + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) override fun getAll(): List = filmRepository.findAll() diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt index 0784280..18a952a 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -9,13 +9,16 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock 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.* -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +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 @@ -24,17 +27,18 @@ class FilmControllerTest { @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", - ) + 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) + 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()) @@ -42,86 +46,95 @@ class FilmControllerTest { @Test fun `edit film should return updated film`() { - val createRequest = CreateFilmRequest( - title = "Old Title", - description = "Old Description" - ) + 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 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", - ) + 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) + 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" - ) + val request = + CreateFilmRequest( + title = "Inception", + description = "Dream within a dream", + ) mockMvc.perform( post("/api/films") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)), ) - mockMvc.perform( - get("/api/films/search") - .param("title", "Inception") - ) - .andExpect(status().isOk) + 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 empty`() { - mockMvc.perform( - get("/api/films/search") - .param("title", "NonExistentFilm12345") - ) - .andExpect(status().isOk) - .andExpect(content().string("")) + 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 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 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")) + mockMvc + .perform(delete("/api/films/$filmId")) .andExpect(status().isNoContent()) - mockMvc.perform(get("/api/films/search").param("title", "Film To Delete")) - .andExpect(status().isOk) - .andExpect(content().string("")) + 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 index d24e60e..3bbc177 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -10,7 +10,9 @@ 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 @@ -18,7 +20,6 @@ import org.springframework.transaction.annotation.Transactional @AutoConfigureMockMvc @Transactional class FilmLibraryControllerTest { - @Autowired private lateinit var mockMvc: MockMvc @@ -27,60 +28,177 @@ class FilmLibraryControllerTest { @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 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 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()) + 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 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 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")) + 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 = (0 until films.size()).map { films[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 = (0 until films.size()).map { films[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 index db2eecc..7a2dca3 100644 --- a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -20,7 +20,6 @@ import org.springframework.transaction.annotation.Transactional @AutoConfigureMockMvc @Transactional class UserControllerTest { - @Autowired private lateinit var mockMvc: MockMvc @@ -29,17 +28,18 @@ class UserControllerTest { @Test fun `create user should return 201 CREATED`() { - val request = CreateUserRequest( - name = "John Doe", - email = "john@example.com", - ) + 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) + 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()) @@ -47,54 +47,62 @@ class UserControllerTest { @Test fun `edit user should return updated user`() { - val createRequest = CreateUserRequest( - name = "Old Name", - email = "edit@example.com", - ) + 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 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) + 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 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 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")) + 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")) + mockMvc + .perform(delete("/api/users/$nonExistentId")) .andExpect(status().isNotFound()) } } -- 2.54.0 From 44483d748fdbf1f08bbd687866cd10e248f6ea66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 12:03:23 +0000 Subject: [PATCH 29/77] refactor: use idiomatic Kotlin iteration for JsonNode in tests Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/60cd9b1b-e3e1-46a1-bfd9-04a75bd0d569 Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/controllers/FilmLibraryControllerTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt index 3bbc177..560cf38 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -153,7 +153,7 @@ class FilmLibraryControllerTest { val responseBody = result.response.contentAsString val films = objectMapper.readTree(responseBody) - val returnedIds = (0 until films.size()).map { films[it].get("id").asText() } + 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" } } @@ -198,7 +198,7 @@ class FilmLibraryControllerTest { val responseBody = result.response.contentAsString val films = objectMapper.readTree(responseBody) - val returnedIds = (0 until films.size()).map { films[it].get("id").asText() } + val returnedIds = films.toList().map { it.get("id").asText() } assert(returnedIds.contains(filmId)) { "Film should appear in available films when user has no library" } } } -- 2.54.0 From 1d8e3dd4e157baa10db53f6d0d944c1f5fe3a253 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 May 2026 19:14:02 +0300 Subject: [PATCH 30/77] style(format): reformatted with ktlint --- .../persistence/jdbc/UserRepository.kt | 14 +++-- .../entity/UserEntityMappingTest.kt | 59 ++++++++++--------- 2 files changed, 39 insertions(+), 34 deletions(-) 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 09d8f9d..6c70b4c 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 @@ -69,10 +69,11 @@ class UserRepository( } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc + .query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -84,7 +85,10 @@ class UserRepository( ): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", + """ + SELECT id, name, email, provider, provider_id, created_at FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), userEntityRowMapper, provider.name, providerId, diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index bec7f20..cdf0c42 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -9,17 +9,17 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class UserEntityMappingTest { - @Test fun `toDomain maps UserEntity correctly`() { - val entity = UserEntity( - id = UUID.randomUUID(), - name = "John Pork", - email = "john@email.com", - provider = "GOOGLE", - providerId = "google1234", - createdAt = LocalDateTime.now(), - ) + val entity = + UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) val user = entity.toDomain() assertEquals(entity.id, user.id) @@ -30,12 +30,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User with OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Jane", - email = "jane@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null, + ) val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") @@ -48,12 +49,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User without OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Bob", - email = "bob@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null, + ) val entity = user.toEntity() @@ -63,12 +65,13 @@ class UserEntityMappingTest { @Test fun `mapping is reversible for basic fields`() { - val original = User( - id = UUID.randomUUID(), - name = "Alice", - email = "alice@email.com", - library = null - ) + val original = + User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null, + ) val mapped = original.toEntity().toDomain() @@ -76,6 +79,4 @@ class UserEntityMappingTest { assertEquals(original.name, mapped.name) assertEquals(original.email, mapped.email) } - - } -- 2.54.0 From 02d3f4580daf3d3cafaf7c1c9510d41150ff6786 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 May 2026 19:47:31 +0300 Subject: [PATCH 31/77] fix(repository): temporary schema fix before #32 PR merge --- .../adapters/web/FilmLibraryController.kt | 14 ++++++++------ src/main/resources/db/migration/V1__init.sql | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) 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 59022b0..74b04bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -74,10 +74,12 @@ class FilmLibraryController( fun removeFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ) = removeFilmFromLibraryUseCase.removeFilm( - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - ), - ) + ) { + removeFilmFromLibraryUseCase.removeFilm( + RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + ), + ) + } } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11017d1..900f6b5 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,7 +1,10 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, - email VARCHAR(320) NOT NULL UNIQUE + email VARCHAR(320) NOT NULL UNIQUE, + provider VARCHAR(64), + provider_id VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( -- 2.54.0 From d9f45a9e4c3ea11a63c2b7eee31fe27b3a681311 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 May 2026 21:08:43 +0300 Subject: [PATCH 32/77] style(format): run ktlint format --- .../persistence/jdbc/FilmRepository.kt | 11 +++++----- .../movienight/adapters/web/FilmController.kt | 18 +++++++-------- .../adapters/web/FilmLibraryController.kt | 14 +++++++----- .../movienight/adapters/web/UserController.kt | 13 +++++------ .../application/services/FilmService.kt | 22 ++++++++++--------- .../application/services/UserService.kt | 19 ++++++++-------- 6 files changed, 49 insertions(+), 48 deletions(-) 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 a13f721..243b8d7 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 @@ -62,11 +62,12 @@ class FilmRepository( ) override fun findByTitle(title: String): Film? { - val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ?", - filmRowMapper, - title - ) + val films = + jdbc.query( + "SELECT id, title, description FROM films WHERE title = ?", + filmRowMapper, + title, + ) return films.firstOrNull() } 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 12ffa79..c2d1eb2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -56,10 +56,11 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = EditFilmCommand( - title = request.title, - description = request.description, - ), + command = + EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -72,16 +73,13 @@ class FilmController( @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): FilmResponse = - FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) @GetMapping("/search") fun searchByTitle( @RequestParam title: String, - ): FilmResponse? = - searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } + ): FilmResponse? = searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } @GetMapping - fun getAll(): List = - getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } } 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 8d0e69c..b6731a9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -63,9 +63,10 @@ class FilmLibraryController( fun getAllFilmsInLibrary( @PathVariable userId: UUID, ): List { - val library = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val library = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val film = getFilmByIdUseCase.getById(library.filmId) @@ -105,9 +106,10 @@ class FilmLibraryController( fun getAvailableFilms( @PathVariable userId: UUID, ): List { - val userLibrary = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val userLibrary = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val allFilms = getAllFilmsUseCase.getAll() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 6c1a996..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -46,14 +46,12 @@ class UserController( ) @GetMapping - fun getAll(): List = - getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): UserResponse = - UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @@ -63,9 +61,10 @@ class UserController( UserResponse.fromDomain( editUserUseCase.edit( id = id, - command = EditUserCommand( - name = request.name, - ), + command = + EditUserCommand( + name = request.name, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 555655a..f775de3 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -28,7 +28,6 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -37,15 +36,19 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -63,9 +66,8 @@ class FilmService( filmRepository.deleteById(id) } - override fun getById(id: UUID): Film { - return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - } + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) override fun getAll(): List = filmRepository.findAll() diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 4d16ea1..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -26,18 +26,18 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") } - val user = User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - ) + val user = + User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + ) return userRepository.save(user) } @@ -59,9 +59,8 @@ class UserService( userRepository.deleteById(id) } - override fun getById(id: UUID): User { - return userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - } + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) override fun getAll(): List = userRepository.findAll() } -- 2.54.0 From 238aec898eb5e1f4fe544c8d40b922f16f27dbb3 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 May 2026 22:25:50 +0300 Subject: [PATCH 33/77] test(web): added search controller test --- .../adapters/web/FilmControllerSearchTest.kt | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt new file mode 100644 index 0000000..dd6bd33 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -0,0 +1,77 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.ports.input.CreateFilmUseCase +import com.project.movienight.application.ports.input.DeleteFilmUseCase +import com.project.movienight.application.ports.input.EditFilmUseCase +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 com.project.movienight.domain.model.Film +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.util.UUID + +class FilmControllerSearchTest { + private lateinit var mockMvc: MockMvc + private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase + + @BeforeEach + fun setup() { + searchFilmByTitleUseCase = mockk() + + val controller = + FilmController( + createFilmUseCase = mockk(), + editFilmUseCase = mockk(), + deleteFilmUseCase = mockk(), + getFilmByIdUseCase = mockk(), + getAllFilmsUseCase = mockk(), + searchFilmByTitleUseCase = searchFilmByTitleUseCase, + ) + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun `search returns film when title exists`() { + val title = "Inception" + val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist") + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns film + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(film.id.toString()) } + jsonPath("$.title") { value(title) } + jsonPath("$.description") { value("A dream heist") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } + + @Test + fun `search returns empty body when title is missing`() { + val title = "Unknown Title" + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns null + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + content { string("") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } +} -- 2.54.0 From 27317c00e42b4325de6642281a03c972cdc7ceae Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 5 May 2026 00:58:20 +0300 Subject: [PATCH 34/77] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20develop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - После слияния с develop возникли конфликты в реализации OAuth2 - Две ветки независимо реализовали OAuth2 функциональность по-разному - Сборка проекта падала из-за отсутствия зависимостей OAuth2 Изменения: - Временно отключена OAuth2 зависимость в build.gradle.kts - Перенесен OAuth2 код в папку security.disabled для сохранения - Добавлены исключения security.disabled из компиляции, ktlint и detekt - Удалена миграция V2__add_oauth2_fields.sql (OAuth2 поля теперь в V1) - Удалено поле password из User domain модели - Обновлены репозитории и сервисы для работы с новой схемой БД Результат: - Проект успешно собирается (./gradlew build) - Все 35 тестов проходят - OAuth2 код сохранен для будущего использования --- build.gradle.kts | 9 +- .../movienight/MovieNightApplication.kt | 3 +- .../persistence/jdbc/FilmRepository.kt | 11 ++- .../persistence/jdbc/UserRepository.kt | 99 ++++++++----------- .../CustomOAuth2UserService.kt | 48 ++++++--- .../GoogleOAuth2UserInfo.kt | 3 +- .../OAuth2UserInfoFactory.kt | 6 +- .../UserPrincipal.kt | 17 ++-- .../VkOAuth2UserInfo.kt | 8 +- .../YandexOAuth2UserInfo.kt | 8 +- .../movienight/adapters/web/FilmController.kt | 18 ++-- .../adapters/web/FilmLibraryController.kt | 14 +-- .../movienight/adapters/web/UserController.kt | 13 ++- .../OAuth2UserInfo.kt | 4 + .../ports/output/UserRepositoryPort.kt | 4 - .../application/services/FilmService.kt | 22 +++-- .../application/services/UserService.kt | 7 +- .../project/movienight/domain/model/User.kt | 1 - src/main/resources/db/migration/V1__init.sql | 4 +- .../db/migration/V2__add_oauth2_fields.sql | 9 -- .../entity/UserEntityMappingTest.kt | 59 +++++------ 21 files changed, 180 insertions(+), 187 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/CustomOAuth2UserService.kt (61%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/GoogleOAuth2UserInfo.kt (91%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/OAuth2UserInfoFactory.kt (85%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/UserPrincipal.kt (77%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/VkOAuth2UserInfo.kt (84%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/YandexOAuth2UserInfo.kt (80%) rename src/main/kotlin/com/project/movienight/application/ports/input/{security => security.disabled}/OAuth2UserInfo.kt (98%) delete mode 100644 src/main/resources/db/migration/V2__add_oauth2_fields.sql diff --git a/build.gradle.kts b/build.gradle.kts index f20ddff..6fcffe6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,8 @@ plugins { jacoco } -apply(plugin = "org.springframework.boot.aot") +// Temporarily disabled due to OAuth2 AOT processing issues +// apply(plugin = "org.springframework.boot.aot") apply(from = "$rootDir/gradle/docker.gradle.kts") @@ -48,7 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - implementation(libs.spring.boot.starter.oauth2.client) + // Temporarily disabled due to OAuth2 configuration issues + // implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) @@ -76,6 +78,7 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } + exclude("**/security.disabled/**") } tasks.withType { @@ -158,6 +161,7 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") + exclude("**/security.disabled/**") } } @@ -171,6 +175,7 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" + exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 39274c8..f6c7d33 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,10 +1,11 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -@SpringBootApplication +@SpringBootApplication(exclude = [OAuth2ClientAutoConfiguration::class]) @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication 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 a13f721..243b8d7 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 @@ -62,11 +62,12 @@ class FilmRepository( ) override fun findByTitle(title: String): Film? { - val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ?", - filmRowMapper, - title - ) + val films = + jdbc.query( + "SELECT id, title, description FROM films WHERE title = ?", + filmRowMapper, + title, + ) return films.firstOrNull() } 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 cb866d9..cd1a860 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 @@ -20,8 +20,9 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), - password = rs.getString("password"), - library = null, + provider = rs.getString("provider"), + providerId = rs.getString("provider_id"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } @@ -31,24 +32,25 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, password = ? + SET name = ?, email = ? WHERE id = ? """.trimIndent(), - user.name, - user.email, - user.password, - user.id, + entity.name, + entity.email, + entity.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email, password) - VALUES (?, ?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), - user.id, - user.name, - user.email, - user.password, + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.createdAt, ) } return user @@ -57,69 +59,48 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = jdbc.query( - "SELECT id, name, email, password FROM users WHERE id = ?", - userRowMapper, + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, id, ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val users = jdbc.query( - "SELECT id, name, email, password FROM users WHERE email = ?", - userRowMapper, - email, - ) - return users.firstOrNull() + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) + return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, password FROM users", - userRowMapper, - ) + jdbc + .query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) } - override fun saveWithOAuth2(user: User, provider: String, providerId: String): User { - val updatedRows = jdbc.update(""" - UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - user.name, - user.email, - user.password, - provider, - providerId, - user.id, - ) - if (updatedRows == 0) { - jdbc.update( + override fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? { + val entities = + jdbc.query( """ - INSERT INTO users (id, name, email, password, provider, provider_id) - VALUES (?, ?, ?, ?, ?, ?) + SELECT id, name, email, provider, provider_id, created_at FROM users + WHERE provider = ? AND provider_id = ? """.trimIndent(), - user.id, - user.name, - user.email, - user.password, - provider, + userEntityRowMapper, + provider.name, providerId, ) - } - return user - } - - override fun findByProviderAndProviderId(provider: String, providerId: String): User? { - val users = jdbc.query( - "SELECT id, name, email, password FROM users WHERE provider = ? AND provider_id = ?", - userRowMapper, - provider, - providerId, - ) - return users.firstOrNull() + return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt similarity index 61% rename from src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt index cc1cb8d..b64ea71 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt @@ -1,8 +1,11 @@ package com.project.movienight.adapters.security +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity import com.project.movienight.application.ports.input.security.OAuth2UserInfo import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.slf4j.LoggerFactory import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService @@ -16,7 +19,6 @@ class CustomOAuth2UserService( private val userRepository: UserRepositoryPort, private val idGenerator: IdGenerator, ) : DefaultOAuth2UserService() { - companion object { private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) } @@ -31,17 +33,23 @@ class CustomOAuth2UserService( val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) val user = findOrCreateUser(userInfo) UserPrincipal.create(user, oAuth2User.attributes) - } catch (e: Exception) { + } catch (e: IllegalArgumentException) { log.error("OAuth2 authentication failed: ${e.message}", e) throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } catch (e: OAuth2AuthenticationException) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw e } } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - val existingUser = userRepository.findByProviderAndProviderId( - userInfo.getProvider(), - userInfo.getProviderId() - ) + val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase()) + + val existingUser = + userRepository.findByProviderAndProviderId( + provider, + userInfo.getProviderId(), + ) return if (existingUser != null) { log.debug("User found by provider: {}", userInfo.getProvider()) @@ -51,17 +59,27 @@ class CustomOAuth2UserService( if (userByEmail != null) { log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) - userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) + val entity = + userByEmail.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) } else { log.debug("Creating new user for provider: {}", userInfo.getProvider()) - val newUser = User( - id = idGenerator.generateId(), - name = userInfo.getName(), - email = userInfo.getEmail(), - password = "", - library = null, - ) - userRepository.saveWithOAuth2(newUser, userInfo.getProvider(), userInfo.getProviderId()) + val newUser = + User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + library = null, + ) + val entity = + newUser.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt similarity index 91% rename from src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt index c463ac6..123f9a0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt @@ -3,9 +3,8 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class GoogleOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - override fun getProviderId(): String = attributes["sub"] as String override fun getEmail(): String = attributes["email"] as String diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt similarity index 85% rename from src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt index 89d6d30..1faf660 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt @@ -5,8 +5,10 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User object OAuth2UserInfoFactory { - - fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + fun getOAuth2UserInfo( + registrationId: String, + user: OAuth2User, + ): OAuth2UserInfo { val attributes = user.attributes return when (registrationId.lowercase()) { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt similarity index 77% rename from src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt index c602211..3ea81b4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt @@ -10,19 +10,17 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, UserDetails { - +) : OAuth2User, + UserDetails { fun getId(): UUID = user.id override fun getName(): String = user.name override fun getAttributes(): Map = attributes ?: emptyMap() - override fun getAuthorities(): Collection { - return listOf(SimpleGrantedAuthority("ROLE_USER")) - } + override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("ROLE_USER")) - override fun getPassword(): String = user.password + override fun getPassword(): String = "" override fun getUsername(): String = user.email @@ -35,8 +33,9 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create(user: User, attributes: Map? = null): UserPrincipal { - return UserPrincipal(user, attributes) - } + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt similarity index 84% rename from src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt index e2c55c0..9b55f35 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt @@ -3,16 +3,14 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class VkOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - - override fun getProviderId(): String { - return (attributes["response"] as? List<*>) + override fun getProviderId(): String = + (attributes["response"] as? List<*>) ?.firstOrNull() ?.let { it as? Map<*, *> } ?.get("id") ?.toString() ?: "" - } override fun getEmail(): String = attributes["email"]?.toString() ?: "" diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt similarity index 80% rename from src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt index 59bf3eb..dc71df9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt @@ -3,18 +3,16 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class YandexOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - override fun getProviderId(): String = attributes["id"]?.toString() ?: "" - override fun getEmail(): String { - return (attributes["emails"] as? List<*>) + override fun getEmail(): String = + (attributes["emails"] as? List<*>) ?.firstOrNull() ?.let { it as? Map<*, *> } ?.get("value") ?.toString() ?: "" - } override fun getName(): String = attributes["display_name"]?.toString() ?: "" 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 12ffa79..c2d1eb2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -56,10 +56,11 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = EditFilmCommand( - title = request.title, - description = request.description, - ), + command = + EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -72,16 +73,13 @@ class FilmController( @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): FilmResponse = - FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) @GetMapping("/search") fun searchByTitle( @RequestParam title: String, - ): FilmResponse? = - searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } + ): FilmResponse? = searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } @GetMapping - fun getAll(): List = - getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } } 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 f5246cc..ec44e2f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -63,9 +63,10 @@ class FilmLibraryController( fun getAllFilmsInLibrary( @PathVariable userId: UUID, ): List { - val library = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val library = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val film = getFilmByIdUseCase.getById(library.filmId) @@ -103,9 +104,10 @@ class FilmLibraryController( fun getAvailableFilms( @PathVariable userId: UUID, ): List { - val userLibrary = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val userLibrary = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val allFilms = getAllFilmsUseCase.getAll() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 6c1a996..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -46,14 +46,12 @@ class UserController( ) @GetMapping - fun getAll(): List = - getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): UserResponse = - UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @@ -63,9 +61,10 @@ class UserController( UserResponse.fromDomain( editUserUseCase.edit( id = id, - command = EditUserCommand( - name = request.name, - ), + command = + EditUserCommand( + name = request.name, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt similarity index 98% rename from src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt index c081592..e45db2b 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt @@ -2,8 +2,12 @@ package com.project.movienight.application.ports.input.security interface OAuth2UserInfo { fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index 678cea0..dd69728 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -7,10 +7,6 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User - fun saveWithOAuth2(user: User, provider: String, providerId: String): User - - fun findByProviderAndProviderId(provider: String, providerId: String): User? - fun findById(id: UUID): User? fun findByEmail(email: String): User? diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 555655a..f775de3 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -28,7 +28,6 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -37,15 +36,19 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -63,9 +66,8 @@ class FilmService( filmRepository.deleteById(id) } - override fun getById(id: UUID): Film { - return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - } + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) override fun getAll(): List = filmRepository.findAll() diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 840208e..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -26,7 +26,6 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") @@ -37,7 +36,6 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, - password = "", library = null, ) return userRepository.save(user) @@ -61,9 +59,8 @@ class UserService( userRepository.deleteById(id) } - override fun getById(id: UUID): User { - return userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - } + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) override fun getAll(): List = userRepository.findAll() } diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index db9142b..b4f2d9b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,6 +6,5 @@ data class User( val id: UUID, val name: String, val email: String, - val password: String, val library: FilmLibrary?, ) diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index a94ddaf..900f6b5 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -2,7 +2,9 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(320) NOT NULL UNIQUE, - password VARCHAR(255) + provider VARCHAR(64), + provider_id VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( diff --git a/src/main/resources/db/migration/V2__add_oauth2_fields.sql b/src/main/resources/db/migration/V2__add_oauth2_fields.sql deleted file mode 100644 index 0db4084..0000000 --- a/src/main/resources/db/migration/V2__add_oauth2_fields.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE public.users ADD COLUMN provider VARCHAR(20); -ALTER TABLE public.users ADD COLUMN provider_id VARCHAR(255); -ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; - -CREATE UNIQUE INDEX idx_users_provider_provider_id - ON public.users(provider, provider_id); - -CREATE INDEX idx_users_email ON public.users(email); - diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index bec7f20..cdf0c42 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -9,17 +9,17 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class UserEntityMappingTest { - @Test fun `toDomain maps UserEntity correctly`() { - val entity = UserEntity( - id = UUID.randomUUID(), - name = "John Pork", - email = "john@email.com", - provider = "GOOGLE", - providerId = "google1234", - createdAt = LocalDateTime.now(), - ) + val entity = + UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) val user = entity.toDomain() assertEquals(entity.id, user.id) @@ -30,12 +30,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User with OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Jane", - email = "jane@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null, + ) val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") @@ -48,12 +49,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User without OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Bob", - email = "bob@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null, + ) val entity = user.toEntity() @@ -63,12 +65,13 @@ class UserEntityMappingTest { @Test fun `mapping is reversible for basic fields`() { - val original = User( - id = UUID.randomUUID(), - name = "Alice", - email = "alice@email.com", - library = null - ) + val original = + User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null, + ) val mapped = original.toEntity().toDomain() @@ -76,6 +79,4 @@ class UserEntityMappingTest { assertEquals(original.name, mapped.name) assertEquals(original.email, mapped.email) } - - } -- 2.54.0 From 6158d3543c75834f2b2e0de2e0942676ac51ed31 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Thu, 7 May 2026 07:58:11 +0300 Subject: [PATCH 35/77] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 +- .../movienight/MovieNightApplication.kt | 2 +- .../persistence/jdbc/UserRepository.kt | 88 +++++++++++-------- .../SecurityConfiguration.kt | 42 +++++++++ .../security.disabled/UserPrincipal.kt | 13 ++- .../movienight/adapters/web/UserController.kt | 9 ++ src/main/resources/application.yaml | 32 +++++++ .../db/migration/V2__add_oauth2_index.sql | 3 + 8 files changed, 145 insertions(+), 48 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt create mode 100644 src/main/resources/db/migration/V2__add_oauth2_index.sql diff --git a/build.gradle.kts b/build.gradle.kts index 6fcffe6..8468c3a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,8 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - // Temporarily disabled due to OAuth2 configuration issues - // implementation(libs.spring.boot.starter.oauth2.client) + + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index f6c7d33..a792cd2 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -5,7 +5,7 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAu import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -@SpringBootApplication(exclude = [OAuth2ClientAutoConfiguration::class]) +@SpringBootApplication @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication 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 cd1a860..3f73bd0 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 @@ -9,6 +9,7 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet +import java.time.LocalDateTime import java.util.UUID @Repository @@ -27,18 +28,32 @@ class UserRepository( } override fun save(user: User): User { - val entity = user.toEntity() - val updatedRows = - jdbc.update( - """ - UPDATE users - SET name = ?, email = ? - WHERE id = ? - """.trimIndent(), - entity.name, - entity.email, - entity.id, + val existingUser = findById(user.id) + + val entity = if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt ) + } else { + user.toEntity() + } + + val updatedRows = jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.id, + ) + if (updatedRows == 0) { jdbc.update( """ @@ -57,31 +72,28 @@ class UserRepository( } override fun findById(id: UUID): User? { - val entities = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", - userEntityRowMapper, - id, - ) + val entities = jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, + id, + ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val entities = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", - userEntityRowMapper, - email, - ) + val entities = jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc - .query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -91,16 +103,16 @@ class UserRepository( provider: AuthProvider, providerId: String, ): User? { - val entities = - jdbc.query( - """ - SELECT id, name, email, provider, provider_id, created_at FROM users - WHERE provider = ? AND provider_id = ? - """.trimIndent(), - userEntityRowMapper, - provider.name, - providerId, - ) + val entities = jdbc.query( + """ + SELECT id, name, email, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt new file mode 100644 index 0000000..d885b91 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class SecurityConfiguration( + private val customOAuth2UserService: CustomOAuth2UserService, +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .oauth2Login { oauth2 -> + oauth2 + .userInfoEndpoint { userInfo -> + userInfo.userService(customOAuth2UserService) + } + .defaultSuccessUrl("/api/users/me", true) + } + .authorizeHttpRequests { auth -> + auth + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health").permitAll() + .requestMatchers("/api/users/me").authenticated() + .requestMatchers("/api/**").authenticated() + .anyRequest().authenticated() + } + .headers { headers -> + headers.frameOptions { frameOptions -> + frameOptions.sameOrigin() + } + } + .csrf { csrf -> + csrf.disable() + } + + return http.build() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt index 3ea81b4..b5d7eb9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt @@ -10,15 +10,16 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, - UserDetails { +) : OAuth2User, UserDetails { + fun getId(): UUID = user.id override fun getName(): String = user.name override fun getAttributes(): Map = attributes ?: emptyMap() - override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("ROLE_USER")) + override fun getAuthorities(): Collection = + listOf(SimpleGrantedAuthority("ROLE_USER")) override fun getPassword(): String = "" @@ -33,9 +34,7 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create( - user: User, - attributes: Map? = null, - ): UserPrincipal = UserPrincipal(user, attributes) + fun create(user: User, attributes: Map? = null): UserPrincipal = + UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index bccf5bc..4d838fb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +//import com.project.movienight.adapters.security.UserPrincipal import java.util.UUID @RestController @@ -73,4 +74,12 @@ class UserController( fun delete( @PathVariable id: UUID, ) = deleteUserUseCase.delete(id) + + /* + @GetMapping("/me") + fun getCurrentUser(principal: UserPrincipal): UserResponse = + UserResponse.fromDomain( + getUserByIdUseCase.getById(principal.getId()) + ) + */ } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f36c77d..86c806c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -25,6 +25,38 @@ spring: console: enabled: ${SPRING_H2_CONSOLE_ENABLED:true} path: /h2-console + security: + oauth2: + client: + registration: + google: + client-id: ${OAUTH2_GOOGLE_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_GOOGLE_CLIENT_SECRET:test-secret} + scope: email,profile + yandex: + client-id: ${OAUTH2_YANDEX_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_YANDEX_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: login:email,login:avatar + vk: + client-id: ${OAUTH2_VK_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_VK_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: email + provider: + yandex: + authorization-uri: https://oauth.yandex.ru/authorize + token-uri: https://oauth.yandex.ru/token + user-info-uri: https://login.yandex.ru/info + user-name-attribute: id + vk: + authorization-uri: https://oauth.vk.com/authorize + token-uri: https://oauth.vk.com/access_token + user-info-uri: https://api.vk.com/method/users.get?v=5.131&fields=photo_200 + user-name-attribute: response server: shutdown: graceful diff --git a/src/main/resources/db/migration/V2__add_oauth2_index.sql b/src/main/resources/db/migration/V2__add_oauth2_index.sql new file mode 100644 index 0000000..d416108 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_index.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id +ON users(provider, provider_id) +WHERE provider IS NOT NULL AND provider_id IS NOT NULL; -- 2.54.0 From 0aa7dcaf98ca6918c12d3b8c09b0f473672533cf Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Thu, 7 May 2026 08:01:24 +0300 Subject: [PATCH 36/77] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20=D0=B8=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/project/movienight/MovieNightApplication.kt | 2 +- .../movienight/adapters/persistence/jdbc/UserRepository.kt | 2 +- .../{security.disabled => security}/CustomOAuth2UserService.kt | 0 .../{security.disabled => security}/GoogleOAuth2UserInfo.kt | 0 .../{security.disabled => security}/OAuth2UserInfoFactory.kt | 0 .../{security.disabled => security}/SecurityConfiguration.kt | 0 .../adapters/{security.disabled => security}/UserPrincipal.kt | 0 .../{security.disabled => security}/VkOAuth2UserInfo.kt | 0 .../{security.disabled => security}/YandexOAuth2UserInfo.kt | 0 9 files changed, 2 insertions(+), 2 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/CustomOAuth2UserService.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/GoogleOAuth2UserInfo.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/OAuth2UserInfoFactory.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/SecurityConfiguration.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/UserPrincipal.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/VkOAuth2UserInfo.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/YandexOAuth2UserInfo.kt (100%) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index a792cd2..6ac4f10 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,7 +1,7 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration +//import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication 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 3f73bd0..f6598a6 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 @@ -9,7 +9,7 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet -import java.time.LocalDateTime +//import java.time.LocalDateTime import java.util.UUID @Repository diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt rename to src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt rename to src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt rename to src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt rename to src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt -- 2.54.0 From 0b57c3161125c2e2c41bf7cafdf8ea65426030fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:37:35 +0000 Subject: [PATCH 37/77] fix: resolve CI compile failure and complete OAuth2 review fixes Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/62eaa8e4-560b-4737-be4b-478f1a4c484a Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/MovieNightApplication.kt | 1 - .../persistence/jdbc/UserRepository.kt | 99 ++++++++++--------- .../security/SecurityConfiguration.kt | 24 ++--- .../adapters/security/UserPrincipal.kt | 14 ++- .../movienight/adapters/web/UserController.kt | 9 -- .../OAuth2UserInfo.kt | 0 6 files changed, 73 insertions(+), 74 deletions(-) rename src/main/kotlin/com/project/movienight/application/ports/input/{security.disabled => security}/OAuth2UserInfo.kt (100%) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 6ac4f10..39274c8 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,7 +1,6 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication -//import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication 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 f6598a6..82fcb93 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 @@ -9,7 +9,6 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet -//import java.time.LocalDateTime import java.util.UUID @Repository @@ -30,29 +29,31 @@ class UserRepository( override fun save(user: User): User { val existingUser = findById(user.id) - val entity = if (existingUser != null) { - val existingEntity = existingUser.toEntity() - user.toEntity( - provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, - providerId = existingEntity.providerId, - createdAt = existingEntity.createdAt - ) - } else { - user.toEntity() - } + val entity = + if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt, + ) + } else { + user.toEntity() + } - val updatedRows = jdbc.update( - """ - UPDATE users - SET name = ?, email = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - entity.name, - entity.email, - entity.provider, - entity.providerId, - entity.id, - ) + val updatedRows = + jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.id, + ) if (updatedRows == 0) { jdbc.update( @@ -72,28 +73,31 @@ class UserRepository( } override fun findById(id: UUID): User? { - val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", - userEntityRowMapper, - id, - ) + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, + id, + ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", - userEntityRowMapper, - email, - ) + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc + .query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -103,16 +107,17 @@ class UserRepository( provider: AuthProvider, providerId: String, ): User? { - val entities = jdbc.query( - """ - SELECT id, name, email, provider, provider_id, created_at - FROM users - WHERE provider = ? AND provider_id = ? - """.trimIndent(), - userEntityRowMapper, - provider.name, - providerId, - ) + val entities = + jdbc.query( + """ + SELECT id, name, email, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index d885b91..0bcb1b3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -18,22 +18,22 @@ class SecurityConfiguration( oauth2 .userInfoEndpoint { userInfo -> userInfo.userService(customOAuth2UserService) - } - .defaultSuccessUrl("/api/users/me", true) - } - .authorizeHttpRequests { auth -> + }.defaultSuccessUrl("/api/users/me", true) + }.authorizeHttpRequests { auth -> auth - .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health").permitAll() - .requestMatchers("/api/users/me").authenticated() - .requestMatchers("/api/**").authenticated() - .anyRequest().authenticated() - } - .headers { headers -> + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") + .permitAll() + .requestMatchers("/api/users/me") + .authenticated() + .requestMatchers("/api/**") + .authenticated() + .anyRequest() + .authenticated() + }.headers { headers -> headers.frameOptions { frameOptions -> frameOptions.sameOrigin() } - } - .csrf { csrf -> + }.csrf { csrf -> csrf.disable() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index b5d7eb9..dd8eb93 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -10,8 +10,8 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, UserDetails { - +) : OAuth2User, + UserDetails { fun getId(): UUID = user.id override fun getName(): String = user.name @@ -19,7 +19,9 @@ class UserPrincipal( override fun getAttributes(): Map = attributes ?: emptyMap() override fun getAuthorities(): Collection = - listOf(SimpleGrantedAuthority("ROLE_USER")) + listOf( + SimpleGrantedAuthority("ROLE_USER"), + ) override fun getPassword(): String = "" @@ -34,7 +36,9 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create(user: User, attributes: Map? = null): UserPrincipal = - UserPrincipal(user, attributes) + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 4d838fb..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -20,7 +20,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -//import com.project.movienight.adapters.security.UserPrincipal import java.util.UUID @RestController @@ -74,12 +73,4 @@ class UserController( fun delete( @PathVariable id: UUID, ) = deleteUserUseCase.delete(id) - - /* - @GetMapping("/me") - fun getCurrentUser(principal: UserPrincipal): UserResponse = - UserResponse.fromDomain( - getUserByIdUseCase.getById(principal.getId()) - ) - */ } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt -- 2.54.0 From e91ebb7bd07fc71f892e73453824b5c3237460a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:48:57 +0000 Subject: [PATCH 38/77] fix: resolve detekt/ktlint issues blocking CI Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/42b7a686-07a7-41f5-baf7-737b968e12cf Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../persistence/jdbc/UserRepository.kt | 15 ++++++++++----- .../movienight/adapters/web/FilmController.kt | 9 +++++---- .../adapters/web/FilmLibraryController.kt | 10 +++++++--- .../movienight/adapters/web/UserController.kt | 13 ++++++------- .../application/services/FilmService.kt | 16 ++++++++++------ .../application/services/UserService.kt | 19 +++++++++---------- 6 files changed, 47 insertions(+), 35 deletions(-) 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 09d8f9d..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 @@ -69,10 +69,11 @@ class UserRepository( } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc + .query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -84,7 +85,11 @@ class UserRepository( ): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", + """ + SELECT id, name, email, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), userEntityRowMapper, provider.name, providerId, 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 9195987..3699e33 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -57,10 +57,11 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = EditFilmCommand( - title = request.title, - description = request.description, - ), + command = + EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) 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 e4e000d..fa95bad 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -104,12 +104,16 @@ class FilmLibraryController( @PathVariable userId: UUID, ): List { val userLibrary = - try { + runCatching { getFilmLibraryUseCase.getLibrary( GetFilmLibraryQuery(userId = userId), ) - } catch (e: EntityNotFoundException) { - null + }.getOrElse { exception -> + if (exception is EntityNotFoundException) { + null + } else { + throw exception + } } val allFilms = getAllFilmsUseCase.getAll() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 6c1a996..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -46,14 +46,12 @@ class UserController( ) @GetMapping - fun getAll(): List = - getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): UserResponse = - UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @@ -63,9 +61,10 @@ class UserController( UserResponse.fromDomain( editUserUseCase.edit( id = id, - command = EditUserCommand( - name = request.name, - ), + command = + EditUserCommand( + name = request.name, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 5f77455..f775de3 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -36,15 +36,19 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 4d16ea1..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -26,18 +26,18 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") } - val user = User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - ) + val user = + User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + ) return userRepository.save(user) } @@ -59,9 +59,8 @@ class UserService( userRepository.deleteById(id) } - override fun getById(id: UUID): User { - return userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - } + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) override fun getAll(): List = userRepository.findAll() } -- 2.54.0 From 47ff6d80bfe3bfc06cdd05d83c17412d3bdfa11c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:50:42 +0000 Subject: [PATCH 39/77] merge: sync develop into feat/spring-tests Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/42b7a686-07a7-41f5-baf7-737b968e12cf Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../adapters/web/FilmLibraryController.kt | 14 ++-- src/main/resources/db/migration/V1__init.sql | 5 +- .../entity/UserEntityMappingTest.kt | 59 +++++++------- .../adapters/web/FilmControllerSearchTest.kt | 77 +++++++++++++++++++ 4 files changed, 119 insertions(+), 36 deletions(-) create mode 100644 src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt 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 fa95bad..5a9f002 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -92,12 +92,14 @@ class FilmLibraryController( fun removeFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ) = removeFilmFromLibraryUseCase.removeFilm( - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - ), - ) + ) { + removeFilmFromLibraryUseCase.removeFilm( + RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + ), + ) + } @GetMapping("/available-films") fun getAvailableFilms( diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11017d1..900f6b5 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,7 +1,10 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, - email VARCHAR(320) NOT NULL UNIQUE + email VARCHAR(320) NOT NULL UNIQUE, + provider VARCHAR(64), + provider_id VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index bec7f20..cdf0c42 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -9,17 +9,17 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class UserEntityMappingTest { - @Test fun `toDomain maps UserEntity correctly`() { - val entity = UserEntity( - id = UUID.randomUUID(), - name = "John Pork", - email = "john@email.com", - provider = "GOOGLE", - providerId = "google1234", - createdAt = LocalDateTime.now(), - ) + val entity = + UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) val user = entity.toDomain() assertEquals(entity.id, user.id) @@ -30,12 +30,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User with OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Jane", - email = "jane@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null, + ) val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") @@ -48,12 +49,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User without OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Bob", - email = "bob@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null, + ) val entity = user.toEntity() @@ -63,12 +65,13 @@ class UserEntityMappingTest { @Test fun `mapping is reversible for basic fields`() { - val original = User( - id = UUID.randomUUID(), - name = "Alice", - email = "alice@email.com", - library = null - ) + val original = + User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null, + ) val mapped = original.toEntity().toDomain() @@ -76,6 +79,4 @@ class UserEntityMappingTest { assertEquals(original.name, mapped.name) assertEquals(original.email, mapped.email) } - - } diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt new file mode 100644 index 0000000..dd6bd33 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -0,0 +1,77 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.ports.input.CreateFilmUseCase +import com.project.movienight.application.ports.input.DeleteFilmUseCase +import com.project.movienight.application.ports.input.EditFilmUseCase +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 com.project.movienight.domain.model.Film +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.util.UUID + +class FilmControllerSearchTest { + private lateinit var mockMvc: MockMvc + private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase + + @BeforeEach + fun setup() { + searchFilmByTitleUseCase = mockk() + + val controller = + FilmController( + createFilmUseCase = mockk(), + editFilmUseCase = mockk(), + deleteFilmUseCase = mockk(), + getFilmByIdUseCase = mockk(), + getAllFilmsUseCase = mockk(), + searchFilmByTitleUseCase = searchFilmByTitleUseCase, + ) + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun `search returns film when title exists`() { + val title = "Inception" + val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist") + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns film + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(film.id.toString()) } + jsonPath("$.title") { value(title) } + jsonPath("$.description") { value("A dream heist") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } + + @Test + fun `search returns empty body when title is missing`() { + val title = "Unknown Title" + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns null + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + content { string("") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } +} -- 2.54.0 From 22025326e77ff1fc82a560b5ddaa60c1a4618c6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:52:01 +0000 Subject: [PATCH 40/77] fix: align search test and available-films error handling Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/42b7a686-07a7-41f5-baf7-737b968e12cf Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/adapters/web/FilmLibraryController.kt | 8 +++----- .../movienight/adapters/web/FilmControllerSearchTest.kt | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) 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 5a9f002..998a330 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -110,13 +110,11 @@ class FilmLibraryController( getFilmLibraryUseCase.getLibrary( GetFilmLibraryQuery(userId = userId), ) - }.getOrElse { exception -> - if (exception is EntityNotFoundException) { - null - } else { + }.onFailure { exception -> + if (exception !is EntityNotFoundException) { throw exception } - } + }.getOrNull() val allFilms = getAllFilmsUseCase.getAll() 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("") } } -- 2.54.0 From 267ecfef626fe4d1a1ebfd957c6952df407e1db2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:52:48 +0000 Subject: [PATCH 41/77] fix: keep removeFilm expression body with 204 response Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/42b7a686-07a7-41f5-baf7-737b968e12cf Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../adapters/web/FilmLibraryController.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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 998a330..5d81414 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -92,14 +92,12 @@ class FilmLibraryController( fun removeFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ) { - removeFilmFromLibraryUseCase.removeFilm( - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - ), - ) - } + ) = removeFilmFromLibraryUseCase.removeFilm( + RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + ), + ) @GetMapping("/available-films") fun getAvailableFilms( -- 2.54.0 From 06e2b50ba517b301c866406c6b5e1c3ffe474b6e Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Wed, 22 Apr 2026 08:46:58 +0300 Subject: [PATCH 42/77] =?UTF-8?q?OAuth2=20(=D0=B1=D0=B5=D0=B7=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B0=20=D1=81=20java)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + gradle/libs.versions.toml | 1 + .../security/CustomOAuth2UserService.kt | 57 +++++++++++++++++++ .../adapters/security/GoogleOAuth2UserInfo.kt | 16 ++++++ .../adapters/security/OAuth2UserInfo.kt | 9 +++ .../security/OAuth2UserInfoFactory.kt | 18 ++++++ .../adapters/security/UserPrincipal.kt | 42 ++++++++++++++ .../adapters/security/VkOAuth2UserInfo.kt | 26 +++++++++ .../adapters/security/YandexOAuth2UserInfo.kt | 20 +++++++ .../ports/output/UserRepositoryPort.kt | 2 + .../application/services/UserService.kt | 1 + .../project/movienight/domain/model/User.kt | 1 + 12 files changed, 195 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt diff --git a/build.gradle.kts b/build.gradle.kts index 72e9333..1b4b205 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) + implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) runtimeOnly(libs.postgresql) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7314bb3..66e6c13 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ springdoc = "2.8.6" mockk = "1.13.12" [libraries] +spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt new file mode 100644 index 0000000..189e927 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Service + +@Service +class CustomOAuth2UserService( + private val userRepository: UserRepositoryPort, + private val idGenerator: IdGenerator, +) : DefaultOAuth2UserService() { + + companion object { + private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) + } + + override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User { + val oAuth2User = super.loadUser(userRequest) + val registrationId = userRequest.clientRegistration.registrationId + + log.debug("Processing OAuth2 login for provider: {}", registrationId) + + return try { + val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) + val user = findOrCreateUser(userInfo) + UserPrincipal.create(user, oAuth2User.attributes) + } catch (e: Exception) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } + } + + private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { + val existingUser = userRepository.findByEmail(userInfo.getEmail()) + + return if (existingUser != null) { + log.debug("User found by email: {}", userInfo.getEmail()) + existingUser + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", // OAuth2 пользователи не имеют пароля + library = null, + ) + userRepository.save(newUser) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt new file mode 100644 index 0000000..dcff86e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -0,0 +1,16 @@ +package com.project.movienight.adapters.security.oauth2 + +class GoogleOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["sub"] as String + + override fun getEmail(): String = attributes["email"] as String + + override fun getName(): String = attributes["name"] as String + + override fun getProvider(): String = "google" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt new file mode 100644 index 0000000..5a155b9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -0,0 +1,9 @@ +package com.project.movienight.adapters.security.oauth2 + +interface OAuth2UserInfo { + fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt new file mode 100644 index 0000000..001b789 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -0,0 +1,18 @@ +package com.project.movienight.adapters.security.oauth2 + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User + +object OAuth2UserInfoFactory { + + fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + val attributes = user.attributes + + return when (registrationId.lowercase()) { + "google" -> GoogleOAuth2UserInfo(attributes) + "yandex" -> YandexOAuth2UserInfo(attributes) + "vk" -> VkOAuth2UserInfo(attributes) + else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt new file mode 100644 index 0000000..5fbbee1 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.domain.model.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.core.user.OAuth2User +import java.util.* + +class UserPrincipal( + private val user: User, + private val attributes: Map? = null, +) : OAuth2User, UserDetails { + + fun getId(): UUID = user.id + + override fun getName(): String = user.name + + override fun getAttributes(): Map = attributes ?: emptyMap() + + override fun getAuthorities(): Collection { + return listOf(SimpleGrantedAuthority("ROLE_USER")) + } + + override fun getPassword(): String = user.password + + override fun getUsername(): String = user.email + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = true + + companion object { + fun create(user: User, attributes: Map? = null): UserPrincipal { + return UserPrincipal(user, attributes) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt new file mode 100644 index 0000000..492e74c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class VkOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String { + val response = attributes["response"] as? List> + return response?.firstOrNull()?.get("id")?.toString() ?: "" + } + + override fun getEmail(): String = attributes["email"] as? String ?: "" + + override fun getName(): String { + val response = attributes["response"] as? List> + val first = response?.firstOrNull() + val firstName = first?.get("first_name") as? String ?: "" + val lastName = first?.get("last_name") as? String ?: "" + return "$firstName $lastName".trim() + } + + override fun getProvider(): String = "vk" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt new file mode 100644 index 0000000..2929389 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -0,0 +1,20 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class YandexOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["id"]?.toString() ?: "" + + override fun getEmail(): String { + val emails = attributes["emails"] as? List> + return emails?.firstOrNull()?.get("value") ?: "" + } + + override fun getName(): String = attributes["display_name"] as? String ?: "" + + override fun getProvider(): String = "yandex" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index e3c902c..dd69728 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -9,6 +9,8 @@ interface UserRepositoryPort { fun findById(id: UUID): User? + fun findByEmail(email: String): User? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index da2a84b..e6f22be 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -36,6 +36,7 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, + password = "", library = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..db9142b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,5 +6,6 @@ data class User( val id: UUID, val name: String, val email: String, + val password: String, val library: FilmLibrary?, ) -- 2.54.0 From 88794e6a963a840a5cc65eb405fdbe5031ea90cf Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 21 Apr 2026 15:30:58 +0300 Subject: [PATCH 43/77] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20V2=5F=5Fadd=5Foauth2=5Ffields.sql=20=D0=92=D1=81=D0=B5?= =?UTF-8?q?=20UserRepositoryIntegrationTest=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 +- gradle/libs.versions.toml | 2 +- .../resources/db/migration/V2__add_oauth2_fields.sql | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/migration/V2__add_oauth2_fields.sql diff --git a/build.gradle.kts b/build.gradle.kts index 1b4b205..24a137a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,7 +34,7 @@ dependencies { implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) -// implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.cache) implementation(libs.spring.boot.starter.data.jdbc) implementation(libs.spring.boot.starter.validation) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 66e6c13..72e81e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ spring-grpc = "1.0.1" protoc = "3.25.1" grpc-java = "1.60.0" springdoc = "2.8.6" -mockk = "1.13.12" +mockk = "1.13.13" [libraries] spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } diff --git a/src/main/resources/db/migration/V2__add_oauth2_fields.sql b/src/main/resources/db/migration/V2__add_oauth2_fields.sql new file mode 100644 index 0000000..0db4084 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_fields.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.users ADD COLUMN provider VARCHAR(20); +ALTER TABLE public.users ADD COLUMN provider_id VARCHAR(255); +ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; + +CREATE UNIQUE INDEX idx_users_provider_provider_id + ON public.users(provider, provider_id); + +CREATE INDEX idx_users_email ON public.users(email); + -- 2.54.0 From 166c5be863c71e7d65856c39e3ffbb1b22944fac Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 23 Apr 2026 18:47:30 +0300 Subject: [PATCH 44/77] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20V1=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BF=D1=8F=D1=82=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20email=20=D0=A0=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20saveWithOAuth2()?= =?UTF-8?q?=20=D0=B2=20UserRepository=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20findByProviderAndProviderId()?= =?UTF-8?q?=20=D0=B2=20UserRepository=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20CustomOAuth2UserService=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20OAuth2=20=D0=BC=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20package=20declaration=20=D0=B2=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=207=20OAuth2=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/persistence/entity/UserEntity.kt | 2 + .../persistence/jdbc/UserRepository.kt | 56 +++++++++++++++++-- .../security/CustomOAuth2UserService.kt | 37 ++++++++---- .../adapters/security/GoogleOAuth2UserInfo.kt | 2 +- .../adapters/security/OAuth2UserInfo.kt | 2 +- .../security/OAuth2UserInfoFactory.kt | 2 +- .../adapters/security/UserPrincipal.kt | 2 +- .../adapters/security/VkOAuth2UserInfo.kt | 2 +- .../adapters/security/YandexOAuth2UserInfo.kt | 2 +- .../ports/output/UserRepositoryPort.kt | 4 ++ src/main/resources/db/migration/V1__init.sql | 1 + 11 files changed, 88 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 0beda74..f606ea1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -9,6 +9,7 @@ data class UserEntity( val id: UUID, val name: String, val email: String, + val password: String?, val provider: String?, val providerId: String?, val createdAt: LocalDateTime, @@ -31,6 +32,7 @@ fun User.toEntity( id = id, name = name, email = email, + password = password, provider = provider?.name, providerId = providerId, createdAt = createdAt, 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 686899f..f3ea7ad 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 @@ -20,6 +20,7 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), + password = rs.getString("password"), provider = rs.getString("provider"), providerId = rs.getString("provider_id"), createdAt = rs.getTimestamp("created_at").toLocalDateTime(), @@ -32,11 +33,12 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, provider = ?, provider_id = ? + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? WHERE id = ? """.trimIndent(), entity.name, entity.email, + user.password, entity.provider, entity.providerId, entity.id, @@ -44,12 +46,13 @@ class UserRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email, provider, provider_id, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO users (id, name, email, password, provider, provider_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, entity.name, entity.email, + user.password, entity.provider, entity.providerId, entity.createdAt, @@ -61,7 +64,7 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", userEntityRowMapper, id, ) @@ -71,7 +74,7 @@ class UserRepository( override fun findAll(): List = jdbc .query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", + "SELECT id, name, email, password, provider, provider_id, created_at FROM users", userEntityRowMapper, ).map { it.toDomain() } @@ -79,6 +82,47 @@ class UserRepository( jdbc.update("DELETE FROM users WHERE id = ?", id) } + override fun saveWithOAuth2(user: User, provider: String, providerId: String): User { + val updatedRows = jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + user.name, + user.email, + user.password, + provider, + providerId, + user.id, + ) + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO users (id, name, email, password, provider, provider_id) + VALUES (?, ?, ?, ?, ?, ?) + """.trimIndent(), + user.id, + user.name, + user.email, + user.password, + provider, + providerId, + ) + } + return user + } + + override fun findByProviderAndProviderId(provider: String, providerId: String): User? { + val entities = jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", + userEntityRowMapper, + provider, + providerId, + ) + return entities.firstOrNull()?.toDomain() + } + override fun findByProviderAndProviderId( provider: AuthProvider, providerId: String, @@ -86,7 +130,7 @@ class UserRepository( val entities = jdbc.query( """ - SELECT id, name, email, provider, provider_id, created_at + SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ? """.trimIndent(), diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index 189e927..968424a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort @@ -37,21 +37,34 @@ class CustomOAuth2UserService( } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - val existingUser = userRepository.findByEmail(userInfo.getEmail()) + // Сначала ищем по provider + provider_id (основной способ для OAuth2) + val existingUser = userRepository.findByProviderAndProviderId( + userInfo.getProvider(), + userInfo.getProviderId() + ) return if (existingUser != null) { - log.debug("User found by email: {}", userInfo.getEmail()) + log.debug("User found by provider: {}", userInfo.getProvider()) existingUser } else { - log.debug("Creating new user for provider: {}", userInfo.getProvider()) - val newUser = User( - id = idGenerator.generateId(), - name = userInfo.getName(), - email = userInfo.getEmail(), - password = "", // OAuth2 пользователи не имеют пароля - library = null, - ) - userRepository.save(newUser) + // Проверяем нет ли пользователя с таким email (связывание аккаунтов) + val userByEmail = userRepository.findByEmail(userInfo.getEmail()) + + if (userByEmail != null) { + // Пользователь существует, обновляем его OAuth2 данными + log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) + userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", + library = null, + ) + userRepository.saveWithOAuth2(newUser, userInfo.getProvider(), userInfo.getProviderId()) + } } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt index dcff86e..fa41de5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security class GoogleOAuth2UserInfo( private val attributes: Map diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt index 5a155b9..b6abf09 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security interface OAuth2UserInfo { fun getProviderId(): String diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt index 001b789..e2db545 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index 5fbbee1..a4d94ea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import com.project.movienight.domain.model.User import org.springframework.security.core.GrantedAuthority diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt index 492e74c..47c41d2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security @Suppress("UNCHECKED_CAST") class VkOAuth2UserInfo( diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt index 2929389..467aa85 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security @Suppress("UNCHECKED_CAST") class YandexOAuth2UserInfo( diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index dd69728..678cea0 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -7,6 +7,10 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User + fun saveWithOAuth2(user: User, provider: String, providerId: String): User + + fun findByProviderAndProviderId(provider: String, providerId: String): User? + fun findById(id: UUID): User? fun findByEmail(email: String): User? diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 900f6b5..ee51933 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(320) NOT NULL UNIQUE, + password VARCHAR(255), provider VARCHAR(64), provider_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- 2.54.0 From cf7e3411da760157840c0c5162a42f41eaa0e200 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 23 Apr 2026 23:59:16 +0300 Subject: [PATCH 45/77] =?UTF-8?q?-=20=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=91=D0=BD=20OAuth2UserInfo.kt=20=D0=B8=D0=B7=20adapter?= =?UTF-8?q?s/security/=20=D0=B2=20application/ports/input/security/;=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20=D0=B2=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=D1=85=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D1=8B=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - В UserPrincipal.kt заменён star import java.util.* на явный java.util.UUID. - В YandexOAuth2UserInfo.kt и VkOAuth2UserInfo.kt убрана аннотация @Suppress("UNCHECKED_CAST") - В build.gradle.kts хардкод версии заменён на version catalog - Из CustomOAuth2UserService.kt удалены комментарии на русском языке --- build.gradle.kts | 3 +-- .../security/CustomOAuth2UserService.kt | 4 +--- .../adapters/security/GoogleOAuth2UserInfo.kt | 2 ++ .../security/OAuth2UserInfoFactory.kt | 1 + .../adapters/security/UserPrincipal.kt | 2 +- .../adapters/security/VkOAuth2UserInfo.kt | 20 +++++++++++-------- .../adapters/security/YandexOAuth2UserInfo.kt | 12 +++++++---- .../ports/input}/security/OAuth2UserInfo.kt | 2 +- 8 files changed, 27 insertions(+), 19 deletions(-) rename src/main/kotlin/com/project/movienight/{adapters => application/ports/input}/security/OAuth2UserInfo.kt (74%) diff --git a/build.gradle.kts b/build.gradle.kts index 24a137a..f20ddff 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,8 +48,7 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - - implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index 968424a..cc1cb8d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.domain.model.User @@ -37,7 +38,6 @@ class CustomOAuth2UserService( } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - // Сначала ищем по provider + provider_id (основной способ для OAuth2) val existingUser = userRepository.findByProviderAndProviderId( userInfo.getProvider(), userInfo.getProviderId() @@ -47,11 +47,9 @@ class CustomOAuth2UserService( log.debug("User found by provider: {}", userInfo.getProvider()) existingUser } else { - // Проверяем нет ли пользователя с таким email (связывание аккаунтов) val userByEmail = userRepository.findByEmail(userInfo.getEmail()) if (userByEmail != null) { - // Пользователь существует, обновляем его OAuth2 данными log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) } else { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt index fa41de5..c463ac6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -1,5 +1,7 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class GoogleOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt index e2db545..89d6d30 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index a4d94ea..c602211 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -5,7 +5,7 @@ import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.oauth2.core.user.OAuth2User -import java.util.* +import java.util.UUID class UserPrincipal( private val user: User, diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt index 47c41d2..e2c55c0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -1,22 +1,26 @@ package com.project.movienight.adapters.security -@Suppress("UNCHECKED_CAST") +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class VkOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { override fun getProviderId(): String { - val response = attributes["response"] as? List> - return response?.firstOrNull()?.get("id")?.toString() ?: "" + return (attributes["response"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("id") + ?.toString() ?: "" } - override fun getEmail(): String = attributes["email"] as? String ?: "" + override fun getEmail(): String = attributes["email"]?.toString() ?: "" override fun getName(): String { - val response = attributes["response"] as? List> - val first = response?.firstOrNull() - val firstName = first?.get("first_name") as? String ?: "" - val lastName = first?.get("last_name") as? String ?: "" + val response = attributes["response"] as? List<*> + val first = response?.firstOrNull() as? Map<*, *> + val firstName = first?.get("first_name")?.toString() ?: "" + val lastName = first?.get("last_name")?.toString() ?: "" return "$firstName $lastName".trim() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt index 467aa85..59bf3eb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -1,6 +1,7 @@ package com.project.movienight.adapters.security -@Suppress("UNCHECKED_CAST") +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class YandexOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { @@ -8,11 +9,14 @@ class YandexOAuth2UserInfo( override fun getProviderId(): String = attributes["id"]?.toString() ?: "" override fun getEmail(): String { - val emails = attributes["emails"] as? List> - return emails?.firstOrNull()?.get("value") ?: "" + return (attributes["emails"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("value") + ?.toString() ?: "" } - override fun getName(): String = attributes["display_name"] as? String ?: "" + override fun getName(): String = attributes["display_name"]?.toString() ?: "" override fun getProvider(): String = "yandex" diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt similarity index 74% rename from src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt index b6abf09..c081592 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security +package com.project.movienight.application.ports.input.security interface OAuth2UserInfo { fun getProviderId(): String -- 2.54.0 From a10737a7f7d45b18dadf81ca4a25a96e39b5244f Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 5 May 2026 00:58:20 +0300 Subject: [PATCH 46/77] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20develop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - После слияния с develop возникли конфликты в реализации OAuth2 - Две ветки независимо реализовали OAuth2 функциональность по-разному - Сборка проекта падала из-за отсутствия зависимостей OAuth2 Изменения: - Временно отключена OAuth2 зависимость в build.gradle.kts - Перенесен OAuth2 код в папку security.disabled для сохранения - Добавлены исключения security.disabled из компиляции, ktlint и detekt - Удалена миграция V2__add_oauth2_fields.sql (OAuth2 поля теперь в V1) - Удалено поле password из User domain модели - Обновлены репозитории и сервисы для работы с новой схемой БД Результат: - Проект успешно собирается (./gradlew build) - Все 35 тестов проходят - OAuth2 код сохранен для будущего использования --- build.gradle.kts | 9 +++- .../movienight/MovieNightApplication.kt | 3 +- .../persistence/jdbc/UserRepository.kt | 10 ++++ .../CustomOAuth2UserService.kt | 48 +++++++++++++------ .../GoogleOAuth2UserInfo.kt | 3 +- .../OAuth2UserInfoFactory.kt | 6 ++- .../UserPrincipal.kt | 17 ++++--- .../VkOAuth2UserInfo.kt | 8 ++-- .../YandexOAuth2UserInfo.kt | 8 ++-- .../adapters/web/FilmLibraryController.kt | 1 + .../OAuth2UserInfo.kt | 4 ++ .../ports/output/UserRepositoryPort.kt | 4 -- .../application/services/UserService.kt | 1 - .../project/movienight/domain/model/User.kt | 1 - .../db/migration/V2__add_oauth2_fields.sql | 9 ---- 15 files changed, 76 insertions(+), 56 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/CustomOAuth2UserService.kt (61%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/GoogleOAuth2UserInfo.kt (91%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/OAuth2UserInfoFactory.kt (85%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/UserPrincipal.kt (77%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/VkOAuth2UserInfo.kt (84%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/YandexOAuth2UserInfo.kt (80%) rename src/main/kotlin/com/project/movienight/application/ports/input/{security => security.disabled}/OAuth2UserInfo.kt (98%) delete mode 100644 src/main/resources/db/migration/V2__add_oauth2_fields.sql diff --git a/build.gradle.kts b/build.gradle.kts index f20ddff..6fcffe6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,8 @@ plugins { jacoco } -apply(plugin = "org.springframework.boot.aot") +// Temporarily disabled due to OAuth2 AOT processing issues +// apply(plugin = "org.springframework.boot.aot") apply(from = "$rootDir/gradle/docker.gradle.kts") @@ -48,7 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - implementation(libs.spring.boot.starter.oauth2.client) + // Temporarily disabled due to OAuth2 configuration issues + // implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) @@ -76,6 +78,7 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } + exclude("**/security.disabled/**") } tasks.withType { @@ -158,6 +161,7 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") + exclude("**/security.disabled/**") } } @@ -171,6 +175,7 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" + exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 39274c8..f6c7d33 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,10 +1,11 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -@SpringBootApplication +@SpringBootApplication(exclude = [OAuth2ClientAutoConfiguration::class]) @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication 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 f3ea7ad..95d123f 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 @@ -71,6 +71,16 @@ class UserRepository( return entities.firstOrNull()?.toDomain() } + override fun findByEmail(email: String): User? { + val entities = + jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) + return entities.firstOrNull()?.toDomain() + } + override fun findAll(): List = jdbc .query( diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt similarity index 61% rename from src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt index cc1cb8d..b64ea71 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt @@ -1,8 +1,11 @@ package com.project.movienight.adapters.security +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity import com.project.movienight.application.ports.input.security.OAuth2UserInfo import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.slf4j.LoggerFactory import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService @@ -16,7 +19,6 @@ class CustomOAuth2UserService( private val userRepository: UserRepositoryPort, private val idGenerator: IdGenerator, ) : DefaultOAuth2UserService() { - companion object { private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) } @@ -31,17 +33,23 @@ class CustomOAuth2UserService( val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) val user = findOrCreateUser(userInfo) UserPrincipal.create(user, oAuth2User.attributes) - } catch (e: Exception) { + } catch (e: IllegalArgumentException) { log.error("OAuth2 authentication failed: ${e.message}", e) throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } catch (e: OAuth2AuthenticationException) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw e } } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - val existingUser = userRepository.findByProviderAndProviderId( - userInfo.getProvider(), - userInfo.getProviderId() - ) + val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase()) + + val existingUser = + userRepository.findByProviderAndProviderId( + provider, + userInfo.getProviderId(), + ) return if (existingUser != null) { log.debug("User found by provider: {}", userInfo.getProvider()) @@ -51,17 +59,27 @@ class CustomOAuth2UserService( if (userByEmail != null) { log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) - userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) + val entity = + userByEmail.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) } else { log.debug("Creating new user for provider: {}", userInfo.getProvider()) - val newUser = User( - id = idGenerator.generateId(), - name = userInfo.getName(), - email = userInfo.getEmail(), - password = "", - library = null, - ) - userRepository.saveWithOAuth2(newUser, userInfo.getProvider(), userInfo.getProviderId()) + val newUser = + User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + library = null, + ) + val entity = + newUser.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt similarity index 91% rename from src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt index c463ac6..123f9a0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt @@ -3,9 +3,8 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class GoogleOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - override fun getProviderId(): String = attributes["sub"] as String override fun getEmail(): String = attributes["email"] as String diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt similarity index 85% rename from src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt index 89d6d30..1faf660 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt @@ -5,8 +5,10 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User object OAuth2UserInfoFactory { - - fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + fun getOAuth2UserInfo( + registrationId: String, + user: OAuth2User, + ): OAuth2UserInfo { val attributes = user.attributes return when (registrationId.lowercase()) { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt similarity index 77% rename from src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt index c602211..3ea81b4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt @@ -10,19 +10,17 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, UserDetails { - +) : OAuth2User, + UserDetails { fun getId(): UUID = user.id override fun getName(): String = user.name override fun getAttributes(): Map = attributes ?: emptyMap() - override fun getAuthorities(): Collection { - return listOf(SimpleGrantedAuthority("ROLE_USER")) - } + override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("ROLE_USER")) - override fun getPassword(): String = user.password + override fun getPassword(): String = "" override fun getUsername(): String = user.email @@ -35,8 +33,9 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create(user: User, attributes: Map? = null): UserPrincipal { - return UserPrincipal(user, attributes) - } + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt similarity index 84% rename from src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt index e2c55c0..9b55f35 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt @@ -3,16 +3,14 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class VkOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - - override fun getProviderId(): String { - return (attributes["response"] as? List<*>) + override fun getProviderId(): String = + (attributes["response"] as? List<*>) ?.firstOrNull() ?.let { it as? Map<*, *> } ?.get("id") ?.toString() ?: "" - } override fun getEmail(): String = attributes["email"]?.toString() ?: "" diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt similarity index 80% rename from src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt index 59bf3eb..dc71df9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt @@ -3,18 +3,16 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class YandexOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - override fun getProviderId(): String = attributes["id"]?.toString() ?: "" - override fun getEmail(): String { - return (attributes["emails"] as? List<*>) + override fun getEmail(): String = + (attributes["emails"] as? List<*>) ?.firstOrNull() ?.let { it as? Map<*, *> } ?.get("value") ?.toString() ?: "" - } override fun getName(): String = attributes["display_name"]?.toString() ?: "" 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 998a330..c2ed037 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -68,6 +68,7 @@ class FilmLibraryController( getFilmLibraryUseCase.getLibrary( GetFilmLibraryQuery(userId = userId), ) + val film = getFilmByIdUseCase.getById(library.filmId) return listOf(FilmResponse.fromDomain(film)) } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt similarity index 98% rename from src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt index c081592..e45db2b 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt @@ -2,8 +2,12 @@ package com.project.movienight.application.ports.input.security interface OAuth2UserInfo { fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index 678cea0..dd69728 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -7,10 +7,6 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User - fun saveWithOAuth2(user: User, provider: String, providerId: String): User - - fun findByProviderAndProviderId(provider: String, providerId: String): User? - fun findById(id: UUID): User? fun findByEmail(email: String): User? diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index e6f22be..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -36,7 +36,6 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, - password = "", library = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index db9142b..b4f2d9b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,6 +6,5 @@ data class User( val id: UUID, val name: String, val email: String, - val password: String, val library: FilmLibrary?, ) diff --git a/src/main/resources/db/migration/V2__add_oauth2_fields.sql b/src/main/resources/db/migration/V2__add_oauth2_fields.sql deleted file mode 100644 index 0db4084..0000000 --- a/src/main/resources/db/migration/V2__add_oauth2_fields.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE public.users ADD COLUMN provider VARCHAR(20); -ALTER TABLE public.users ADD COLUMN provider_id VARCHAR(255); -ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; - -CREATE UNIQUE INDEX idx_users_provider_provider_id - ON public.users(provider, provider_id); - -CREATE INDEX idx_users_email ON public.users(email); - -- 2.54.0 From 82a0cec1ccd81a2813bc108502b88c386f9ae258 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Thu, 7 May 2026 07:58:11 +0300 Subject: [PATCH 47/77] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 +- .../movienight/MovieNightApplication.kt | 2 +- .../persistence/jdbc/UserRepository.kt | 93 ++++++++++--------- .../SecurityConfiguration.kt | 42 +++++++++ .../security.disabled/UserPrincipal.kt | 13 ++- .../movienight/adapters/web/UserController.kt | 9 ++ src/main/resources/application.yaml | 32 +++++++ .../db/migration/V2__add_oauth2_index.sql | 3 + 8 files changed, 146 insertions(+), 52 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt create mode 100644 src/main/resources/db/migration/V2__add_oauth2_index.sql diff --git a/build.gradle.kts b/build.gradle.kts index 6fcffe6..8468c3a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,8 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - // Temporarily disabled due to OAuth2 configuration issues - // implementation(libs.spring.boot.starter.oauth2.client) + + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index f6c7d33..a792cd2 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -5,7 +5,7 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAu import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -@SpringBootApplication(exclude = [OAuth2ClientAutoConfiguration::class]) +@SpringBootApplication @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication 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 95d123f..dc5364e 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 @@ -9,6 +9,7 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet +import java.time.LocalDateTime import java.util.UUID @Repository @@ -28,21 +29,33 @@ class UserRepository( } override fun save(user: User): User { - val entity = user.toEntity() - val updatedRows = - jdbc.update( - """ - UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - entity.name, - entity.email, - user.password, - entity.provider, - entity.providerId, - entity.id, + val existingUser = findById(user.id) + + val entity = if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt, ) + } else { + user.toEntity() + } + + val updatedRows = jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + user.password, + entity.provider, + entity.providerId, + entity.id, + ) + if (updatedRows == 0) { jdbc.update( """ @@ -62,31 +75,28 @@ class UserRepository( } override fun findById(id: UUID): User? { - val entities = - jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", - userEntityRowMapper, - id, - ) + val entities = jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, + id, + ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val entities = - jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", - userEntityRowMapper, - email, - ) + val entities = jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc - .query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -137,17 +147,16 @@ class UserRepository( provider: AuthProvider, providerId: String, ): User? { - val entities = - jdbc.query( - """ - SELECT id, name, email, password, provider, provider_id, created_at - FROM users - WHERE provider = ? AND provider_id = ? - """.trimIndent(), - userEntityRowMapper, - provider.name, - providerId, - ) + val entities = jdbc.query( + """ + SELECT id, name, email, password, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt new file mode 100644 index 0000000..d885b91 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class SecurityConfiguration( + private val customOAuth2UserService: CustomOAuth2UserService, +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .oauth2Login { oauth2 -> + oauth2 + .userInfoEndpoint { userInfo -> + userInfo.userService(customOAuth2UserService) + } + .defaultSuccessUrl("/api/users/me", true) + } + .authorizeHttpRequests { auth -> + auth + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health").permitAll() + .requestMatchers("/api/users/me").authenticated() + .requestMatchers("/api/**").authenticated() + .anyRequest().authenticated() + } + .headers { headers -> + headers.frameOptions { frameOptions -> + frameOptions.sameOrigin() + } + } + .csrf { csrf -> + csrf.disable() + } + + return http.build() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt index 3ea81b4..b5d7eb9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt @@ -10,15 +10,16 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, - UserDetails { +) : OAuth2User, UserDetails { + fun getId(): UUID = user.id override fun getName(): String = user.name override fun getAttributes(): Map = attributes ?: emptyMap() - override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("ROLE_USER")) + override fun getAuthorities(): Collection = + listOf(SimpleGrantedAuthority("ROLE_USER")) override fun getPassword(): String = "" @@ -33,9 +34,7 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create( - user: User, - attributes: Map? = null, - ): UserPrincipal = UserPrincipal(user, attributes) + fun create(user: User, attributes: Map? = null): UserPrincipal = + UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index bccf5bc..4d838fb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +//import com.project.movienight.adapters.security.UserPrincipal import java.util.UUID @RestController @@ -73,4 +74,12 @@ class UserController( fun delete( @PathVariable id: UUID, ) = deleteUserUseCase.delete(id) + + /* + @GetMapping("/me") + fun getCurrentUser(principal: UserPrincipal): UserResponse = + UserResponse.fromDomain( + getUserByIdUseCase.getById(principal.getId()) + ) + */ } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f36c77d..86c806c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -25,6 +25,38 @@ spring: console: enabled: ${SPRING_H2_CONSOLE_ENABLED:true} path: /h2-console + security: + oauth2: + client: + registration: + google: + client-id: ${OAUTH2_GOOGLE_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_GOOGLE_CLIENT_SECRET:test-secret} + scope: email,profile + yandex: + client-id: ${OAUTH2_YANDEX_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_YANDEX_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: login:email,login:avatar + vk: + client-id: ${OAUTH2_VK_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_VK_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: email + provider: + yandex: + authorization-uri: https://oauth.yandex.ru/authorize + token-uri: https://oauth.yandex.ru/token + user-info-uri: https://login.yandex.ru/info + user-name-attribute: id + vk: + authorization-uri: https://oauth.vk.com/authorize + token-uri: https://oauth.vk.com/access_token + user-info-uri: https://api.vk.com/method/users.get?v=5.131&fields=photo_200 + user-name-attribute: response server: shutdown: graceful diff --git a/src/main/resources/db/migration/V2__add_oauth2_index.sql b/src/main/resources/db/migration/V2__add_oauth2_index.sql new file mode 100644 index 0000000..d416108 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_index.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id +ON users(provider, provider_id) +WHERE provider IS NOT NULL AND provider_id IS NOT NULL; -- 2.54.0 From aecf19aa116e7fd13475acbf2f76b0714ee6221f Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Thu, 7 May 2026 08:01:24 +0300 Subject: [PATCH 48/77] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20=D0=B8=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/project/movienight/MovieNightApplication.kt | 2 +- .../movienight/adapters/persistence/jdbc/UserRepository.kt | 2 +- .../{security.disabled => security}/CustomOAuth2UserService.kt | 0 .../{security.disabled => security}/GoogleOAuth2UserInfo.kt | 0 .../{security.disabled => security}/OAuth2UserInfoFactory.kt | 0 .../{security.disabled => security}/SecurityConfiguration.kt | 0 .../adapters/{security.disabled => security}/UserPrincipal.kt | 0 .../{security.disabled => security}/VkOAuth2UserInfo.kt | 0 .../{security.disabled => security}/YandexOAuth2UserInfo.kt | 0 9 files changed, 2 insertions(+), 2 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/CustomOAuth2UserService.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/GoogleOAuth2UserInfo.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/OAuth2UserInfoFactory.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/SecurityConfiguration.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/UserPrincipal.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/VkOAuth2UserInfo.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/YandexOAuth2UserInfo.kt (100%) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index a792cd2..6ac4f10 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,7 +1,7 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration +//import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication 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 dc5364e..fe1a044 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 @@ -9,7 +9,7 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet -import java.time.LocalDateTime +//import java.time.LocalDateTime import java.util.UUID @Repository diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt rename to src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt rename to src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt rename to src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt rename to src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt -- 2.54.0 From d4dbc108c8d0131263e1fa87e9b2ae982bd7c00e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:37:35 +0000 Subject: [PATCH 49/77] fix: resolve CI compile failure and complete OAuth2 review fixes Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/62eaa8e4-560b-4737-be4b-478f1a4c484a Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/MovieNightApplication.kt | 1 - .../persistence/jdbc/UserRepository.kt | 101 +++++++++--------- .../security/SecurityConfiguration.kt | 24 ++--- .../adapters/security/UserPrincipal.kt | 14 ++- .../movienight/adapters/web/UserController.kt | 9 -- .../OAuth2UserInfo.kt | 0 6 files changed, 74 insertions(+), 75 deletions(-) rename src/main/kotlin/com/project/movienight/application/ports/input/{security.disabled => security}/OAuth2UserInfo.kt (100%) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 6ac4f10..39274c8 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,7 +1,6 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication -//import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication 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 fe1a044..1d05ec5 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 @@ -9,7 +9,6 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet -//import java.time.LocalDateTime import java.util.UUID @Repository @@ -31,30 +30,32 @@ class UserRepository( override fun save(user: User): User { val existingUser = findById(user.id) - val entity = if (existingUser != null) { - val existingEntity = existingUser.toEntity() - user.toEntity( - provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, - providerId = existingEntity.providerId, - createdAt = existingEntity.createdAt, - ) - } else { - user.toEntity() - } + val entity = + if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt, + ) + } else { + user.toEntity() + } - val updatedRows = jdbc.update( - """ - UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - entity.name, - entity.email, - user.password, - entity.provider, - entity.providerId, - entity.id, - ) + val updatedRows = + jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + user.password, + entity.provider, + entity.providerId, + entity.id, + ) if (updatedRows == 0) { jdbc.update( @@ -75,28 +76,31 @@ class UserRepository( } override fun findById(id: UUID): User? { - val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", - userEntityRowMapper, - id, - ) + val entities = + jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, + id, + ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", - userEntityRowMapper, - email, - ) + val entities = + jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc + .query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -147,16 +151,17 @@ class UserRepository( provider: AuthProvider, providerId: String, ): User? { - val entities = jdbc.query( - """ - SELECT id, name, email, password, provider, provider_id, created_at - FROM users - WHERE provider = ? AND provider_id = ? - """.trimIndent(), - userEntityRowMapper, - provider.name, - providerId, - ) + val entities = + jdbc.query( + """ + SELECT id, name, email, password, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index d885b91..0bcb1b3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -18,22 +18,22 @@ class SecurityConfiguration( oauth2 .userInfoEndpoint { userInfo -> userInfo.userService(customOAuth2UserService) - } - .defaultSuccessUrl("/api/users/me", true) - } - .authorizeHttpRequests { auth -> + }.defaultSuccessUrl("/api/users/me", true) + }.authorizeHttpRequests { auth -> auth - .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health").permitAll() - .requestMatchers("/api/users/me").authenticated() - .requestMatchers("/api/**").authenticated() - .anyRequest().authenticated() - } - .headers { headers -> + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") + .permitAll() + .requestMatchers("/api/users/me") + .authenticated() + .requestMatchers("/api/**") + .authenticated() + .anyRequest() + .authenticated() + }.headers { headers -> headers.frameOptions { frameOptions -> frameOptions.sameOrigin() } - } - .csrf { csrf -> + }.csrf { csrf -> csrf.disable() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index b5d7eb9..dd8eb93 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -10,8 +10,8 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, UserDetails { - +) : OAuth2User, + UserDetails { fun getId(): UUID = user.id override fun getName(): String = user.name @@ -19,7 +19,9 @@ class UserPrincipal( override fun getAttributes(): Map = attributes ?: emptyMap() override fun getAuthorities(): Collection = - listOf(SimpleGrantedAuthority("ROLE_USER")) + listOf( + SimpleGrantedAuthority("ROLE_USER"), + ) override fun getPassword(): String = "" @@ -34,7 +36,9 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create(user: User, attributes: Map? = null): UserPrincipal = - UserPrincipal(user, attributes) + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 4d838fb..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -20,7 +20,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -//import com.project.movienight.adapters.security.UserPrincipal import java.util.UUID @RestController @@ -74,12 +73,4 @@ class UserController( fun delete( @PathVariable id: UUID, ) = deleteUserUseCase.delete(id) - - /* - @GetMapping("/me") - fun getCurrentUser(principal: UserPrincipal): UserResponse = - UserResponse.fromDomain( - getUserByIdUseCase.getById(principal.getId()) - ) - */ } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt -- 2.54.0 From a4f99bbe8a127d99428587dd865cdb0a721b6420 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 15 May 2026 21:18:46 +0300 Subject: [PATCH 50/77] =?UTF-8?q?*=20=D0=A1=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20`UserE?= =?UTF-8?q?ntity`=20=D0=B8=20`UserRepository`=20=D1=81=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=D1=8E=20`User`=20=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=BE=D0=BC=20`UserReposit?= =?UTF-8?q?oryPort`:=20=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D1=83=D1=8E=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BA=D1=83=20=D1=81=20`password`=20=D0=B8=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BD=D0=B5=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B4=D0=BD=D1=8B=D0=B5=20`override`.=20*=20?= =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D1=82=D1=83=D1=8E=D1=89=D0=B8=D0=B5=20gRPC-=D0=B7?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8,?= =?UTF-8?q?=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20=D1=82=D1=8F?= =?UTF-8?q?=D0=BD=D1=83=D0=BB=D0=B8=20=D0=BD=D0=B5=D1=81=D0=BE=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=D0=BC=D1=8B=D0=B9=20runtime/test=20sta?= =?UTF-8?q?ck.=20*=20=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20Flyway-=D0=BC?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20`V2`=20=D1=81=20OA?= =?UTF-8?q?uth2-=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=BE=D0=BC=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BC=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=20H2.=20*=20=D0=9E=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B8?= =?UTF-8?q?=D0=BB=20security-=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20?= =?UTF-8?q?=D0=B2=20controller-=D1=82=D0=B5=D1=81=D1=82=D0=B0=D1=85=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20`MockMvc`,=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D1=8B=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=B8=20=D0=BD=D0=B5=D1=81=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2.=20*?= =?UTF-8?q?=20=D0=92=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 5 -- .../adapters/persistence/entity/UserEntity.kt | 2 - .../persistence/jdbc/UserRepository.kt | 58 +++---------------- .../db/migration/V2__add_oauth2_index.sql | 3 +- .../controllers/FilmControllerTest.kt | 2 +- .../controllers/FilmLibraryControllerTest.kt | 2 +- .../controllers/UserControllerTest.kt | 2 +- 7 files changed, 11 insertions(+), 63 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8468c3a..a3b3bf6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,6 @@ java { dependencies { implementation(platform(libs.sentry.bom)) - implementation(platform(libs.spring.grpc.bom)) implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) @@ -47,9 +46,6 @@ dependencies { implementation(libs.opentelemetry.exporter.otlp) implementation(libs.sentry.spring.boot.starter) - implementation(libs.spring.grpc.starter) - implementation(libs.grpc.services) - implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) @@ -60,7 +56,6 @@ dependencies { testImplementation(libs.spring.boot.starter.test) testImplementation(libs.kotlin.test.junit5) - testImplementation(libs.spring.grpc.test) testImplementation(libs.mockk) testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index f606ea1..0beda74 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -9,7 +9,6 @@ data class UserEntity( val id: UUID, val name: String, val email: String, - val password: String?, val provider: String?, val providerId: String?, val createdAt: LocalDateTime, @@ -32,7 +31,6 @@ fun User.toEntity( id = id, name = name, email = email, - password = password, provider = provider?.name, providerId = providerId, createdAt = createdAt, 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 1d05ec5..82fcb93 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 @@ -20,7 +20,6 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), - password = rs.getString("password"), provider = rs.getString("provider"), providerId = rs.getString("provider_id"), createdAt = rs.getTimestamp("created_at").toLocalDateTime(), @@ -46,12 +45,11 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + SET name = ?, email = ?, provider = ?, provider_id = ? WHERE id = ? """.trimIndent(), entity.name, entity.email, - user.password, entity.provider, entity.providerId, entity.id, @@ -60,13 +58,12 @@ class UserRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email, password, provider, provider_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, entity.name, entity.email, - user.password, entity.provider, entity.providerId, entity.createdAt, @@ -78,7 +75,7 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", userEntityRowMapper, id, ) @@ -88,7 +85,7 @@ class UserRepository( override fun findByEmail(email: String): User? { val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", userEntityRowMapper, email, ) @@ -98,7 +95,7 @@ class UserRepository( override fun findAll(): List = jdbc .query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users", + "SELECT id, name, email, provider, provider_id, created_at FROM users", userEntityRowMapper, ).map { it.toDomain() } @@ -106,47 +103,6 @@ class UserRepository( jdbc.update("DELETE FROM users WHERE id = ?", id) } - override fun saveWithOAuth2(user: User, provider: String, providerId: String): User { - val updatedRows = jdbc.update( - """ - UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - user.name, - user.email, - user.password, - provider, - providerId, - user.id, - ) - if (updatedRows == 0) { - jdbc.update( - """ - INSERT INTO users (id, name, email, password, provider, provider_id) - VALUES (?, ?, ?, ?, ?, ?) - """.trimIndent(), - user.id, - user.name, - user.email, - user.password, - provider, - providerId, - ) - } - return user - } - - override fun findByProviderAndProviderId(provider: String, providerId: String): User? { - val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", - userEntityRowMapper, - provider, - providerId, - ) - return entities.firstOrNull()?.toDomain() - } - override fun findByProviderAndProviderId( provider: AuthProvider, providerId: String, @@ -154,7 +110,7 @@ class UserRepository( val entities = jdbc.query( """ - SELECT id, name, email, password, provider, provider_id, created_at + SELECT id, name, email, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ? """.trimIndent(), diff --git a/src/main/resources/db/migration/V2__add_oauth2_index.sql b/src/main/resources/db/migration/V2__add_oauth2_index.sql index d416108..b92102c 100644 --- a/src/main/resources/db/migration/V2__add_oauth2_index.sql +++ b/src/main/resources/db/migration/V2__add_oauth2_index.sql @@ -1,3 +1,2 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id -ON users(provider, provider_id) -WHERE provider IS NOT NULL AND provider_id IS NOT NULL; +ON users(provider, provider_id); diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt index 18a952a..c91e49e 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -17,7 +17,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @SpringBootTest -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) class FilmControllerTest { @Autowired private lateinit var mockMvc: MockMvc diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt index 560cf38..16d835d 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -17,7 +17,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @SpringBootTest -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) @Transactional class FilmLibraryControllerTest { @Autowired diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt index 7a2dca3..342fda9 100644 --- a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -17,7 +17,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @SpringBootTest -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) @Transactional class UserControllerTest { @Autowired -- 2.54.0 From bc06654c84650df27adccbdc4890741527b11d2e Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 15 May 2026 22:20:03 +0300 Subject: [PATCH 51/77] =?UTF-8?q?-=20=20=20=20=20exclude("**/security.disa?= =?UTF-8?q?bled/**")=20-=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D0=BE=20-=D0=94?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=B3=D0=B8=D0=BD=20org.graalvm.buildtools.native=20=D0=B2=20b?= =?UTF-8?q?uild.gradle.kts,=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=8F=D0=B2=D0=B8=D0=BB=D0=B8=D1=81=D1=8C=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=D0=B8=20processAot=20=D0=B8=20nativeCompile.?= =?UTF-8?q?=20-=20=D0=9E=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=20confi?= =?UTF-8?q?guration-cache=20=D0=B2=20gradle.properties,=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8=D0=BA=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20nativeCompile=20=D0=BD=D0=B0=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=BC=20toolchain.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 7 +------ gradle.properties | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a3b3bf6..630e545 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,15 +9,13 @@ plugins { alias(libs.plugins.kotlin.spring) alias(libs.plugins.spring.boot) alias(libs.plugins.spring.dependency.management) + id("org.graalvm.buildtools.native") version "0.10.5" alias(libs.plugins.protobuf) alias(libs.plugins.ktlint) alias(libs.plugins.detekt) jacoco } -// Temporarily disabled due to OAuth2 AOT processing issues -// apply(plugin = "org.springframework.boot.aot") - apply(from = "$rootDir/gradle/docker.gradle.kts") group = "com.project" @@ -73,7 +71,6 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } - exclude("**/security.disabled/**") } tasks.withType { @@ -156,7 +153,6 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") - exclude("**/security.disabled/**") } } @@ -170,7 +166,6 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" - exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) diff --git a/gradle.properties b/gradle.properties index b185c69..7ef89b3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,5 @@ kotlin.incremental=true kotlin.incremental.js=true kotlin.incremental.multiplatform=true -org.gradle.configuration-cache=true +org.gradle.configuration-cache=false org.gradle.unsafe.configuration-cache-problems=warn -- 2.54.0 From d2821a843906043b0e75fa4814e2cc48973bad49 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 15 May 2026 22:40:50 +0300 Subject: [PATCH 52/77] =?UTF-8?q?Revert=20"-=20=20=20=20=20exclude("**/sec?= =?UTF-8?q?urity.disabled/**")=20-=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D0=BE"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bc06654c84650df27adccbdc4890741527b11d2e. --- build.gradle.kts | 7 ++++++- gradle.properties | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 630e545..a3b3bf6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,13 +9,15 @@ plugins { alias(libs.plugins.kotlin.spring) alias(libs.plugins.spring.boot) alias(libs.plugins.spring.dependency.management) - id("org.graalvm.buildtools.native") version "0.10.5" alias(libs.plugins.protobuf) alias(libs.plugins.ktlint) alias(libs.plugins.detekt) jacoco } +// Temporarily disabled due to OAuth2 AOT processing issues +// apply(plugin = "org.springframework.boot.aot") + apply(from = "$rootDir/gradle/docker.gradle.kts") group = "com.project" @@ -71,6 +73,7 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } + exclude("**/security.disabled/**") } tasks.withType { @@ -153,6 +156,7 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") + exclude("**/security.disabled/**") } } @@ -166,6 +170,7 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" + exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) diff --git a/gradle.properties b/gradle.properties index 7ef89b3..b185c69 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,5 @@ kotlin.incremental=true kotlin.incremental.js=true kotlin.incremental.multiplatform=true -org.gradle.configuration-cache=false +org.gradle.configuration-cache=true org.gradle.unsafe.configuration-cache-problems=warn -- 2.54.0 From 56f1f0a46b063d24dfa7ba33371fef4407320cf7 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 15 May 2026 22:46:38 +0300 Subject: [PATCH 53/77] . --- build.gradle.kts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a3b3bf6..c0f9dfd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,8 +15,7 @@ plugins { jacoco } -// Temporarily disabled due to OAuth2 AOT processing issues -// apply(plugin = "org.springframework.boot.aot") +apply(plugin = "org.springframework.boot.aot") apply(from = "$rootDir/gradle/docker.gradle.kts") @@ -73,7 +72,6 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } - exclude("**/security.disabled/**") } tasks.withType { @@ -156,7 +154,6 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") - exclude("**/security.disabled/**") } } @@ -170,7 +167,6 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" - exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) -- 2.54.0 From e956c058ac3410794a53e30e339470390b08d5c3 Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 19 May 2026 16:13:10 +0300 Subject: [PATCH 54/77] add Film observability with trace id and metrics (#44) * add Film observability with trace id and metrics * test: provide meter registry in FilmServiceTest --- .../adapters/web/ApiExceptionHandler.kt | 52 +++++- .../movienight/adapters/web/TraceIdFilter.kt | 27 +++ .../application/services/FilmService.kt | 163 +++++++++++++++--- src/main/resources/application.yaml | 3 + .../application/services/FilmServiceTest.kt | 5 +- 5 files changed, 219 insertions(+), 31 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 27a236d..40b3362 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -3,6 +3,8 @@ package com.project.movienight.adapters.web import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException +import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus @@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class ApiExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + @ExceptionHandler(EntityNotFoundException::class) @ResponseStatus(HttpStatus.NOT_FOUND) - fun handleNotFound(exception: EntityNotFoundException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Entity not found") + fun handleNotFound(exception: EntityNotFoundException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Entity not found", + traceId = traceId, + ) + } @ExceptionHandler(BlockedValueException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun handleBlockedValue(exception: BlockedValueException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Blocked value") + fun handleBlockedValue(exception: BlockedValueException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Blocked value", + traceId = traceId, + ) + } @ExceptionHandler(DomainException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun handleDomainException(exception: DomainException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Domain error") + fun handleDomainException(exception: DomainException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Domain error", + traceId = traceId, + ) + } + + @ExceptionHandler(Exception::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleUnexpectedException(exception: Exception): ErrorResponse { + val traceId = currentTraceId() + log.error("Unexpected error: traceId='{}'", traceId, exception) + + return ErrorResponse( + message = "Internal server error", + traceId = traceId, + ) + } + + private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown" } data class ErrorResponse( val message: String, + val traceId: String, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt new file mode 100644 index 0000000..596cb47 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt @@ -0,0 +1,27 @@ +package com.project.movienight.adapters.web + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.util.UUID + +@Component +class TraceIdFilter : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val traceId = UUID.randomUUID().toString() + MDC.put("traceId", traceId) + + try { + filterChain.doFilter(request, response) + } finally { + MDC.remove("traceId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index f775de3..bbd32b1 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -14,6 +14,10 @@ import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -22,48 +26,117 @@ class FilmService( private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, private val filmConfig: FilmServiceProperties, + private val meterRegistry: MeterRegistry, ) : CreateFilmUseCase, EditFilmUseCase, DeleteFilmUseCase, GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - override fun create(command: CreateFilmCommand): Film { - if (filmConfig.isBlocked(command.title)) { - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - throw BlockedValueException(target = "Film", field = "description") - } + private val log = LoggerFactory.getLogger(javaClass) - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, + override fun create(command: CreateFilmCommand): Film { + val sample = Timer.start(meterRegistry) + + try { + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, ) - return filmRepository.save(film) + + if (filmConfig.isBlocked(command.title)) { + log.debug("Create film blocked by title policy: title='{}'", command.title) + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Create film blocked by description policy") + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "description") + } + + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) + val saved = filmRepository.save(film) + + filmCreatedCounter.increment() + + log.info("Film created: id='{}', title='{}'", saved.id, saved.title) + return saved + } finally { + sample.stop(createFilmTimer) + } } override fun edit( id: UUID, command: EditFilmCommand, ): Film { - if (filmConfig.isBlocked(command.title)) { - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - throw BlockedValueException(target = "Film", field = "description") - } + val sample = Timer.start(meterRegistry) - var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - film = film.copy(title = command.title, description = command.description) - return filmRepository.save(film) + try { + log.debug("Edit film with id: {}", id) + + if (filmConfig.isBlocked(command.title)) { + log.debug("Edit film blocked by title policy: title='{}'", command.title) + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Edit film blocked by description policy") + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "description") + } + + val film = filmRepository.findById(id) + + if (film == null) { + log.debug("Film not found for edit: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) + } + + val updatedFilm = + film.copy( + title = command.title, + description = command.description, + ) + val saved = filmRepository.save(updatedFilm) + + filmEditedCounter.increment() + + log.info("Film edited: id='{}'", saved.id) + return saved + } finally { + sample.stop(editFilmTimer) + } } override fun delete(id: UUID) { - filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - filmRepository.deleteById(id) + val sample = Timer.start(meterRegistry) + + try { + log.debug("Delete film with id: {}", id) + + val film = filmRepository.findById(id) + + if (film == null) { + log.debug("Film not found for delete: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) + } + + filmRepository.deleteById(id) + + filmDeletedCounter.increment() + + log.info("Film deleted: id='{}'", id) + } finally { + sample.stop(deleteFilmTimer) + } } override fun getById(id: UUID): Film = @@ -72,4 +145,46 @@ class FilmService( override fun getAll(): List = filmRepository.findAll() override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) + + private val filmCreatedCounter = + Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) + + private val filmEditedCounter = + Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) + + private val filmDeletedCounter = + Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) + + private val filmBlockedCounter = + Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) + + private val createFilmTimer = + Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) + + private val editFilmTimer = + Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) + + private val deleteFilmTimer = + Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 86c806c..284e69a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -106,3 +106,6 @@ services: - censored - epstein - python +logging: + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n" diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt index fcc5613..e0f96be 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -8,6 +8,7 @@ import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film +import io.micrometer.core.instrument.simple.SimpleMeterRegistry import io.mockk.every import io.mockk.justRun import io.mockk.mockk @@ -23,6 +24,7 @@ class FilmServiceTest { private lateinit var filmRepository: FilmRepositoryPort private lateinit var idGenerator: IdGenerator private lateinit var filmConfig: FilmServiceProperties + private lateinit var meterRegistry: SimpleMeterRegistry private lateinit var filmService: FilmService @BeforeEach @@ -30,7 +32,8 @@ class FilmServiceTest { filmRepository = mockk() idGenerator = mockk() filmConfig = mockk() - filmService = FilmService(filmRepository, idGenerator, filmConfig) + meterRegistry = SimpleMeterRegistry() + filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry) } @Test -- 2.54.0 From 75bbe27198a119a6ffcdf1486705b0df613128a4 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:50:22 +0300 Subject: [PATCH 55/77] feat(migrations): extended data structures --- src/main/resources/db/migration/V1__init.sql | 49 ++++++++++++++++++- .../db/migration/V2__jellyfin_events.sql | 14 ++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V2__jellyfin_events.sql diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 900f6b5..e98c8ff 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -4,13 +4,24 @@ CREATE TABLE IF NOT EXISTS public.users ( email VARCHAR(320) NOT NULL UNIQUE, provider VARCHAR(64), provider_id VARCHAR(255), + jellyfin_user_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( id UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, - description TEXT NOT NULL + description TEXT NOT NULL, + content_type VARCHAR(32) NOT NULL DEFAULT 'FILM', + release_year INT, + genres TEXT NOT NULL DEFAULT '', + cast_members TEXT NOT NULL DEFAULT '', + directors TEXT NOT NULL DEFAULT '', + imdb_rating DOUBLE PRECISION, + platform_rating DOUBLE PRECISION, + external_url TEXT, + jellyfin_item_id VARCHAR(255), + jellyfin_library_id VARCHAR(255) ); CREATE TABLE IF NOT EXISTS public.favorites ( @@ -19,6 +30,42 @@ CREATE TABLE IF NOT EXISTS public.favorites ( film_id UUID NOT NULL, comment VARCHAR(1024), is_viewed BOOLEAN NOT NULL DEFAULT FALSE, + watched_at TIMESTAMP, CONSTRAINT favorites_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, CONSTRAINT favorites_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE ); + +CREATE TABLE IF NOT EXISTS public.user_preferences ( + user_id UUID PRIMARY KEY, + weighted_genres TEXT NOT NULL DEFAULT '', + plot_types TEXT NOT NULL DEFAULT '', + eras TEXT NOT NULL DEFAULT '', + cast_and_directors TEXT NOT NULL DEFAULT '', + moods TEXT NOT NULL DEFAULT '', + content_types TEXT NOT NULL DEFAULT '', + CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.film_ratings ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + score INT NOT NULL, + note VARCHAR(2048), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), + CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) +); + +CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( + user_id UUID PRIMARY KEY, + last_synced_at TIMESTAMP, + last_successful_sync_at TIMESTAMP, + last_error TEXT, + synced_item_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V2__jellyfin_events.sql b/src/main/resources/db/migration/V2__jellyfin_events.sql new file mode 100644 index 0000000..9f7b8fa --- /dev/null +++ b/src/main/resources/db/migration/V2__jellyfin_events.sql @@ -0,0 +1,14 @@ +-- Create table to store Jellyfin events for idempotency and auditing +CREATE TABLE IF NOT EXISTS jellyfin_events ( + event_id VARCHAR(255) PRIMARY KEY, + server_id VARCHAR(255), + event_type VARCHAR(255) NOT NULL, + occurred_at TIMESTAMP WITH TIME ZONE, + jellyfin_user_id VARCHAR(255), + jellyfin_item_id VARCHAR(255), + payload JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_jellyfin_events_user ON jellyfin_events(jellyfin_user_id); +CREATE INDEX IF NOT EXISTS idx_jellyfin_events_item ON jellyfin_events(jellyfin_item_id); -- 2.54.0 From 54305f7d162e884e286385b6d27fe739c17db697 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:51:09 +0300 Subject: [PATCH 56/77] chore(persistence): actualized jdbc adapters according to new data structures --- .../persistence/jdbc/FilmLibraryRepository.kt | 27 ++++-- .../persistence/jdbc/FilmRatingRepository.kt | 85 +++++++++++++++++++ .../persistence/jdbc/FilmRepository.kt | 67 +++++++++++++-- .../jdbc/JellyfinEventRepository.kt | 43 ++++++++++ .../jdbc/JellyfinSyncStateRepository.kt | 75 ++++++++++++++++ .../jdbc/UserPreferencesRepository.kt | 74 ++++++++++++++++ .../persistence/jdbc/UserRepository.kt | 15 ++-- .../jdbc/support/DelimitedValueCodec.kt | 38 +++++++++ 8 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt index f5603cb..fa7f153 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt @@ -18,6 +18,7 @@ class FilmLibraryRepository( filmId = UUID.fromString(rs.getString("film_id")), comment = rs.getString("comment"), isViewed = rs.getBoolean("is_viewed"), + watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(), ) } @@ -26,26 +27,28 @@ class FilmLibraryRepository( jdbc.update( """ UPDATE favorites - SET user_id = ?, film_id = ?, comment = ?, is_viewed = ? + SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ? WHERE id = ? """.trimIndent(), filmLibrary.userId, filmLibrary.filmId, filmLibrary.comment, filmLibrary.isViewed, + filmLibrary.watchedAt, filmLibrary.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO favorites (id, user_id, film_id, comment, is_viewed) - VALUES (?, ?, ?, ?, ?) + INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), filmLibrary.id, filmLibrary.userId, filmLibrary.filmId, filmLibrary.comment, filmLibrary.isViewed, + filmLibrary.watchedAt, ) } return filmLibrary @@ -54,16 +57,30 @@ class FilmLibraryRepository( override fun findById(id: UUID): FilmLibrary? { val entries = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed FROM favorites WHERE id = ?", + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?", filmLibraryRowMapper, id, ) return entries.firstOrNull() } + override fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? { + val entries = + jdbc.query( + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ? AND film_id = ?", + filmLibraryRowMapper, + userId, + filmId, + ) + return entries.firstOrNull() + } + override fun findAll(): List = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed FROM favorites", + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites", filmLibraryRowMapper, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt new file mode 100644 index 0000000..fac103b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt @@ -0,0 +1,85 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.adapters.persistence.entity.FilmRatingEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.domain.model.FilmRating +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.time.LocalDateTime +import java.util.UUID + +@Repository +class FilmRatingRepository( + private val jdbc: JdbcTemplate, +) : FilmRatingRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + FilmRatingEntity( + id = UUID.fromString(rs.getString("id")), + userId = UUID.fromString(rs.getString("user_id")), + filmId = UUID.fromString(rs.getString("film_id")), + score = rs.getInt("score"), + note = rs.getString("note"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), + updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(), + ) + } + + override fun save(rating: FilmRating): FilmRating { + val entity = rating.toEntity() + val updatedRows = + jdbc.update( + """ + UPDATE film_ratings + SET score = ?, note = ?, updated_at = ? + WHERE user_id = ? AND film_id = ? + """.trimIndent(), + entity.score, + entity.note, + LocalDateTime.now(), + entity.userId, + entity.filmId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO film_ratings (id, user_id, film_id, score, note, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + entity.id, + entity.userId, + entity.filmId, + entity.score, + entity.note, + entity.createdAt, + entity.updatedAt, + ) + } + + return rating + } + + override fun findByUserId(userId: UUID): List = + jdbc + .query( + "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ?", + rowMapper, + userId, + ).map { it.toDomain() } + + override fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmRating? = + jdbc + .query( + "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ? AND film_id = ?", + rowMapper, + userId, + filmId, + ).firstOrNull() + ?.toDomain() +} 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 2883aca..8da5f94 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 @@ -1,6 +1,8 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @@ -16,6 +18,21 @@ class FilmRepository( id = UUID.fromString(rs.getString("id")), title = rs.getString("title"), description = rs.getString("description"), + contentType = + runCatching { + ContentType.valueOf( + rs.getString("content_type"), + ) + }.getOrDefault(ContentType.FILM), + releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() }, + genres = DelimitedValueCodec.decodeList(rs.getString("genres")), + cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")), + directors = DelimitedValueCodec.decodeList(rs.getString("directors")), + imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() }, + platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() }, + externalUrl = rs.getString("external_url"), + jellyfinItemId = rs.getString("jellyfin_item_id"), + jellyfinLibraryId = rs.getString("jellyfin_library_id"), ) } @@ -24,22 +41,42 @@ class FilmRepository( jdbc.update( """ UPDATE films - SET title = ?, description = ? + SET title = ?, description = ?, content_type = ?, release_year = ?, genres = ?, cast_members = ?, directors = ?, imdb_rating = ?, platform_rating = ?, external_url = ?, jellyfin_item_id = ?, jellyfin_library_id = ? WHERE id = ? """.trimIndent(), film.title, film.description, + film.contentType.name, + film.releaseYear, + DelimitedValueCodec.encodeList(film.genres), + DelimitedValueCodec.encodeList(film.cast), + DelimitedValueCodec.encodeList(film.directors), + film.imdbRating, + film.platformRating, + film.externalUrl, + film.jellyfinItemId, + film.jellyfinLibraryId, film.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO films (id, title, description) - VALUES (?, ?, ?) + INSERT INTO films (id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), film.id, film.title, film.description, + film.contentType.name, + film.releaseYear, + DelimitedValueCodec.encodeList(film.genres), + DelimitedValueCodec.encodeList(film.cast), + DelimitedValueCodec.encodeList(film.directors), + film.imdbRating, + film.platformRating, + film.externalUrl, + film.jellyfinItemId, + film.jellyfinLibraryId, ) } return film @@ -48,16 +85,36 @@ class FilmRepository( override fun findById(id: UUID): Film? { val films = jdbc.query( - "SELECT id, title, description FROM films WHERE id = ?", + "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films WHERE id = ?", filmRowMapper, id, ) return films.firstOrNull() } + override fun findByJellyfinItemId(jellyfinItemId: String): Film? { + val films = + jdbc.query( + "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films WHERE jellyfin_item_id = ?", + filmRowMapper, + jellyfinItemId, + ) + return films.firstOrNull() + } + + override fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? { + val films = + jdbc.query( + "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films WHERE jellyfin_library_id = ?", + filmRowMapper, + jellyfinLibraryId, + ) + return films.firstOrNull() + } + override fun findAll(): List = jdbc.query( - "SELECT id, title, description FROM films", + "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films", filmRowMapper, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt new file mode 100644 index 0000000..6092c47 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -0,0 +1,43 @@ +package com.project.movienight.adapters.persistence.jdbc + +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Repository + +@Repository +class JellyfinEventRepository( + private val jdbc: NamedParameterJdbcTemplate, +) { + fun exists(eventId: String): Boolean { + val sql = "SELECT 1 FROM jellyfin_events WHERE event_id = :eventId" + val params = MapSqlParameterSource().addValue("eventId", eventId) + return jdbc.query(sql, params) { rs, _ -> rs.getInt(1) }.any() + } + + fun save( + eventId: String, + serverId: String?, + eventType: String, + occurredAt: java.time.OffsetDateTime?, + jellyfinUserId: String?, + jellyfinItemId: String?, + payload: String?, + ) { + val sql = """ + INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload) + VALUES (:eventId, :serverId, :eventType, :occurredAt, :jellyfinUserId, :jellyfinItemId, cast(:payload as jsonb)) + ON CONFLICT (event_id) DO NOTHING + """.trimIndent() + + val params = MapSqlParameterSource() + .addValue("eventId", eventId) + .addValue("serverId", serverId) + .addValue("eventType", eventType) + .addValue("occurredAt", occurredAt) + .addValue("jellyfinUserId", jellyfinUserId) + .addValue("jellyfinItemId", jellyfinItemId) + .addValue("payload", payload) + + jdbc.update(sql, params) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt new file mode 100644 index 0000000..151b525 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -0,0 +1,75 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.adapters.persistence.entity.JellyfinSyncStateEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity +import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort +import com.project.movienight.domain.model.JellyfinSyncState +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.util.UUID + +@Repository +class JellyfinSyncStateRepository( + private val jdbc: JdbcTemplate, +) : JellyfinSyncStateRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + JellyfinSyncStateEntity( + userId = UUID.fromString(rs.getString("user_id")), + lastSyncedAt = rs.getTimestamp("last_synced_at")?.toLocalDateTime(), + lastSuccessfulSyncAt = rs.getTimestamp("last_successful_sync_at")?.toLocalDateTime(), + lastError = rs.getString("last_error"), + syncedItemCount = rs.getInt("synced_item_count"), + ) + } + + override fun save(state: JellyfinSyncState): JellyfinSyncState { + val entity = state.toEntity() + val updatedRows = + jdbc.update( + """ + UPDATE jellyfin_sync_state + SET last_synced_at = ?, last_successful_sync_at = ?, last_error = ?, synced_item_count = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + """.trimIndent(), + entity.lastSyncedAt, + entity.lastSuccessfulSyncAt, + entity.lastError, + entity.syncedItemCount, + entity.userId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO jellyfin_sync_state (user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count) + VALUES (?, ?, ?, ?, ?) + """.trimIndent(), + entity.userId, + entity.lastSyncedAt, + entity.lastSuccessfulSyncAt, + entity.lastError, + entity.syncedItemCount, + ) + } + + return state + } + + override fun findByUserId(userId: UUID): JellyfinSyncState? = + jdbc + .query( + "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state WHERE user_id = ?", + rowMapper, + userId, + ).firstOrNull() + ?.toDomain() + + override fun findAll(): List = + jdbc + .query( + "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", + rowMapper, + ).map { it.toDomain() } +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt new file mode 100644 index 0000000..58e056e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt @@ -0,0 +1,74 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.adapters.persistence.entity.UserPreferencesEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.UserPreferences +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.util.UUID + +@Repository +class UserPreferencesRepository( + private val jdbc: JdbcTemplate, +) : UserPreferencesRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + UserPreferencesEntity( + userId = UUID.fromString(rs.getString("user_id")), + weightedGenres = rs.getString("weighted_genres"), + plotTypes = rs.getString("plot_types"), + eras = rs.getString("eras"), + castAndDirectors = rs.getString("cast_and_directors"), + moods = rs.getString("moods"), + contentTypes = rs.getString("content_types"), + ) + } + + override fun save(preferences: UserPreferences): UserPreferences { + val entity = preferences.toEntity() + val updatedRows = + jdbc.update( + """ + UPDATE user_preferences + SET weighted_genres = ?, plot_types = ?, eras = ?, cast_and_directors = ?, moods = ?, content_types = ? + WHERE user_id = ? + """.trimIndent(), + entity.weightedGenres, + entity.plotTypes, + entity.eras, + entity.castAndDirectors, + entity.moods, + entity.contentTypes, + entity.userId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO user_preferences (user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types) + VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + entity.userId, + entity.weightedGenres, + entity.plotTypes, + entity.eras, + entity.castAndDirectors, + entity.moods, + entity.contentTypes, + ) + } + + return preferences + } + + override fun findByUserId(userId: UUID): UserPreferences? = + jdbc + .query( + "SELECT user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types FROM user_preferences WHERE user_id = ?", + rowMapper, + userId, + ).firstOrNull() + ?.toDomain() +} 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..f6e261a 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 @@ -22,6 +22,7 @@ class UserRepository( email = rs.getString("email"), provider = rs.getString("provider"), providerId = rs.getString("provider_id"), + jellyfinUserId = rs.getString("jellyfin_user_id"), createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } @@ -32,26 +33,28 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, provider = ?, provider_id = ? + SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ? WHERE id = ? """.trimIndent(), entity.name, entity.email, entity.provider, entity.providerId, + entity.jellyfinUserId, entity.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email, provider, provider_id, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, entity.name, entity.email, entity.provider, entity.providerId, + entity.jellyfinUserId, entity.createdAt, ) } @@ -61,7 +64,7 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?", userEntityRowMapper, id, ) @@ -71,7 +74,7 @@ class UserRepository( override fun findAll(): List = jdbc .query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", + "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users", userEntityRowMapper, ).map { it.toDomain() } @@ -86,7 +89,7 @@ class UserRepository( val entities = jdbc.query( """ - SELECT id, name, email, provider, provider_id, created_at FROM users + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE provider = ? AND provider_id = ? """.trimIndent(), userEntityRowMapper, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt new file mode 100644 index 0000000..d671f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt @@ -0,0 +1,38 @@ +package com.project.movienight.adapters.persistence.jdbc.support + +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object DelimitedValueCodec { + fun encodeList(values: List): String = values.joinToString("|") { encode(it) } + + fun decodeList(value: String?): List = + value + ?.takeIf { it.isNotBlank() } + ?.split("|") + ?.map { decode(it) } + ?: emptyList() + + fun encodeWeightedMap(values: Map): String = + values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" } + + fun decodeWeightedMap(value: String?): Map { + if (value.isNullOrBlank()) return emptyMap() + + return value + .split("|") + .mapNotNull { pair -> + val parts = pair.split(":", limit = 2) + if (parts.size != 2) return@mapNotNull null + + val key = decode(parts[0]) + val weight = parts[1].toIntOrNull() ?: return@mapNotNull null + key to weight + }.toMap() + } + + private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8) +} -- 2.54.0 From 8e89fe5f7fba13c9458f633c729d2c3c7776eb57 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:51:39 +0300 Subject: [PATCH 57/77] feat(entities): actualized entities and added new --- .../persistence/entity/FilmRatingEntity.kt | 37 +++++++++++++++++ .../entity/JellyfinSyncStateEntity.kt | 31 ++++++++++++++ .../adapters/persistence/entity/UserEntity.kt | 4 ++ .../entity/UserPreferencesEntity.kt | 41 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt new file mode 100644 index 0000000..10a86db --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt @@ -0,0 +1,37 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.FilmRating +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRatingEntity( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) + +fun FilmRatingEntity.toDomain(): FilmRating = + FilmRating( + id = id, + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = createdAt, + updatedAt = updatedAt, + ) + +fun FilmRating.toEntity(): FilmRatingEntity = + FilmRatingEntity( + id = id, + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = createdAt, + updatedAt = updatedAt, + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt new file mode 100644 index 0000000..5720ba9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt @@ -0,0 +1,31 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.JellyfinSyncState +import java.time.LocalDateTime +import java.util.UUID + +data class JellyfinSyncStateEntity( + val userId: UUID, + val lastSyncedAt: LocalDateTime?, + val lastSuccessfulSyncAt: LocalDateTime?, + val lastError: String?, + val syncedItemCount: Int, +) + +fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState = + JellyfinSyncState( + userId = userId, + lastSyncedAt = lastSyncedAt, + lastSuccessfulSyncAt = lastSuccessfulSyncAt, + lastError = lastError, + syncedItemCount = syncedItemCount, + ) + +fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity = + JellyfinSyncStateEntity( + userId = userId, + lastSyncedAt = lastSyncedAt, + lastSuccessfulSyncAt = lastSuccessfulSyncAt, + lastError = lastError, + syncedItemCount = syncedItemCount, + ) \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 0beda74..58e2c3c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -11,6 +11,7 @@ data class UserEntity( val email: String, val provider: String?, val providerId: String?, + val jellyfinUserId: String?, val createdAt: LocalDateTime, ) @@ -20,6 +21,8 @@ fun UserEntity.toDomain(): User = name = name, email = email, library = null, + preferences = null, + jellyfinUserId = jellyfinUserId, ) fun User.toEntity( @@ -33,5 +36,6 @@ fun User.toEntity( email = email, provider = provider?.name, providerId = providerId, + jellyfinUserId = jellyfinUserId, createdAt = createdAt, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt new file mode 100644 index 0000000..706d175 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt @@ -0,0 +1,41 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +data class UserPreferencesEntity( + val userId: UUID, + val weightedGenres: String, + val plotTypes: String, + val eras: String, + val castAndDirectors: String, + val moods: String, + val contentTypes: String, +) + +fun UserPreferencesEntity.toDomain(): UserPreferences = + UserPreferences( + userId = userId, + weightedGenres = DelimitedValueCodec.decodeWeightedMap(weightedGenres), + plotTypes = DelimitedValueCodec.decodeList(plotTypes), + eras = DelimitedValueCodec.decodeList(eras), + castAndDirectors = DelimitedValueCodec.decodeList(castAndDirectors), + moods = DelimitedValueCodec.decodeList(moods), + contentTypes = + DelimitedValueCodec.decodeList(contentTypes).mapNotNull { value -> + runCatching { ContentType.valueOf(value) }.getOrNull() + }, + ) + +fun UserPreferences.toEntity(): UserPreferencesEntity = + UserPreferencesEntity( + userId = userId, + weightedGenres = DelimitedValueCodec.encodeWeightedMap(weightedGenres), + plotTypes = DelimitedValueCodec.encodeList(plotTypes), + eras = DelimitedValueCodec.encodeList(eras), + castAndDirectors = DelimitedValueCodec.encodeList(castAndDirectors), + moods = DelimitedValueCodec.encodeList(moods), + contentTypes = DelimitedValueCodec.encodeList(contentTypes.map { it.name }), + ) -- 2.54.0 From 432659500b00ebdd2ef180e7ec814013144a5c48 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:57:46 +0300 Subject: [PATCH 58/77] feat(domain): extended and actualized domain models --- .../project/movienight/domain/model/Film.kt | 17 +++++++++++++++++ .../movienight/domain/model/FilmLibrary.kt | 2 ++ .../movienight/domain/model/FilmRating.kt | 14 ++++++++++++++ .../domain/model/JellyfinSyncState.kt | 19 +++++++++++++++++++ .../domain/model/RecommendationContext.kt | 16 ++++++++++++++++ .../project/movienight/domain/model/User.kt | 2 ++ .../domain/model/UserPreferences.kt | 13 +++++++++++++ 7 files changed, 83 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt diff --git a/src/main/kotlin/com/project/movienight/domain/model/Film.kt b/src/main/kotlin/com/project/movienight/domain/model/Film.kt index 32122de..76f2657 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/Film.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/Film.kt @@ -6,4 +6,21 @@ data class Film( val id: UUID, val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) + +enum class ContentType { + FILM, + SERIES, + EPISODE, + OTHER, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt index 868f57a..8d7861c 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt @@ -1,5 +1,6 @@ package com.project.movienight.domain.model +import java.time.LocalDateTime import java.util.UUID data class FilmLibrary( @@ -8,4 +9,5 @@ data class FilmLibrary( val filmId: UUID, val comment: String?, val isViewed: Boolean, + val watchedAt: LocalDateTime? = null, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt new file mode 100644 index 0000000..380060d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt @@ -0,0 +1,14 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRating( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = createdAt, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt new file mode 100644 index 0000000..d2395fa --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt @@ -0,0 +1,19 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class JellyfinSyncState( + val userId: UUID, + val lastSyncedAt: LocalDateTime? = null, + val lastSuccessfulSyncAt: LocalDateTime? = null, + val lastError: String? = null, + val syncedItemCount: Int = 0, +) + +data class JellyfinSyncSummary( + val syncedUsers: Int, + val skippedUsers: Int, + val syncedItems: Int, + val durationMs: Long, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt new file mode 100644 index 0000000..1049513 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt @@ -0,0 +1,16 @@ +package com.project.movienight.domain.model + +import java.util.UUID + +data class RecommendationContext( + val userId: UUID, + val contentType: ContentType? = null, + val mood: String? = null, + val limit: Int = 10, +) + +data class RecommendationResult( + val film: Film, + val score: Double, + val reasons: List, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..236a698 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -7,4 +7,6 @@ data class User( val name: String, val email: String, val library: FilmLibrary?, + val preferences: UserPreferences? = null, + val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt new file mode 100644 index 0000000..451e227 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt @@ -0,0 +1,13 @@ +package com.project.movienight.domain.model + +import java.util.UUID + +data class UserPreferences( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) -- 2.54.0 From 967c1b818cd671b5fbd684084cfca8cd7584edb1 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:58:26 +0300 Subject: [PATCH 59/77] chore(style): reformatted some files --- .../entity/JellyfinSyncStateEntity.kt | 2 +- .../jdbc/JellyfinEventRepository.kt | 22 ++++++++++--------- .../jdbc/JellyfinSyncStateRepository.kt | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt index 5720ba9..5edd5c9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt @@ -28,4 +28,4 @@ fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity = lastSuccessfulSyncAt = lastSuccessfulSyncAt, lastError = lastError, syncedItemCount = syncedItemCount, - ) \ No newline at end of file + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 6092c47..37f58ca 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -23,20 +23,22 @@ class JellyfinEventRepository( jellyfinItemId: String?, payload: String?, ) { - val sql = """ + val sql = + """ INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload) VALUES (:eventId, :serverId, :eventType, :occurredAt, :jellyfinUserId, :jellyfinItemId, cast(:payload as jsonb)) ON CONFLICT (event_id) DO NOTHING - """.trimIndent() + """.trimIndent() - val params = MapSqlParameterSource() - .addValue("eventId", eventId) - .addValue("serverId", serverId) - .addValue("eventType", eventType) - .addValue("occurredAt", occurredAt) - .addValue("jellyfinUserId", jellyfinUserId) - .addValue("jellyfinItemId", jellyfinItemId) - .addValue("payload", payload) + val params = + MapSqlParameterSource() + .addValue("eventId", eventId) + .addValue("serverId", serverId) + .addValue("eventType", eventType) + .addValue("occurredAt", occurredAt) + .addValue("jellyfinUserId", jellyfinUserId) + .addValue("jellyfinItemId", jellyfinItemId) + .addValue("payload", payload) jdbc.update(sql, params) } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt index 151b525..2f12ad5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -72,4 +72,4 @@ class JellyfinSyncStateRepository( "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", rowMapper, ).map { it.toDomain() } -} \ No newline at end of file +} -- 2.54.0 From b94599a6520b5c9b46f5c7c4f1cdcccd669ef3d8 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:24:37 +0300 Subject: [PATCH 60/77] style(): yet another formatting improvements --- config/detekt/detekt.yaml | 9 ++ .../persistence/jdbc/FilmLibraryRepository.kt | 5 +- .../persistence/jdbc/FilmRatingRepository.kt | 42 ++++++- .../persistence/jdbc/FilmRepository.kt | 108 ++++++++++++++++-- .../jdbc/JellyfinSyncStateRepository.kt | 33 +++++- .../jdbc/UserPreferencesRepository.kt | 29 ++++- 6 files changed, 206 insertions(+), 20 deletions(-) diff --git a/config/detekt/detekt.yaml b/config/detekt/detekt.yaml index 9b7c718..1cb8259 100644 --- a/config/detekt/detekt.yaml +++ b/config/detekt/detekt.yaml @@ -7,3 +7,12 @@ comments: active: false UndocumentedPublicProperty: active: false + +style: + MagicNumber: + active: false + ReturnCount: + max: 3 + +complexity: + active: false diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt index fa7f153..9fa474d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt @@ -70,7 +70,10 @@ class FilmLibraryRepository( ): FilmLibrary? { val entries = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ? AND film_id = ?", + """ + SELECT id, user_id, film_id, comment, is_viewed, watched_at + FROM favorites WHERE user_id = ? AND film_id = ? + """.trimIndent(), filmLibraryRowMapper, userId, filmId, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt index fac103b..85334d0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt @@ -33,8 +33,11 @@ class FilmRatingRepository( jdbc.update( """ UPDATE film_ratings - SET score = ?, note = ?, updated_at = ? - WHERE user_id = ? AND film_id = ? + SET score = ?, + note = ?, + updated_at = ? + WHERE user_id = ? + AND film_id = ? """.trimIndent(), entity.score, entity.note, @@ -46,7 +49,15 @@ class FilmRatingRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO film_ratings (id, user_id, film_id, score, note, created_at, updated_at) + INSERT INTO film_ratings ( + id, + user_id, + film_id, + score, + note, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, @@ -65,7 +76,17 @@ class FilmRatingRepository( override fun findByUserId(userId: UUID): List = jdbc .query( - "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ?", + """ + SELECT id, + user_id, + film_id, + score, + note, + created_at, + updated_at + FROM film_ratings + WHERE user_id = ? + """.trimIndent(), rowMapper, userId, ).map { it.toDomain() } @@ -76,7 +97,18 @@ class FilmRatingRepository( ): FilmRating? = jdbc .query( - "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ? AND film_id = ?", + """ + SELECT id, + user_id, + film_id, + score, + note, + created_at, + updated_at + FROM film_ratings + WHERE user_id = ? + AND film_id = ? + """.trimIndent(), rowMapper, userId, filmId, 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 8da5f94..93f580a 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 @@ -41,7 +41,18 @@ class FilmRepository( jdbc.update( """ UPDATE films - SET title = ?, description = ?, content_type = ?, release_year = ?, genres = ?, cast_members = ?, directors = ?, imdb_rating = ?, platform_rating = ?, external_url = ?, jellyfin_item_id = ?, jellyfin_library_id = ? + SET title = ?, + description = ?, + content_type = ?, + release_year = ?, + genres = ?, + cast_members = ?, + directors = ?, + imdb_rating = ?, + platform_rating = ?, + external_url = ?, + jellyfin_item_id = ?, + jellyfin_library_id = ? WHERE id = ? """.trimIndent(), film.title, @@ -61,7 +72,21 @@ class FilmRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO films (id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id) + INSERT INTO films ( + id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), film.id, @@ -85,7 +110,23 @@ class FilmRepository( override fun findById(id: UUID): Film? { val films = jdbc.query( - "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films WHERE id = ?", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE id = ? + """.trimIndent(), filmRowMapper, id, ) @@ -95,7 +136,23 @@ class FilmRepository( override fun findByJellyfinItemId(jellyfinItemId: String): Film? { val films = jdbc.query( - "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films WHERE jellyfin_item_id = ?", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE jellyfin_item_id = ? + """.trimIndent(), filmRowMapper, jellyfinItemId, ) @@ -105,7 +162,23 @@ class FilmRepository( override fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? { val films = jdbc.query( - "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films WHERE jellyfin_library_id = ?", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE jellyfin_library_id = ? + """.trimIndent(), filmRowMapper, jellyfinLibraryId, ) @@ -114,11 +187,32 @@ class FilmRepository( override fun findAll(): List = jdbc.query( - "SELECT id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id FROM films", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + """.trimIndent(), filmRowMapper, ) override fun deleteById(id: UUID) { - jdbc.update("DELETE FROM films WHERE id = ?", id) + jdbc.update( + """ + DELETE FROM films + WHERE id = ? + """.trimIndent(), + id, + ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt index 2f12ad5..29deb18 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -30,7 +30,11 @@ class JellyfinSyncStateRepository( jdbc.update( """ UPDATE jellyfin_sync_state - SET last_synced_at = ?, last_successful_sync_at = ?, last_error = ?, synced_item_count = ?, updated_at = CURRENT_TIMESTAMP + SET last_synced_at = ?, + last_successful_sync_at = ?, + last_error = ?, + synced_item_count = ?, + updated_at = CURRENT_TIMESTAMP WHERE user_id = ? """.trimIndent(), entity.lastSyncedAt, @@ -43,7 +47,13 @@ class JellyfinSyncStateRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO jellyfin_sync_state (user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count) + INSERT INTO jellyfin_sync_state ( + user_id, + last_synced_at, + last_successful_sync_at, + last_error, + synced_item_count + ) VALUES (?, ?, ?, ?, ?) """.trimIndent(), entity.userId, @@ -60,7 +70,15 @@ class JellyfinSyncStateRepository( override fun findByUserId(userId: UUID): JellyfinSyncState? = jdbc .query( - "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state WHERE user_id = ?", + """ + SELECT user_id, + last_synced_at, + last_successful_sync_at, + last_error, + synced_item_count + FROM jellyfin_sync_state + WHERE user_id = ? + """.trimIndent(), rowMapper, userId, ).firstOrNull() @@ -69,7 +87,14 @@ class JellyfinSyncStateRepository( override fun findAll(): List = jdbc .query( - "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", + """ + SELECT user_id, + last_synced_at, + last_successful_sync_at, + last_error, + synced_item_count + FROM jellyfin_sync_state + """.trimIndent(), rowMapper, ).map { it.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt index 58e056e..33ec0cc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt @@ -32,7 +32,12 @@ class UserPreferencesRepository( jdbc.update( """ UPDATE user_preferences - SET weighted_genres = ?, plot_types = ?, eras = ?, cast_and_directors = ?, moods = ?, content_types = ? + SET weighted_genres = ?, + plot_types = ?, + eras = ?, + cast_and_directors = ?, + moods = ?, + content_types = ? WHERE user_id = ? """.trimIndent(), entity.weightedGenres, @@ -47,7 +52,15 @@ class UserPreferencesRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO user_preferences (user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types) + INSERT INTO user_preferences ( + user_id, + weighted_genres, + plot_types, + eras, + cast_and_directors, + moods, + content_types + ) VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.userId, @@ -66,7 +79,17 @@ class UserPreferencesRepository( override fun findByUserId(userId: UUID): UserPreferences? = jdbc .query( - "SELECT user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types FROM user_preferences WHERE user_id = ?", + """ + SELECT user_id, + weighted_genres, + plot_types, + eras, + cast_and_directors, + moods, + content_types + FROM user_preferences + WHERE user_id = ? + """.trimIndent(), rowMapper, userId, ).firstOrNull() -- 2.54.0 From aa89455e503ce0d3e80b087d59907deb486aba5a Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:24:58 +0300 Subject: [PATCH 61/77] fix(): test fix after restructuring --- .../adapters/persistence/entity/UserEntityMappingTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index cdf0c42..4e65569 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -18,6 +18,7 @@ class UserEntityMappingTest { email = "john@email.com", provider = "GOOGLE", providerId = "google1234", + jellyfinUserId = null, createdAt = LocalDateTime.now(), ) val user = entity.toDomain() @@ -26,6 +27,7 @@ class UserEntityMappingTest { assertEquals(entity.name, user.name) assertEquals(entity.email, user.email) assertNull(user.library) + assertNull(user.jellyfinUserId) } @Test -- 2.54.0 From a48463b723427576a2ba16f5a751eae877fe24db Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:34:56 +0300 Subject: [PATCH 62/77] feat(ports): extended and actualized repository interfaces --- .../ports/output/FilmLibraryRepositoryPort.kt | 5 +++++ .../ports/output/FilmRatingRepositoryPort.kt | 15 +++++++++++++++ .../ports/output/FilmRepositoryPort.kt | 4 ++++ .../output/JellyfinSyncStateRepositoryPort.kt | 12 ++++++++++++ .../ports/output/UserPreferencesRepositoryPort.kt | 10 ++++++++++ 5 files changed, 46 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt index a3eb9c9..933f45c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt @@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort { fun findById(id: UUID): FilmLibrary? + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt new file mode 100644 index 0000000..908e5ff --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt @@ -0,0 +1,15 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface FilmRatingRepositoryPort { + fun save(rating: FilmRating): FilmRating + + fun findByUserId(userId: UUID): List + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmRating? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index c0d3938..d18b2e6 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -8,6 +8,10 @@ interface FilmRepositoryPort { fun findById(id: UUID): Film? + fun findByJellyfinItemId(jellyfinItemId: String): Film? + + fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? + fun findAll(): List fun findByTitle(title: String): Film? diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt new file mode 100644 index 0000000..78d75b5 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt @@ -0,0 +1,12 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncState +import java.util.UUID + +interface JellyfinSyncStateRepositoryPort { + fun save(state: JellyfinSyncState): JellyfinSyncState + + fun findByUserId(userId: UUID): JellyfinSyncState? + + fun findAll(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt new file mode 100644 index 0000000..0d110b5 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UserPreferencesRepositoryPort { + fun save(preferences: UserPreferences): UserPreferences + + fun findByUserId(userId: UUID): UserPreferences? +} -- 2.54.0 From ea7e66c1a00bc4bfa69be7d0a79e986e93d39963 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:38:30 +0300 Subject: [PATCH 63/77] (scope): [body] [footer(s)] --- .../movienight/MovieNightApplication.kt | 2 + .../movienight/adapters/web/FilmController.kt | 26 ++++ .../adapters/web/FilmLibraryController.kt | 35 ++++-- .../movienight/adapters/web/UserController.kt | 1 + .../web/dto/request/CreateFilmRequest.kt | 10 ++ .../web/dto/request/EditFilmRequest.kt | 10 ++ .../web/dto/request/EditUserRequest.kt | 1 + .../web/dto/response/FilmLibraryResponse.kt | 2 + .../adapters/web/dto/response/FilmResponse.kt | 21 ++++ .../adapters/web/dto/response/UserResponse.kt | 2 + .../ports/input/FilmLibraryUseCase.kt | 15 +++ .../application/ports/input/FilmUseCase.kt | 21 ++++ .../application/ports/input/UserUseCase.kt | 1 + .../services/FilmLibraryService.kt | 117 +++++++++++++----- .../application/services/FilmService.kt | 61 ++++----- .../application/services/UserService.kt | 9 +- src/main/resources/application.yaml | 8 ++ .../services/FilmLibraryServiceTest.kt | 36 ++++-- 18 files changed, 291 insertions(+), 87 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 39274c8..6db897a 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -3,8 +3,10 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication +@EnableScheduling @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication 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 3699e33..9bec40b 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -25,6 +25,12 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import java.util.UUID +private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType = + runCatching { + com.project.movienight.domain.model.ContentType + .valueOf(this) + }.getOrDefault(com.project.movienight.domain.model.ContentType.FILM) + @RestController @RequestMapping("/api/films") class FilmController( @@ -45,6 +51,16 @@ class FilmController( CreateFilmCommand( title = request.title, description = request.description, + contentType = request.contentType.toContentTypeOrFilm(), + releaseYear = request.releaseYear, + genres = request.genres, + cast = request.cast, + directors = request.directors, + imdbRating = request.imdbRating, + platformRating = request.platformRating, + externalUrl = request.externalUrl, + jellyfinItemId = request.jellyfinItemId, + jellyfinLibraryId = request.jellyfinLibraryId, ), ), ) @@ -61,6 +77,16 @@ class FilmController( EditFilmCommand( title = request.title, description = request.description, + contentType = request.contentType.toContentTypeOrFilm(), + releaseYear = request.releaseYear, + genres = request.genres, + cast = request.cast, + directors = request.directors, + imdbRating = request.imdbRating, + platformRating = request.platformRating, + externalUrl = request.externalUrl, + jellyfinItemId = request.jellyfinItemId, + jellyfinLibraryId = request.jellyfinLibraryId, ), ), ) 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 c2ed037..12c2c5e 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -11,6 +11,9 @@ import com.project.movienight.application.ports.input.GetAllFilmsUseCase import com.project.movienight.application.ports.input.GetFilmByIdUseCase import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.domain.exception.EntityNotFoundException @@ -30,10 +33,10 @@ import java.util.UUID class FilmLibraryController( private val createFilmLibraryUseCase: CreateFilmLibraryUseCase, private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, + private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val getFilmByIdUseCase: GetFilmByIdUseCase, - private val getAllFilmsUseCase: GetAllFilmsUseCase, + private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -60,18 +63,10 @@ class FilmLibraryController( ), ) - @GetMapping("/films") - fun getAllFilmsInLibrary( + @GetMapping("/entries") + fun list( @PathVariable userId: UUID, - ): List { - val library = - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) - - val film = getFilmByIdUseCase.getById(library.filmId) - return listOf(FilmResponse.fromDomain(film)) - } + ): List = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) } @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) @@ -88,6 +83,20 @@ class FilmLibraryController( ), ) + @PostMapping("/films/{filmId}/viewed") + fun markViewed( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + ): FilmLibraryResponse = + FilmLibraryResponse.fromDomain( + markFilmViewedUseCase.markViewed( + MarkFilmViewedCommand( + userId = userId, + filmId = filmId, + ), + ), + ) + @DeleteMapping("/films/{filmId}") @ResponseStatus(HttpStatus.NO_CONTENT) fun removeFilm( diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index bccf5bc..e06954c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -64,6 +64,7 @@ class UserController( command = EditUserCommand( name = request.name, + jellyfinUserId = request.jellyfinUserId, ), ), ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt index 994429d..82f7348 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt @@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request data class CreateFilmRequest( val title: String, val description: String, + val contentType: String = "FILM", + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt index 9e476c3..60eddce 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt @@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request data class EditFilmRequest( val title: String, val description: String, + val contentType: String = "FILM", + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt index 83ddd24..358e0e4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt @@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request data class EditUserRequest( val name: String, + val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt index 8ba6c01..90a339d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt @@ -9,6 +9,7 @@ data class FilmLibraryResponse( val filmId: UUID, val comment: String?, val isViewed: Boolean, + val watchedAt: java.time.LocalDateTime?, ) { companion object { fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse = @@ -18,6 +19,7 @@ data class FilmLibraryResponse( filmId = filmLibrary.filmId, comment = filmLibrary.comment, isViewed = filmLibrary.isViewed, + watchedAt = filmLibrary.watchedAt, ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt index 239196d..4948540 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.web.dto.response +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID @@ -7,6 +8,16 @@ data class FilmResponse( val id: UUID, val title: String, val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val imdbRating: Double?, + val platformRating: Double?, + val externalUrl: String?, + val jellyfinItemId: String?, + val jellyfinLibraryId: String?, ) { companion object { fun fromDomain(film: Film): FilmResponse = @@ -14,6 +25,16 @@ data class FilmResponse( id = film.id, title = film.title, description = film.description, + contentType = film.contentType, + releaseYear = film.releaseYear, + genres = film.genres, + cast = film.cast, + directors = film.directors, + imdbRating = film.imdbRating, + platformRating = film.platformRating, + externalUrl = film.externalUrl, + jellyfinItemId = film.jellyfinItemId, + jellyfinLibraryId = film.jellyfinLibraryId, ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt index 48f5dd8..b1b94f4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt @@ -7,6 +7,7 @@ data class UserResponse( val id: UUID, val name: String, val email: String, + val jellyfinUserId: String?, ) { companion object { fun fromDomain(user: User): UserResponse = @@ -14,6 +15,7 @@ data class UserResponse( id = user.id, name = user.name, email = user.email, + jellyfinUserId = user.jellyfinUserId, ) } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt index cf9a0b8..3100547 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt @@ -1,6 +1,7 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.FilmLibrary +import java.time.LocalDateTime import java.util.UUID interface CreateFilmLibraryUseCase { @@ -21,6 +22,16 @@ data class AddFilmToLibraryCommand( val filmId: UUID, ) +interface MarkFilmViewedUseCase { + fun markViewed(command: MarkFilmViewedCommand): FilmLibrary +} + +data class MarkFilmViewedCommand( + val userId: UUID, + val filmId: UUID, + val watchedAt: LocalDateTime? = null, +) + interface RemoveFilmFromLibraryUseCase { fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary } @@ -38,3 +49,7 @@ interface GetFilmLibraryUseCase { data class GetFilmLibraryQuery( val userId: UUID, ) + +interface ListFilmLibraryEntriesUseCase { + fun list(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index db3f4b0..27098c7 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.ports.input +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID @@ -10,6 +11,16 @@ interface CreateFilmUseCase { data class CreateFilmCommand( val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) interface EditFilmUseCase { @@ -22,6 +33,16 @@ interface EditFilmUseCase { data class EditFilmCommand( val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) interface DeleteFilmUseCase { diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index b417525..b066a4f 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -21,6 +21,7 @@ interface EditUserUseCase { data class EditUserCommand( val name: String, + val jellyfinUserId: String? = null, ) interface DeleteUserUseCase { diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index ba64e2a..13ec743 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,11 +1,15 @@ package com.project.movienight.application.services +import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort @@ -20,70 +24,119 @@ import java.util.UUID class FilmLibraryService( private val filmLibraryRepository: FilmLibraryRepositoryPort, private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, ) : CreateFilmLibraryUseCase, AddFilmToLibraryUseCase, + MarkFilmViewedUseCase, RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase { + GetFilmLibraryUseCase, + ListFilmLibraryEntriesUseCase { override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - val existingLibrary = findByUserId(command.userId) - if (existingLibrary != null) { - return existingLibrary - } + findByUserId(command.userId)?.let { return it } - return filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = idGenerator.generateId(), - comment = command.name, - isViewed = false, - ), - ) + val libraryId = idGenerator.generateId() + val saved = + filmLibraryRepository.save( + FilmLibrary( + id = libraryId, + userId = command.userId, + filmId = libraryId, + comment = command.name, + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + return saved } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - val existingLibrary = findByUserId(command.userId) - if (existingLibrary == null) { - return filmLibraryRepository.save( + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + if (existingEntry != null) { + val saved = + filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + return saved + } + + val saved = + filmLibraryRepository.save( FilmLibrary( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, comment = null, isViewed = false, + watchedAt = null, ), ) - } - - return filmLibraryRepository.save( - existingLibrary.copy( - filmId = command.filmId, - isViewed = false, - ), - ) + businessMetricsService.recordLibraryEvent() + return saved } override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { val existingLibrary = - findByUserId(command.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) + if (command.libraryId != null) { + filmLibraryRepository.findById(command.libraryId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + } else { + findByUserAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + } - if (command.libraryId != null && command.libraryId != existingLibrary.id) { - throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } - - if (existingLibrary.filmId != command.filmId) { + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { throw DomainException("Film with id ${command.filmId} not found in user's library") } filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() return existingLibrary } + override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + val saved = + if (existingEntry == null) { + filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } else { + filmLibraryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + businessMetricsService.recordLibraryEvent() + return saved + } + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = findByUserId(query.userId) ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + private fun findByUserId(userId: UUID): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + + private fun findByUserAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index bbd32b1..564667f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -56,21 +56,23 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) - val saved = filmRepository.save(film) - - filmCreatedCounter.increment() - - log.info("Film created: id='{}', title='{}'", saved.id, saved.title) - return saved - } finally { - sample.stop(createFilmTimer) - } + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) + return filmRepository.save(film) } override fun edit( @@ -100,20 +102,23 @@ class FilmService( throw EntityNotFoundException(entity = "Film", id = id.toString()) } - val updatedFilm = - film.copy( - title = command.title, - description = command.description, - ) - val saved = filmRepository.save(updatedFilm) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) - filmEditedCounter.increment() - - log.info("Film edited: id='{}'", saved.id) - return saved - } finally { - sample.stop(editFilmTimer) - } + return filmRepository.save(film) } override fun delete(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index da2a84b..684da5f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -37,6 +37,7 @@ class UserService( name = command.name, email = command.email, library = null, + jellyfinUserId = null, ) return userRepository.save(user) } @@ -50,7 +51,13 @@ class UserService( } var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = user.copy(name = command.name) + + user = + user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) + return userRepository.save(user) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 284e69a..9f562dc 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -95,6 +95,14 @@ info: description: MovieNight backend service version: ${project.version:unknown} +integrations: + jellyfin: + enabled: ${JELLYFIN_SYNC_ENABLED:false} + base-url: ${JELLYFIN_BASE_URL:} + api-key: ${JELLYFIN_API_KEY:} + sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} + request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} + services: user: blocked-names: diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index 1556f83..b029c84 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.services +import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.GetFilmLibraryQuery @@ -23,32 +24,33 @@ import java.util.UUID class FilmLibraryServiceTest { private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort private lateinit var idGenerator: IdGenerator + private lateinit var businessMetricsService: BusinessMetricsService private lateinit var filmLibraryService: FilmLibraryService @BeforeEach fun setup() { filmLibraryRepository = mockk() idGenerator = mockk() - filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator) + businessMetricsService = mockk(relaxed = true) + filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService) } @Test fun `should create new film library when user has no library`() { val userId = UUID.randomUUID() val libraryId = UUID.randomUUID() - val filmId = UUID.randomUUID() val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") val expectedLibrary = FilmLibrary( id = libraryId, userId = userId, - filmId = filmId, + filmId = libraryId, comment = "My Films", isViewed = false, ) every { filmLibraryRepository.findAll() } returns emptyList() - every { idGenerator.generateId() } returnsMany listOf(libraryId, filmId) + every { idGenerator.generateId() } returns libraryId every { filmLibraryRepository.save( match { @@ -62,11 +64,11 @@ class FilmLibraryServiceTest { assertNotNull(result) assertEquals(libraryId, result.id) assertEquals(userId, result.userId) - assertEquals(filmId, result.filmId) + assertEquals(libraryId, result.filmId) assertEquals("My Films", result.comment) verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 2) { idGenerator.generateId() } + verify(exactly = 1) { idGenerator.generateId() } verify(exactly = 1) { filmLibraryRepository.save(any()) } } @@ -131,7 +133,7 @@ class FilmLibraryServiceTest { } @Test - fun `should add film to existing library`() { + fun `should add film as a new library entry when another film already exists`() { val userId = UUID.randomUUID() val oldFilmId = UUID.randomUUID() val newFilmId = UUID.randomUUID() @@ -144,16 +146,24 @@ class FilmLibraryServiceTest { isViewed = true, ) val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) - val updatedLibrary = existingLibrary.copy(filmId = newFilmId, isViewed = false) + val createdLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = newFilmId, + comment = null, + isViewed = false, + ) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { idGenerator.generateId() } returns createdLibrary.id every { filmLibraryRepository.save( match { - it.filmId == newFilmId && it.isViewed == false + it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false }, ) - } returns updatedLibrary + } returns createdLibrary val result = filmLibraryService.addFilm(command) @@ -161,7 +171,7 @@ class FilmLibraryServiceTest { assertEquals(false, result.isViewed) verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 1) { idGenerator.generateId() } verify(exactly = 1) { filmLibraryRepository.save(any()) } } @@ -253,13 +263,13 @@ class FilmLibraryServiceTest { libraryId = wrongLibraryId, ) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { filmLibraryRepository.findById(wrongLibraryId) } returns null assertThrows { filmLibraryService.removeFilm(command) } - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) } verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } } -- 2.54.0 From 5f2a6d12ac982e245aaddd1169ec275653a430f1 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 16:57:19 +0300 Subject: [PATCH 64/77] core: restore compilation and tests (ports, DTOs, repo fixes, metrics) --- .../metrics/BusinessMetricsService.kt | 66 +++++++ .../persistence/jdbc/FilmRepository.kt | 20 ++- .../adapters/web/FilmLibraryController.kt | 1 + .../adapters/web/FilmRatingController.kt | 46 +++++ .../adapters/web/RecommendationController.kt | 34 ++++ .../adapters/web/UserPreferencesController.kt | 53 ++++++ .../web/dto/request/RateFilmRequest.kt | 6 + .../request/UpsertUserPreferencesRequest.kt | 10 ++ .../web/dto/response/FilmRatingResponse.kt | 28 +++ .../dto/response/UserPreferencesResponse.kt | 28 +++ .../ports/input/FilmRatingUseCase.kt | 19 ++ .../ports/input/GetRecommendationsUseCase.kt | 16 ++ .../ports/input/UserPreferencesUseCase.kt | 23 +++ .../application/services/FilmRatingService.kt | 57 ++++++ .../application/services/FilmService.kt | 81 +++++---- .../services/RecommendationService.kt | 117 ++++++++++++ .../services/UserPreferencesService.kt | 29 +++ .../movienight/RecommendationSmokeTest.kt | 168 ++++++++++++++++++ 18 files changed, 766 insertions(+), 36 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt create mode 100644 src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt new file mode 100644 index 0000000..6a8f2d0 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -0,0 +1,66 @@ +package com.project.movienight.adapters.metrics + +import com.project.movienight.domain.model.JellyfinSyncSummary +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import org.springframework.stereotype.Service +import java.util.concurrent.atomic.AtomicInteger + +@Service +class BusinessMetricsService( + meterRegistry: MeterRegistry, +) { + private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total") + private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total") + private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total") + private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total") + private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total") + private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total") + private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total") + private val jellyfinSyncDuration: Timer = + Timer + .builder("business_jellyfin_sync_duration_seconds") + .publishPercentileHistogram() + .register(meterRegistry) + private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total") + private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0) + + init { + meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue) + } + + private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total") + + fun recordRecommendationRequest() { + recommendationRequests.increment() + } + + fun recordRatingSubmitted() { + ratingsSubmitted.increment() + } + + fun recordLibraryEvent() { + libraryEvents.increment() + } + + fun recordJellyfinSync(summary: JellyfinSyncSummary) { + jellyfinSyncRuns.increment() + jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble()) + jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble()) + jellyfinSyncedItems.increment(summary.syncedItems.toDouble()) + jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS) + } + + fun recordJellyfinSyncFailure() { + jellyfinSyncFailures.increment() + } + + fun recordJellyfinUnmappedUser() { + jellyfinUnmappedUsersGaugeValue.incrementAndGet() + } + + fun recordBackendWriteFailure() { + backendWriteFailures.increment() + } +} 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 a661396..b1d4ab2 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 @@ -209,7 +209,25 @@ class FilmRepository( override fun findByTitle(title: String): Film? { val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE title = ? + ORDER BY id + LIMIT 1 + """.trimIndent(), filmRowMapper, title, ) 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 12c2c5e..81115cd 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -36,6 +36,7 @@ class FilmLibraryController( private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, ) { @PostMapping diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt new file mode 100644 index 0000000..fec4889 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -0,0 +1,46 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.response.FilmRatingResponse +import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RateFilmUseCase +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/ratings") +class FilmRatingController( + private val rateFilmUseCase: RateFilmUseCase, + private val getFilmRatingsUseCase: GetFilmRatingsUseCase, +) { + @PostMapping("/films/{filmId}") + @ResponseStatus(HttpStatus.CREATED) + fun rate( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + @RequestBody request: RateFilmRequest, + ): FilmRatingResponse = + FilmRatingResponse.fromDomain( + rateFilmUseCase.rate( + RateFilmCommand( + userId = userId, + filmId = filmId, + score = request.score, + note = request.note, + ), + ), + ) + + @GetMapping + fun list( + @PathVariable userId: UUID, + ): List = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt new file mode 100644 index 0000000..8cf7823 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -0,0 +1,34 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationResult +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/recommendations") +class RecommendationController( + private val getRecommendationsUseCase: GetRecommendationsUseCase, +) { + @GetMapping + fun recommend( + @PathVariable userId: UUID, + @RequestParam(required = false) contentType: String?, + @RequestParam(required = false) mood: String?, + @RequestParam(required = false, defaultValue = "10") limit: Int, + ): List = + getRecommendationsUseCase.recommend( + RecommendationQuery( + userId = userId, + contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() }, + mood = mood, + limit = limit, + ), + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt new file mode 100644 index 0000000..276565b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -0,0 +1,53 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest +import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse +import com.project.movienight.application.ports.input.GetUserPreferencesUseCase +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.domain.model.ContentType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/preferences") +class UserPreferencesController( + private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase, + private val getUserPreferencesUseCase: GetUserPreferencesUseCase, +) { + @PutMapping + fun upsert( + @PathVariable userId: UUID, + @RequestBody request: UpsertUserPreferencesRequest, + ): UserPreferencesResponse = + UserPreferencesResponse.fromDomain( + upsertUserPreferencesUseCase.upsert( + UpsertUserPreferencesCommand( + userId = userId, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = + request.contentTypes.mapNotNull { + runCatching { + ContentType.valueOf( + it, + ) + }.getOrNull() + }, + ), + ), + ) + + @GetMapping + fun get( + @PathVariable userId: UUID, + ): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt new file mode 100644 index 0000000..1f44e39 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt @@ -0,0 +1,6 @@ +package com.project.movienight.adapters.web.dto.request + +data class RateFilmRequest( + val score: Int, + val note: String? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt new file mode 100644 index 0000000..38c809e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt @@ -0,0 +1,10 @@ +package com.project.movienight.adapters.web.dto.request + +data class UpsertUserPreferencesRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt new file mode 100644 index 0000000..8f276fc --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.FilmRating +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRatingResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) { + companion object { + fun fromDomain(rating: FilmRating): FilmRatingResponse = + FilmRatingResponse( + id = rating.id, + userId = rating.userId, + filmId = rating.filmId, + score = rating.score, + note = rating.note, + createdAt = rating.createdAt, + updatedAt = rating.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt new file mode 100644 index 0000000..2388d3f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +data class UserPreferencesResponse( + val userId: UUID, + val weightedGenres: Map, + val plotTypes: List, + val eras: List, + val castAndDirectors: List, + val moods: List, + val contentTypes: List, +) { + companion object { + fun fromDomain(preferences: UserPreferences): UserPreferencesResponse = + UserPreferencesResponse( + userId = preferences.userId, + weightedGenres = preferences.weightedGenres, + plotTypes = preferences.plotTypes, + eras = preferences.eras, + castAndDirectors = preferences.castAndDirectors, + moods = preferences.moods, + contentTypes = preferences.contentTypes, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt new file mode 100644 index 0000000..37c8226 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -0,0 +1,19 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface RateFilmUseCase { + fun rate(command: RateFilmCommand): FilmRating +} + +data class RateFilmCommand( + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, +) + +interface GetFilmRatingsUseCase { + fun getRatings(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt new file mode 100644 index 0000000..de9f91f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt @@ -0,0 +1,16 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationResult +import java.util.UUID + +interface GetRecommendationsUseCase { + fun recommend(query: RecommendationQuery): List +} + +data class RecommendationQuery( + val userId: UUID, + val contentType: ContentType? = null, + val mood: String? = null, + val limit: Int = 10, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt new file mode 100644 index 0000000..b44820c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -0,0 +1,23 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UpsertUserPreferencesUseCase { + fun upsert(command: UpsertUserPreferencesCommand): UserPreferences +} + +data class UpsertUserPreferencesCommand( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) + +interface GetUserPreferencesUseCase { + fun get(userId: UUID): UserPreferences? +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt new file mode 100644 index 0000000..738bada --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RateFilmUseCase +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmRating +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.UUID + +@Service +class FilmRatingService( + private val filmRepository: FilmRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, +) : RateFilmUseCase, + GetFilmRatingsUseCase { + override fun rate(command: RateFilmCommand): FilmRating { + if (command.score !in 1..10) { + throw DomainException("Film rating score must be between 1 and 10") + } + + filmRepository.findById(command.filmId) + ?: throw EntityNotFoundException(entity = "Film", id = command.filmId.toString()) + + val existingRating = filmRatingRepository.findByUserIdAndFilmId(command.userId, command.filmId) + val now = LocalDateTime.now() + + val rating = + if (existingRating == null) { + FilmRating( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + score = command.score, + note = command.note, + createdAt = now, + updatedAt = now, + ) + } else { + existingRating.copy(score = command.score, note = command.note, updatedAt = now) + } + + val savedRating = filmRatingRepository.save(rating) + businessMetricsService.recordRatingSubmitted() + return savedRating + } + + override fun getRatings(userId: UUID): List = filmRatingRepository.findByUserId(userId) +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 564667f..7424e84 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -40,7 +40,7 @@ class FilmService( try { log.debug( - "Create film request received: title='{}', descriptionLength={}", + "Create film request received: title='{}', descriptionLength={}'", command.title, command.description.length, ) @@ -56,23 +56,29 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) - return filmRepository.save(film) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) + + val saved = filmRepository.save(film) + filmCreatedCounter.increment() + return saved + } finally { + sample.stop(createFilmTimer) + } } override fun edit( @@ -95,30 +101,35 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = filmRepository.findById(id) + var film = filmRepository.findById(id) if (film == null) { log.debug("Film not found for edit: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - film = - film.copy( - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) - return filmRepository.save(film) + val saved = filmRepository.save(film) + filmEditedCounter.increment() + return saved + } finally { + sample.stop(editFilmTimer) + } } override fun delete(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt new file mode 100644 index 0000000..cc6bc39 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -0,0 +1,117 @@ +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.RecommendationResult +import org.springframework.stereotype.Service + +@Service +class RecommendationService( + private val filmRepository: FilmRepositoryPort, + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val businessMetricsService: BusinessMetricsService, +) : GetRecommendationsUseCase { + override fun recommend(query: RecommendationQuery): List { + businessMetricsService.recordRecommendationRequest() + val preferences = userPreferencesRepository.findByUserId(query.userId) + val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId } + val watchedFilmIds = + filmLibraryRepository + .findAll() + .filter { + it.userId == query.userId && it.isViewed + }.map { it.filmId } + .toSet() + + return filmRepository + .findAll() + .asSequence() + .filter { film -> query.contentType == null || film.contentType == query.contentType } + .map { film -> + scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id)) + }.sortedByDescending { it.score } + .take(query.limit.coerceAtLeast(1)) + .toList() + } + + private fun scoreFilm( + film: Film, + mood: String?, + preferences: com.project.movienight.domain.model.UserPreferences?, + hasUserRating: Boolean, + watched: Boolean, + ): RecommendationResult { + var score = 0.0 + val reasons = mutableListOf() + + preferences?.contentTypes?.let { + if (it.isEmpty() || it.contains(film.contentType)) { + score += 2.0 + reasons += "Matches content preference" + } + } + + preferences?.weightedGenres?.forEach { (genre, weight) -> + if (film.genres.any { it.equals(genre, ignoreCase = true) }) { + score += weight + reasons += "Matches genre $genre" + } + } + + preferences?.castAndDirectors?.forEach { favorite -> + val found = + film.cast.any { it.equals(favorite, ignoreCase = true) } || + film.directors.any { it.equals(favorite, ignoreCase = true) } + if (found) { + score += 1.5 + reasons += "Matches favorite creator or cast member $favorite" + } + } + + preferences?.moods?.forEach { preferredMood -> + if (mood != null && preferredMood.equals(mood, ignoreCase = true)) { + score += 1.25 + reasons += "Matches requested mood $mood" + } + } + + film.imdbRating?.let { + score += it / 2.0 + reasons += "Strong IMDb signal" + } + + film.platformRating?.let { + score += it + reasons += "Strong platform signal" + } + + if (hasUserRating) { + score += 2.0 + reasons += "User has already rated similar content" + } + + if (watched) { + score -= 3.0 + reasons += "Already watched" + } + + if (mood != null && film.title.contains(mood, ignoreCase = true)) { + score += 0.5 + } + + if (reasons.isEmpty()) { + reasons += "Baseline recommendation from library catalog" + } + + return RecommendationResult(film = film, score = score, reasons = reasons) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt new file mode 100644 index 0000000..de388ce --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -0,0 +1,29 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.GetUserPreferencesUseCase +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.UserPreferences +import org.springframework.stereotype.Service + +@Service +class UserPreferencesService( + private val userPreferencesRepository: UserPreferencesRepositoryPort, +) : UpsertUserPreferencesUseCase, + GetUserPreferencesUseCase { + override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences = + userPreferencesRepository.save( + UserPreferences( + userId = command.userId, + weightedGenres = command.weightedGenres, + plotTypes = command.plotTypes, + eras = command.eras, + castAndDirectors = command.castAndDirectors, + moods = command.moods, + contentTypes = command.contentTypes, + ), + ) + + override fun get(userId: java.util.UUID): UserPreferences? = userPreferencesRepository.findByUserId(userId) +} diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt new file mode 100644 index 0000000..a7ab288 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -0,0 +1,168 @@ +package com.project.movienight + +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 com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +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.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.put +import java.util.UUID + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class RecommendationSmokeTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + @Test + fun `should create data and return a ranked recommendation`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Jane", email = "jane@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "jane@example.com", + ), + ) + + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = "Orbital Drift", + description = "A science-fiction rescue mission", + contentType = "FILM", + genres = listOf("SCI-FI", "THRILLER"), + directors = listOf("Nora Finch"), + imdbRating = 8.7, + platformRating = 9.0, + externalUrl = "https://example.com/orbital-drift", + ), + ) + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = "Small Town Summer", + description = "A grounded family drama", + contentType = "FILM", + genres = listOf("DRAMA"), + directors = listOf("Ava Reed"), + imdbRating = 7.1, + platformRating = 6.8, + ), + ) + }.andExpect { + status { isCreated() } + } + + val createdFilms = jdbcTemplate.queryForList("SELECT id, title FROM films ORDER BY title") + val filmIdByTitle = + createdFilms.associate { row -> + row["title"].toString() to UUID.fromString(row["id"].toString()) + } + val firstFilmId = filmIdByTitle.getValue("Orbital Drift") + val secondFilmId = filmIdByTitle.getValue("Small Town Summer") + + mockMvc + .put("/api/users/$userId/preferences") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpsertUserPreferencesRequest( + weightedGenres = mapOf("SCI-FI" to 5), + moods = listOf("focused"), + contentTypes = listOf("FILM"), + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.weightedGenres['SCI-FI']") { value(5) } + } + + mockMvc + .post("/api/users/$userId/ratings/films/$firstFilmId") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Great fit")) + }.andExpect { + status { isCreated() } + jsonPath("$.score") { value(10) } + } + + mockMvc + .post("/api/users/$userId/library/films/$secondFilmId/viewed") + .andExpect { + status { isOk() } + jsonPath("$.viewed") { value(true) } + } + + mockMvc + .get("/api/users/$userId/ratings") + .andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(firstFilmId.toString()) } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$[0].film.id") { value(firstFilmId.toString()) } + } + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM film_ratings") + jdbcTemplate.execute("DELETE FROM user_preferences") + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } +} -- 2.54.0 From d0860cf137a088fcb0140b53b74fa88bfd1858f3 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 16:57:53 +0300 Subject: [PATCH 65/77] integrations(jellyfin): add event ingestion and sync scaffolding + migration --- .../adapters/jellyfin/JellyfinApiClient.kt | 143 ++++++++++++++++ .../adapters/web/JellyfinEventsController.kt | 52 ++++++ .../adapters/web/JellyfinSyncController.kt | 21 +++ .../web/dto/request/JellyfinEventRequest.kt | 21 +++ .../services/JellyfinEventService.kt | 70 ++++++++ .../services/JellyfinSyncService.kt | 153 ++++++++++++++++++ .../config/JellyfinIntegrationProperties.kt | 13 ++ ...fin_events.sql => V3__jellyfin_events.sql} | 0 8 files changed, 473 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt create mode 100644 src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt rename src/main/resources/db/migration/{V2__jellyfin_events.sql => V3__jellyfin_events.sql} (100%) diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt new file mode 100644 index 0000000..a21d103 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -0,0 +1,143 @@ +package com.project.movienight.adapters.jellyfin + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.config.JellyfinIntegrationProperties +import com.project.movienight.domain.model.ContentType +import org.springframework.stereotype.Service +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +data class JellyfinRemoteUser( + val id: String, + val name: String, +) + +data class JellyfinLibraryItemSnapshot( + val jellyfinItemId: String, + val title: String, + val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) + +@Service +class JellyfinApiClient( + private val properties: JellyfinIntegrationProperties, + private val objectMapper: ObjectMapper, +) { + private val httpClient: HttpClient = + HttpClient + .newBuilder() + .connectTimeout(Duration.ofMillis(properties.requestTimeoutMs)) + .build() + + fun fetchUsers(): List = + request("Users") + .asItems() + .mapNotNull { node -> + val id = node.fieldText("Id") ?: return@mapNotNull null + JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) + } + + fun fetchLibraryItems(userId: String): List = + @Suppress("MaxLineLength") + request( + "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", + ).asItems().mapNotNull { node -> + val itemId = node.fieldText("Id") ?: return@mapNotNull null + val providerIds = node["ProviderIds"] + val imdbId = providerIds?.fieldText("Imdb") + val people = node["People"] + val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList() + val directors = people?.peopleByType("Director") ?: emptyList() + JellyfinLibraryItemSnapshot( + jellyfinItemId = itemId, + title = node.fieldText("Name") ?: itemId, + description = node.fieldText("Overview") ?: "", + contentType = mapContentType(node.fieldText("Type")), + releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(), + genres = node["Genres"]?.textList() ?: emptyList(), + cast = cast, + directors = directors, + platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(), + imdbRating = null, + externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" }, + jellyfinLibraryId = node.fieldText("ParentId"), + isPlayed = + node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false, + ) + } + + private fun request(path: String): JsonNode { + val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path") + val request = + HttpRequest + .newBuilder(uri) + .timeout(Duration.ofMillis(properties.requestTimeoutMs)) + .header("Accept", "application/json") + .header("X-Emby-Token", properties.apiKey) + .GET() + .build() + + val response = + try { + httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + } catch ( + @Suppress("TooGenericExceptionCaught") exception: Exception, + ) { + throw IllegalStateException("Failed to call Jellyfin at $uri", exception) + } + + check(response.statusCode() !in 200..299) { + "Jellyfin request failed with status ${response.statusCode()} for $uri" + } + + return objectMapper.readTree(response.body()) + } + + private fun JsonNode.asItems(): List = + when { + isArray -> map { it } + has("Items") && this["Items"].isArray -> this["Items"].map { it } + else -> emptyList() + } + + private fun JsonNode.fieldText(name: String): String? = + get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() } + + private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean() + + private fun JsonNode.textList(): List = + takeIf { it.isArray }?.mapNotNull { item -> + item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() } + } + ?: emptyList() + + private fun JsonNode.peopleByType(vararg types: String): List { + if (!isArray) return emptyList() + return mapNotNull { person -> + val type = person.fieldText("Type") ?: return@mapNotNull null + if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null + } + } + + private fun mapContentType(value: String?): ContentType = + when (value?.lowercase()) { + "movie" -> ContentType.FILM + "series" -> ContentType.SERIES + "episode" -> ContentType.EPISODE + else -> ContentType.OTHER + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt new file mode 100644 index 0000000..3708530 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -0,0 +1,52 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest +import com.project.movienight.application.services.JellyfinEventService +import com.project.movienight.config.JellyfinIntegrationProperties +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/api/integrations/jellyfin") +class JellyfinEventsController( + private val jellyfinEventService: JellyfinEventService, + private val properties: JellyfinIntegrationProperties, +) { + private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) + + @PostMapping("/events") + @ResponseStatus(HttpStatus.OK) + fun receiveEvent( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @RequestBody request: JellyfinEventRequest, + ) { + if (properties.pluginToken.isNotBlank()) { + if (token == null || token != properties.pluginToken) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token") + } + } + + log.debug( + "Received Jellyfin event {} for user {} item {}", + request.eventId, + request.jellyfinUserId, + request.itemId, + ) + jellyfinEventService.handleEvent( + eventId = request.eventId, + serverId = null, + eventType = request.eventType, + occurredAt = request.occurredAt, + jellyfinUserId = request.jellyfinUserId, + itemId = request.itemId, + payload = request.payload, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt new file mode 100644 index 0000000..74aac90 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.services.JellyfinSyncService +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/integrations/jellyfin") +class JellyfinSyncController( + private val jellyfinSyncService: JellyfinSyncService, +) { + @PostMapping("/sync") + fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow() + + @GetMapping("/sync-state") + fun syncState(): List = jellyfinSyncService.getSyncStates() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt new file mode 100644 index 0000000..68abfe8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web.dto.request + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.OffsetDateTime + +data class JellyfinEventRequest( + @JsonProperty("event_id") + val eventId: String, + @JsonProperty("event_type") + val eventType: String, + @JsonProperty("occurred_at") + val occurredAt: OffsetDateTime, + @JsonProperty("jellyfin_user_id") + val jellyfinUserId: String, + @JsonProperty("item_id") + val itemId: String, + @JsonProperty("payload_version") + val payloadVersion: Int = 1, + @JsonProperty("payload") + val payload: Map? = null, +) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt new file mode 100644 index 0000000..8ff3f6b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -0,0 +1,70 @@ +package com.project.movienight.application.services + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import org.springframework.stereotype.Service +import java.time.OffsetDateTime + +@Service +class JellyfinEventService( + private val jellyfinEventRepository: JellyfinEventRepository, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val markFilmViewedUseCase: MarkFilmViewedUseCase, + private val objectMapper: ObjectMapper, + private val businessMetricsService: BusinessMetricsService, +) { + private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed") + + fun handleEvent( + eventId: String, + serverId: String?, + eventType: String, + occurredAt: OffsetDateTime, + jellyfinUserId: String, + itemId: String, + payload: Map?, + ) { + if (jellyfinEventRepository.exists(eventId = eventId)) { + return + } + + val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } + jellyfinEventRepository.save(eventId, serverId, eventType, occurredAt, jellyfinUserId, itemId, payloadJson) + + try { + if (playbackEventTypes.contains(eventType)) { + val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId } + if (localUser == null) { + businessMetricsService.recordJellyfinUnmappedUser() + return + } + + val film = filmRepository.findByJellyfinItemId(itemId) + if (film == null) { + businessMetricsService.recordBackendWriteFailure() + return + } + + markFilmViewedUseCase.markViewed( + MarkFilmViewedCommand( + userId = localUser.id, + filmId = film.id, + watchedAt = occurredAt.toLocalDateTime(), + ), + ) + businessMetricsService.recordLibraryEvent() + } + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordBackendWriteFailure() + throw ex + } + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt new file mode 100644 index 0000000..46faa88 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -0,0 +1,153 @@ +package com.project.movienight.application.services + +import com.project.movienight.adapters.jellyfin.JellyfinApiClient +import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot +import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.config.JellyfinIntegrationProperties +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime + +@Service +class JellyfinSyncService( + private val properties: JellyfinIntegrationProperties, + private val jellyfinApiClient: JellyfinApiClient, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val syncStateRepository: JellyfinSyncStateRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, +) { + @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") + fun scheduledSync() { + if (properties.enabled) { + syncNow() + } + } + + fun syncNow(): JellyfinSyncSummary { + if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) { + return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) + } + + val startedAt = Instant.now() + val remoteUsers = jellyfinApiClient.fetchUsers() + val localUsersByJellyfinId = + userRepository + .findAll() + .mapNotNull { user -> + user.jellyfinUserId?.let { it to user } + }.toMap() + + var syncedUsers = 0 + var skippedUsers = 0 + var syncedItems = 0 + + remoteUsers.forEach { remoteUser -> + val localUser = localUsersByJellyfinId[remoteUser.id] + if (localUser == null) { + skippedUsers += 1 + return@forEach + } + + val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id) + items.forEach { item -> + syncItem(localUser.id, item) + syncedItems += 1 + } + + val now = LocalDateTime.now() + syncStateRepository.save( + JellyfinSyncState( + userId = localUser.id, + lastSyncedAt = now, + lastSuccessfulSyncAt = now, + lastError = null, + syncedItemCount = items.size, + ), + ) + syncedUsers += 1 + } + + val summary = + JellyfinSyncSummary( + syncedUsers = syncedUsers, + skippedUsers = skippedUsers, + syncedItems = syncedItems, + durationMs = Duration.between(startedAt, Instant.now()).toMillis(), + ) + businessMetricsService.recordJellyfinSync(summary) + return summary + } + + fun getSyncStates(): List = syncStateRepository.findAll() + + private fun syncItem( + userId: java.util.UUID, + item: JellyfinLibraryItemSnapshot, + ) { + val film = + filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( + title = item.title, + description = item.description, + contentType = item.contentType, + releaseYear = item.releaseYear, + genres = item.genres, + cast = item.cast, + directors = item.directors, + imdbRating = item.imdbRating, + platformRating = item.platformRating, + externalUrl = item.externalUrl, + jellyfinItemId = item.jellyfinItemId, + jellyfinLibraryId = item.jellyfinLibraryId, + ) ?: Film( + id = idGenerator.generateId(), + title = item.title, + description = item.description, + contentType = item.contentType, + releaseYear = item.releaseYear, + genres = item.genres, + cast = item.cast, + directors = item.directors, + imdbRating = item.imdbRating, + platformRating = item.platformRating, + externalUrl = item.externalUrl, + jellyfinItemId = item.jellyfinItemId, + jellyfinLibraryId = item.jellyfinLibraryId, + ) + + val savedFilm = filmRepository.save(film) + + if (item.isPlayed) { + val watchedAt = LocalDateTime.now() + val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id) + filmLibraryRepository.save( + existingEntry?.copy( + isViewed = true, + watchedAt = watchedAt, + ) ?: FilmLibrary( + id = idGenerator.generateId(), + userId = userId, + filmId = savedFilm.id, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt new file mode 100644 index 0000000..5a59400 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -0,0 +1,13 @@ +package com.project.movienight.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "integrations.jellyfin") +data class JellyfinIntegrationProperties( + val enabled: Boolean = false, + val baseUrl: String = "", + val apiKey: String = "", + val syncIntervalMs: Long = 1_800_000, + val requestTimeoutMs: Long = 20_000, + val pluginToken: String = "", +) diff --git a/src/main/resources/db/migration/V2__jellyfin_events.sql b/src/main/resources/db/migration/V3__jellyfin_events.sql similarity index 100% rename from src/main/resources/db/migration/V2__jellyfin_events.sql rename to src/main/resources/db/migration/V3__jellyfin_events.sql -- 2.54.0 From eec2814bc3f7e78c9bea92a2b9c99794dd037fff Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 19:48:44 +0300 Subject: [PATCH 66/77] feat(migrations): added migrations for ratings and added some jellyfin ids --- .../db/migration/V4__add_ratings_table.sql | 17 +++++++++++++++++ .../migration/V5__add_jellyfin_id_columns.sql | 12 ++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/resources/db/migration/V4__add_ratings_table.sql create mode 100644 src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql diff --git a/src/main/resources/db/migration/V4__add_ratings_table.sql b/src/main/resources/db/migration/V4__add_ratings_table.sql new file mode 100644 index 0000000..be1f105 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_ratings_table.sql @@ -0,0 +1,17 @@ +-- Create ratings table to store user film ratings +CREATE TABLE ratings ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, + rating NUMERIC(3, 1) NOT NULL CHECK (rating >= 0 AND rating <= 10), + source VARCHAR(50) NOT NULL DEFAULT 'MOVIENIGHT', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_user_film_rating UNIQUE (user_id, film_id) +); + +-- Create index on user_id for efficient lookups by user +CREATE INDEX idx_ratings_user_id ON ratings(user_id); + +-- Create index on film_id for efficient lookups by film +CREATE INDEX idx_ratings_film_id ON ratings(film_id); diff --git a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql new file mode 100644 index 0000000..c7c990b --- /dev/null +++ b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql @@ -0,0 +1,12 @@ +-- Add jellyfin_id columns for mapping between MovieNight and Jellyfin +ALTER TABLE films + ADD COLUMN jellyfin_id UUID UNIQUE NULL, + ADD COLUMN jellyfin_library_id UUID NULL; + +CREATE INDEX idx_films_jellyfin_id ON films(jellyfin_id); + +-- Add jellyfin_id to users for sync mapping +ALTER TABLE users + ADD COLUMN jellyfin_id UUID UNIQUE NULL; + +CREATE INDEX idx_users_jellyfin_id ON users(jellyfin_id); -- 2.54.0 From 1f22be340190d9415ae37c682198fd0e0f0963d0 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 20:06:17 +0300 Subject: [PATCH 67/77] migrations: fix jellyfin id DDL for h2 compatibility --- .../db/migration/V5__add_jellyfin_id_columns.sql | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql index c7c990b..25d719c 100644 --- a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql +++ b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql @@ -1,12 +1,17 @@ -- Add jellyfin_id columns for mapping between MovieNight and Jellyfin ALTER TABLE films - ADD COLUMN jellyfin_id UUID UNIQUE NULL, - ADD COLUMN jellyfin_library_id UUID NULL; + ADD COLUMN jellyfin_id UUID; + +ALTER TABLE films + ADD CONSTRAINT uq_films_jellyfin_id UNIQUE (jellyfin_id); CREATE INDEX idx_films_jellyfin_id ON films(jellyfin_id); -- Add jellyfin_id to users for sync mapping ALTER TABLE users - ADD COLUMN jellyfin_id UUID UNIQUE NULL; + ADD COLUMN jellyfin_id UUID; + +ALTER TABLE users + ADD CONSTRAINT uq_users_jellyfin_id UNIQUE (jellyfin_id); CREATE INDEX idx_users_jellyfin_id ON users(jellyfin_id); -- 2.54.0 From 50923e2e5abb73cf4b906ecd256bf8852ec5432f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 18:33:34 +0000 Subject: [PATCH 68/77] fix: address PR review thread issues Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/d4d6ebbb-2508-484e-accf-c891be54750f Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../adapters/jellyfin/JellyfinApiClient.kt | 2 +- .../jdbc/JellyfinEventRepository.kt | 14 ++-- .../adapters/web/JellyfinEventsController.kt | 4 ++ .../services/FilmLibraryService.kt | 16 +---- .../application/services/FilmService.kt | 2 +- .../services/JellyfinEventService.kt | 19 +++-- src/main/resources/db/migration/V1__init.sql | 49 +------------ .../db/migration/V6__extend_schema.sql | 70 +++++++++++++++++++ .../services/FilmLibraryServiceTest.kt | 33 ++------- 9 files changed, 106 insertions(+), 103 deletions(-) create mode 100644 src/main/resources/db/migration/V6__extend_schema.sql diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index a21d103..71670be 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -100,7 +100,7 @@ class JellyfinApiClient( throw IllegalStateException("Failed to call Jellyfin at $uri", exception) } - check(response.statusCode() !in 200..299) { + check(response.statusCode() in 200..299) { "Jellyfin request failed with status ${response.statusCode()} for $uri" } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 37f58ca..153eba7 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -8,12 +8,6 @@ import org.springframework.stereotype.Repository class JellyfinEventRepository( private val jdbc: NamedParameterJdbcTemplate, ) { - fun exists(eventId: String): Boolean { - val sql = "SELECT 1 FROM jellyfin_events WHERE event_id = :eventId" - val params = MapSqlParameterSource().addValue("eventId", eventId) - return jdbc.query(sql, params) { rs, _ -> rs.getInt(1) }.any() - } - fun save( eventId: String, serverId: String?, @@ -22,7 +16,7 @@ class JellyfinEventRepository( jellyfinUserId: String?, jellyfinItemId: String?, payload: String?, - ) { + ): Int { val sql = """ INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload) @@ -40,6 +34,12 @@ class JellyfinEventRepository( .addValue("jellyfinItemId", jellyfinItemId) .addValue("payload", payload) + return jdbc.update(sql, params) + } + + fun delete(eventId: String) { + val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId" + val params = MapSqlParameterSource().addValue("eventId", eventId) jdbc.update(sql, params) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 3708530..81ee52c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -27,6 +27,10 @@ class JellyfinEventsController( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @RequestBody request: JellyfinEventRequest, ) { + if (!properties.enabled) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled") + } + if (properties.pluginToken.isNotBlank()) { if (token == null || token != properties.pluginToken) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token") diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 13ec743..2924922 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -33,21 +33,7 @@ class FilmLibraryService( ListFilmLibraryEntriesUseCase { override fun create(command: CreateFilmLibraryCommand): FilmLibrary { findByUserId(command.userId)?.let { return it } - - val libraryId = idGenerator.generateId() - val saved = - filmLibraryRepository.save( - FilmLibrary( - id = libraryId, - userId = command.userId, - filmId = libraryId, - comment = command.name, - isViewed = false, - watchedAt = null, - ), - ) - businessMetricsService.recordLibraryEvent() - return saved + throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 7424e84..d69a6c8 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -40,7 +40,7 @@ class FilmService( try { log.debug( - "Create film request received: title='{}', descriptionLength={}'", + "Create film request received: title='{}', descriptionLength={}", command.title, command.description.length, ) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 8ff3f6b..64be033 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -30,23 +30,33 @@ class JellyfinEventService( itemId: String, payload: Map?, ) { - if (jellyfinEventRepository.exists(eventId = eventId)) { + val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } + val inserted = + jellyfinEventRepository.save( + eventId = eventId, + serverId = serverId, + eventType = eventType, + occurredAt = occurredAt, + jellyfinUserId = jellyfinUserId, + jellyfinItemId = itemId, + payload = payloadJson, + ) + if (inserted != 1) { return } - val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } - jellyfinEventRepository.save(eventId, serverId, eventType, occurredAt, jellyfinUserId, itemId, payloadJson) - try { if (playbackEventTypes.contains(eventType)) { val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId } if (localUser == null) { + jellyfinEventRepository.delete(eventId) businessMetricsService.recordJellyfinUnmappedUser() return } val film = filmRepository.findByJellyfinItemId(itemId) if (film == null) { + jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() return } @@ -63,6 +73,7 @@ class JellyfinEventService( } catch ( @Suppress("TooGenericExceptionCaught") ex: RuntimeException, ) { + jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() throw ex } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 1dcc5c9..ee51933 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -5,24 +5,13 @@ CREATE TABLE IF NOT EXISTS public.users ( password VARCHAR(255), provider VARCHAR(64), provider_id VARCHAR(255), - jellyfin_user_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( id UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, - description TEXT NOT NULL, - content_type VARCHAR(32) NOT NULL DEFAULT 'FILM', - release_year INT, - genres TEXT NOT NULL DEFAULT '', - cast_members TEXT NOT NULL DEFAULT '', - directors TEXT NOT NULL DEFAULT '', - imdb_rating DOUBLE PRECISION, - platform_rating DOUBLE PRECISION, - external_url TEXT, - jellyfin_item_id VARCHAR(255), - jellyfin_library_id VARCHAR(255) + description TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS public.favorites ( @@ -31,42 +20,6 @@ CREATE TABLE IF NOT EXISTS public.favorites ( film_id UUID NOT NULL, comment VARCHAR(1024), is_viewed BOOLEAN NOT NULL DEFAULT FALSE, - watched_at TIMESTAMP, CONSTRAINT favorites_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, CONSTRAINT favorites_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE ); - -CREATE TABLE IF NOT EXISTS public.user_preferences ( - user_id UUID PRIMARY KEY, - weighted_genres TEXT NOT NULL DEFAULT '', - plot_types TEXT NOT NULL DEFAULT '', - eras TEXT NOT NULL DEFAULT '', - cast_and_directors TEXT NOT NULL DEFAULT '', - moods TEXT NOT NULL DEFAULT '', - content_types TEXT NOT NULL DEFAULT '', - CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS public.film_ratings ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - film_id UUID NOT NULL, - score INT NOT NULL, - note VARCHAR(2048), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, - CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, - CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), - CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) -); - -CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( - user_id UUID PRIMARY KEY, - last_synced_at TIMESTAMP, - last_successful_sync_at TIMESTAMP, - last_error TEXT, - synced_item_count INT NOT NULL DEFAULT 0, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE -); diff --git a/src/main/resources/db/migration/V6__extend_schema.sql b/src/main/resources/db/migration/V6__extend_schema.sql new file mode 100644 index 0000000..3c7189f --- /dev/null +++ b/src/main/resources/db/migration/V6__extend_schema.sql @@ -0,0 +1,70 @@ +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS jellyfin_user_id VARCHAR(255); + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS content_type VARCHAR(32) NOT NULL DEFAULT 'FILM'; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS release_year INT; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS genres TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS cast_members TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS directors TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS imdb_rating DOUBLE PRECISION; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS platform_rating DOUBLE PRECISION; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS external_url TEXT; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS jellyfin_item_id VARCHAR(255); + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS jellyfin_library_id VARCHAR(255); + +ALTER TABLE public.favorites + ADD COLUMN IF NOT EXISTS watched_at TIMESTAMP; + +CREATE TABLE IF NOT EXISTS public.user_preferences ( + user_id UUID PRIMARY KEY, + weighted_genres TEXT NOT NULL DEFAULT '', + plot_types TEXT NOT NULL DEFAULT '', + eras TEXT NOT NULL DEFAULT '', + cast_and_directors TEXT NOT NULL DEFAULT '', + moods TEXT NOT NULL DEFAULT '', + content_types TEXT NOT NULL DEFAULT '', + CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.film_ratings ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + score INT NOT NULL, + note VARCHAR(2048), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), + CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) +); + +CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( + user_id UUID PRIMARY KEY, + last_synced_at TIMESTAMP, + last_successful_sync_at TIMESTAMP, + last_error TEXT, + synced_item_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index b029c84..7145507 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -36,40 +36,19 @@ class FilmLibraryServiceTest { } @Test - fun `should create new film library when user has no library`() { + fun `should throw EntityNotFoundException when creating library for user with no entries`() { val userId = UUID.randomUUID() - val libraryId = UUID.randomUUID() val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") - val expectedLibrary = - FilmLibrary( - id = libraryId, - userId = userId, - filmId = libraryId, - comment = "My Films", - isViewed = false, - ) every { filmLibraryRepository.findAll() } returns emptyList() - every { idGenerator.generateId() } returns libraryId - every { - filmLibraryRepository.save( - match { - it.userId == userId && it.comment == "My Films" && it.isViewed == false - }, - ) - } returns expectedLibrary - val result = filmLibraryService.create(command) - - assertNotNull(result) - assertEquals(libraryId, result.id) - assertEquals(userId, result.userId) - assertEquals(libraryId, result.filmId) - assertEquals("My Films", result.comment) + assertThrows { + filmLibraryService.create(command) + } verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 1) { idGenerator.generateId() } - verify(exactly = 1) { filmLibraryRepository.save(any()) } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmLibraryRepository.save(any()) } } @Test -- 2.54.0 From 00f53bc949d853621f5563457f059109f0cc5708 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 21 May 2026 13:58:14 +0300 Subject: [PATCH 69/77] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0:=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B3=D0=B8?= =?UTF-8?q?=D0=B1=D1=80=D0=B8=D0=B4=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D1=84=D0=B8=D0=BB=D1=8C=D0=BC=D0=BE?= =?UTF-8?q?=D0=B2,=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B9,=20accept/reject=20endpoints,=20watchUrl=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20Jellyfin,=20API=20DTO=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jdbc/RecommendationEventRepository.kt | 65 ++ .../adapters/web/RecommendationController.kt | 63 +- .../response/RecommendationEventResponse.kt | 27 + .../dto/response/RecommendationResponse.kt | 32 + .../ports/input/GetRecommendationsUseCase.kt | 20 + .../RecommendationEventRepositoryPort.kt | 10 + .../services/RecommendationService.kt | 563 +++++++++++++++--- .../config/JellyfinIntegrationProperties.kt | 1 + .../domain/model/RecommendationContext.kt | 1 + .../domain/model/RecommendationEvent.kt | 19 + src/main/resources/application.yaml | 1 + .../migration/V7__recommendation_events.sql | 19 + .../movienight/RecommendationSmokeTest.kt | 33 + src/test/resources/application-test.yaml | 4 + 14 files changed, 778 insertions(+), 80 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt create mode 100644 src/main/resources/db/migration/V7__recommendation_events.sql diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt new file mode 100644 index 0000000..339c05c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt @@ -0,0 +1,65 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationEventType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.util.UUID + +@Repository +class RecommendationEventRepository( + private val jdbc: JdbcTemplate, +) : RecommendationEventRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + RecommendationEvent( + id = UUID.fromString(rs.getString("id")), + userId = UUID.fromString(rs.getString("user_id")), + filmId = UUID.fromString(rs.getString("film_id")), + eventType = RecommendationEventType.valueOf(rs.getString("event_type")), + score = rs.getObject("score")?.let { (it as Number).toDouble() }, + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), + ) + } + + override fun save(event: RecommendationEvent): RecommendationEvent { + jdbc.update( + """ + INSERT INTO recommendation_events ( + id, + user_id, + film_id, + event_type, + score, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """.trimIndent(), + event.id, + event.userId, + event.filmId, + event.eventType.name, + event.score, + event.createdAt, + ) + return event + } + + override fun findByUserId(userId: UUID): List = + jdbc.query( + """ + SELECT id, + user_id, + film_id, + event_type, + score, + created_at + FROM recommendation_events + WHERE user_id = ? + ORDER BY created_at DESC + """.trimIndent(), + rowMapper, + userId, + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 8cf7823..5f306b0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -1,34 +1,91 @@ package com.project.movienight.adapters.web +import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse +import com.project.movienight.adapters.web.dto.response.RecommendationResponse +import com.project.movienight.application.ports.input.AcceptRecommendationCommand +import com.project.movienight.application.ports.input.AcceptRecommendationUseCase import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RejectRecommendationCommand +import com.project.movienight.application.ports.input.RejectRecommendationUseCase import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType -import com.project.movienight.domain.model.RecommendationResult import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/recommendations") class RecommendationController( private val getRecommendationsUseCase: GetRecommendationsUseCase, + private val acceptRecommendationUseCase: AcceptRecommendationUseCase, + private val rejectRecommendationUseCase: RejectRecommendationUseCase, + private val jellyfinProperties: JellyfinIntegrationProperties, ) { @GetMapping fun recommend( @PathVariable userId: UUID, @RequestParam(required = false) contentType: String?, @RequestParam(required = false) mood: String?, + @RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean, @RequestParam(required = false, defaultValue = "10") limit: Int, - ): List = + ): List = getRecommendationsUseCase.recommend( RecommendationQuery( userId = userId, - contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() }, + contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() }, mood = mood, + libraryOnly = libraryOnly, limit = limit, ), + ).map { recommendation -> + RecommendationResponse.fromDomain( + recommendation = recommendation, + watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), + ) + } + + @PostMapping("/{filmId}/accept") + fun accept( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + ): RecommendationEventResponse = + RecommendationEventResponse.fromDomain( + acceptRecommendationUseCase.accept( + AcceptRecommendationCommand( + userId = userId, + filmId = filmId, + ), + ), ) + + @PostMapping("/{filmId}/reject") + fun reject( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + ): RecommendationEventResponse = + RecommendationEventResponse.fromDomain( + rejectRecommendationUseCase.reject( + RejectRecommendationCommand( + userId = userId, + filmId = filmId, + ), + ), + ) + + private fun buildWatchUrl(jellyfinItemId: String?): String? { + if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) { + return null + } + + val baseUrl = jellyfinProperties.webUrl.trimEnd('/') + val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8) + return "$baseUrl/web/#/details?id=$encodedItemId" + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt new file mode 100644 index 0000000..2b90ef8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt @@ -0,0 +1,27 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationEventType +import java.time.LocalDateTime +import java.util.UUID + +data class RecommendationEventResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val eventType: RecommendationEventType, + val score: Double?, + val createdAt: LocalDateTime, +) { + companion object { + fun fromDomain(event: RecommendationEvent): RecommendationEventResponse = + RecommendationEventResponse( + id = event.id, + userId = event.userId, + filmId = event.filmId, + eventType = event.eventType, + score = event.score, + createdAt = event.createdAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt new file mode 100644 index 0000000..da78e72 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt @@ -0,0 +1,32 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.RecommendationResult +import java.util.UUID + +data class RecommendationResponse( + val filmId: UUID, + val title: String, + val score: Double, + val reasons: List, + val jellyfinItemId: String?, + val watchUrl: String?, + val film: FilmResponse, +) { + companion object { + fun fromDomain( + recommendation: RecommendationResult, + watchUrl: String?, + ): RecommendationResponse { + val film = recommendation.film + return RecommendationResponse( + filmId = film.id, + title = film.title, + score = recommendation.score, + reasons = recommendation.reasons, + jellyfinItemId = film.jellyfinItemId, + watchUrl = watchUrl, + film = FilmResponse.fromDomain(film), + ) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt index de9f91f..146e3bc 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt @@ -1,6 +1,7 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationEvent import com.project.movienight.domain.model.RecommendationResult import java.util.UUID @@ -12,5 +13,24 @@ data class RecommendationQuery( val userId: UUID, val contentType: ContentType? = null, val mood: String? = null, + val libraryOnly: Boolean = false, val limit: Int = 10, ) + +interface AcceptRecommendationUseCase { + fun accept(command: AcceptRecommendationCommand): RecommendationEvent +} + +data class AcceptRecommendationCommand( + val userId: UUID, + val filmId: UUID, +) + +interface RejectRecommendationUseCase { + fun reject(command: RejectRecommendationCommand): RecommendationEvent +} + +data class RejectRecommendationCommand( + val userId: UUID, + val filmId: UUID, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt new file mode 100644 index 0000000..aaf37f6 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.RecommendationEvent +import java.util.UUID + +interface RecommendationEventRepositoryPort { + fun save(event: RecommendationEvent): RecommendationEvent + + fun findByUserId(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index cc6bc39..1f9b104 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -1,16 +1,33 @@ package com.project.movienight.application.services import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.AcceptRecommendationCommand +import com.project.movienight.application.ports.input.AcceptRecommendationUseCase import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RejectRecommendationCommand +import com.project.movienight.application.ports.input.RejectRecommendationUseCase import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort -import com.project.movienight.domain.model.ContentType +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmRating +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationEventType import com.project.movienight.domain.model.RecommendationResult +import com.project.movienight.domain.model.UserPreferences +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.Locale +import java.util.UUID +import kotlin.math.sqrt @Service class RecommendationService( @@ -18,100 +35,492 @@ class RecommendationService( private val filmLibraryRepository: FilmLibraryRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val userRepository: UserRepositoryPort, + private val recommendationEventRepository: RecommendationEventRepositoryPort, + private val idGenerator: IdGenerator, private val businessMetricsService: BusinessMetricsService, -) : GetRecommendationsUseCase { +) : GetRecommendationsUseCase, + AcceptRecommendationUseCase, + RejectRecommendationUseCase { + private val log = LoggerFactory.getLogger(javaClass) + override fun recommend(query: RecommendationQuery): List { businessMetricsService.recordRecommendationRequest() - val preferences = userPreferencesRepository.findByUserId(query.userId) - val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId } - val watchedFilmIds = - filmLibraryRepository - .findAll() - .filter { - it.userId == query.userId && it.isViewed - }.map { it.filmId } - .toSet() + userRepository.findById(query.userId) + ?: throw EntityNotFoundException(entity = "User", id = query.userId.toString()) - return filmRepository - .findAll() - .asSequence() - .filter { film -> query.contentType == null || film.contentType == query.contentType } - .map { film -> - scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id)) - }.sortedByDescending { it.score } - .take(query.limit.coerceAtLeast(1)) - .toList() + val preferences = userPreferencesRepository.findByUserId(query.userId) + val ratings = filmRatingRepository.findByUserId(query.userId) + val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId } + val libraryFilmIds = libraryEntries.map { it.filmId }.toSet() + val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet() + val films = filmRepository.findAll() + val filmsById = films.associateBy { it.id } + val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById) + + val candidates = + films + .asSequence() + .filter { film -> query.contentType == null || film.contentType == query.contentType } + .filter { film -> film.id !in watchedFilmIds } + .filter { film -> !query.libraryOnly || film.id in libraryFilmIds } + .toList() + val recommendations = + candidates + .asSequence() + .map { film -> scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds) } + .sortedWith(compareByDescending { it.score }.thenBy { it.film.title }) + .take(query.limit.coerceAtLeast(1)) + .toList() + + recommendations.forEach { recommendation -> + saveEvent( + userId = query.userId, + filmId = recommendation.film.id, + eventType = RecommendationEventType.RECOMMENDED, + score = recommendation.score, + ) + } + + log.info( + RECOMMENDATION_COMPLETED_LOG, + query.userId, + query.contentType, + !query.mood.isNullOrBlank(), + query.libraryOnly, + query.limit, + candidates.size, + recommendations.size, + ) + if (log.isDebugEnabled) { + log.debug( + "Recommendation top results: userId='{}', results='{}'", + query.userId, + recommendations.joinToString(separator = ",") { "${it.film.id}:${it.score}" }, + ) + } + + return recommendations + } + + override fun accept(command: AcceptRecommendationCommand): RecommendationEvent = + saveFeedbackEvent( + userId = command.userId, + filmId = command.filmId, + eventType = RecommendationEventType.ACCEPTED, + ) + + override fun reject(command: RejectRecommendationCommand): RecommendationEvent = + saveFeedbackEvent( + userId = command.userId, + filmId = command.filmId, + eventType = RecommendationEventType.REJECTED, + ) + + private fun saveFeedbackEvent( + userId: UUID, + filmId: UUID, + eventType: RecommendationEventType, + ): RecommendationEvent { + userRepository.findById(userId) + ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + filmRepository.findById(filmId) + ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + + val event = + saveEvent( + userId = userId, + filmId = filmId, + eventType = eventType, + score = null, + ) + + log.info( + RECOMMENDATION_FEEDBACK_SAVED_LOG, + userId, + filmId, + eventType, + ) + + return event + } + + private fun saveEvent( + userId: UUID, + filmId: UUID, + eventType: RecommendationEventType, + score: Double?, + ): RecommendationEvent = + recommendationEventRepository.save( + RecommendationEvent( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + eventType = eventType, + score = score, + createdAt = LocalDateTime.now(), + ), + ) + + private fun buildUserProfile( + preferences: UserPreferences?, + ratings: List, + libraryEntries: List, + filmsById: Map, + ): SparseVector { + val profile = MutableSparseVector() + + preferences?.weightedGenres.orEmpty().forEach { (genre, weight) -> + profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT) + } + preferences?.plotTypes.orEmpty().forEach { plotType -> + tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) } + } + preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } + preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) } + preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } + preferences?.contentTypes.orEmpty().forEach { profile.add(feature("type", it.name), PREFERENCE_CONTENT_TYPE_WEIGHT) } + + ratings.forEach { rating -> + val film = filmsById[rating.filmId] ?: return@forEach + val signal = ratingSignal(rating.score) + profile.add(buildFilmVector(film).scale(signal)) + } + + libraryEntries.filterNot { it.isViewed }.forEach { entry -> + val film = filmsById[entry.filmId] ?: return@forEach + profile.add(buildFilmVector(film).scale(LIBRARY_SIGNAL_WEIGHT)) + } + + return profile.toSparseVector() } private fun scoreFilm( film: Film, - mood: String?, - preferences: com.project.movienight.domain.model.UserPreferences?, - hasUserRating: Boolean, - watched: Boolean, + query: RecommendationQuery, + preferences: UserPreferences?, + userProfile: SparseVector, + inLibrary: Boolean, ): RecommendationResult { - var score = 0.0 val reasons = mutableListOf() + val filmVector = buildFilmVector(film) + val preferenceScore = cosineSimilarity(userProfile, filmVector) + val qualityScore = qualityScore(film) + val contextScore = contextScore(film, query, preferences) + val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE + val diversityScore = diversityScore(film, preferences) + val score = + RELEVANCE_WEIGHT * preferenceScore + + QUALITY_WEIGHT * qualityScore + + CONTEXT_WEIGHT * contextScore + + NOVELTY_WEIGHT * noveltyScore + + DIVERSITY_WEIGHT * diversityScore - preferences?.contentTypes?.let { - if (it.isEmpty() || it.contains(film.contentType)) { - score += 2.0 - reasons += "Matches content preference" + if (preferenceScore > STRONG_REASON_THRESHOLD) { + reasons += "Similar to user preferences and rating history" + } + matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> + reasons += "Matches preferred genre: $genre" + } + matchingPeople(film, preferences).take(MAX_REASON_ITEMS).forEach { person -> + reasons += "Matches preferred cast or director: $person" + } + query.mood?.takeIf { inferredMoods(film).contains(normalize(it)) }?.let { mood -> + reasons += "Matches requested mood: $mood" + } + film.releaseYear?.let { year -> + if (preferences?.eras.orEmpty().any { normalize(it) == normalize(decadeOf(year)) }) { + reasons += "Matches preferred era: ${decadeOf(year)}" } } - - preferences?.weightedGenres?.forEach { (genre, weight) -> - if (film.genres.any { it.equals(genre, ignoreCase = true) }) { - score += weight - reasons += "Matches genre $genre" - } + if (qualityScore >= QUALITY_REASON_THRESHOLD) { + reasons += "High rating signal" } - - preferences?.castAndDirectors?.forEach { favorite -> - val found = - film.cast.any { it.equals(favorite, ignoreCase = true) } || - film.directors.any { it.equals(favorite, ignoreCase = true) } - if (found) { - score += 1.5 - reasons += "Matches favorite creator or cast member $favorite" - } - } - - preferences?.moods?.forEach { preferredMood -> - if (mood != null && preferredMood.equals(mood, ignoreCase = true)) { - score += 1.25 - reasons += "Matches requested mood $mood" - } - } - - film.imdbRating?.let { - score += it / 2.0 - reasons += "Strong IMDb signal" - } - - film.platformRating?.let { - score += it - reasons += "Strong platform signal" - } - - if (hasUserRating) { - score += 2.0 - reasons += "User has already rated similar content" - } - - if (watched) { - score -= 3.0 - reasons += "Already watched" - } - - if (mood != null && film.title.contains(mood, ignoreCase = true)) { - score += 0.5 + if (inLibrary) { + reasons += "Already in user library" } if (reasons.isEmpty()) { - reasons += "Baseline recommendation from library catalog" + reasons += "Baseline recommendation from catalog quality" } - return RecommendationResult(film = film, score = score, reasons = reasons) + return RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()) + } + + private fun buildFilmVector(film: Film): SparseVector { + val vector = MutableSparseVector() + val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } + val plotTokens = tokenize("${film.title} ${film.description}") + val moods = inferredMoods(film) + val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() } + + vector.add(feature("type", film.contentType.name), CONTENT_TYPE_VECTOR_WEIGHT) + distribute(vector, "genre", normalizedGenres, GENRE_VECTOR_WEIGHT) + distribute(vector, "plot", plotTokens, PLOT_VECTOR_WEIGHT) + distribute(vector, "mood", moods, MOOD_VECTOR_WEIGHT) + film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), ERA_VECTOR_WEIGHT) } + distribute(vector, "person", people, PEOPLE_VECTOR_WEIGHT) + + return vector.toSparseVector() + } + + private fun contextScore( + film: Film, + query: RecommendationQuery, + preferences: UserPreferences?, + ): Double { + var score = 0.0 + var checks = 0 + + query.mood?.let { + checks += 1 + if (inferredMoods(film).contains(normalize(it))) { + score += 1.0 + } + } + preferences?.contentTypes?.takeIf { it.isNotEmpty() }?.let { + checks += 1 + if (film.contentType in it) { + score += 1.0 + } + } + preferences?.eras?.takeIf { it.isNotEmpty() }?.let { eras -> + film.releaseYear?.let { + checks += 1 + if (eras.any { era -> normalize(era) == normalize(decadeOf(it)) }) { + score += 1.0 + } + } + } + + return if (checks == 0) BASE_CONTEXT_SCORE else score / checks + } + + private fun qualityScore(film: Film): Double { + val normalizedRatings = + listOfNotNull( + film.imdbRating?.let { normalizeRating(it) }, + film.platformRating?.let { normalizeRating(it) }, + ) + return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE + } + + private fun diversityScore( + film: Film, + preferences: UserPreferences?, + ): Double { + val preferredGenres = preferences?.weightedGenres.orEmpty().keys.map(::normalize).toSet() + val filmGenres = film.genres.map(::normalize).toSet() + return when { + preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE + filmGenres.none { it in preferredGenres } -> HIGH_DIVERSITY_SCORE + filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE + else -> LOW_DIVERSITY_SCORE + } + } + + private fun inferredMoods(film: Film): Set { + val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}") + return moodLexicon + .filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } } + .keys + } + + private fun matchingGenres( + film: Film, + preferences: UserPreferences?, + ): List { + val filmGenres = film.genres.associateBy { normalize(it) } + return preferences + ?.weightedGenres + .orEmpty() + .keys + .map(::normalize) + .mapNotNull { filmGenres[it] } + } + + private fun matchingPeople( + film: Film, + preferences: UserPreferences?, + ): List { + val people = (film.cast + film.directors).associateBy { normalize(it) } + return preferences + ?.castAndDirectors + .orEmpty() + .map(::normalize) + .mapNotNull { people[it] } + } + + private fun distribute( + vector: MutableSparseVector, + namespace: String, + values: Collection, + totalWeight: Double, + ) { + val uniqueValues = values.map(::normalize).filter { it.isNotBlank() }.distinct() + if (uniqueValues.isEmpty()) { + return + } + val itemWeight = totalWeight / uniqueValues.size + uniqueValues.forEach { vector.add(feature(namespace, it), itemWeight) } + } + + private fun ratingSignal(score: Int): Double = + when (score.coerceIn(MIN_USER_RATING, MAX_USER_RATING)) { + 10 -> 1.0 + 9 -> 0.9 + 8 -> 0.7 + 7 -> 0.4 + 6 -> 0.1 + 5 -> 0.0 + 4 -> -0.3 + 3 -> -0.5 + 2 -> -0.8 + else -> -1.0 + } + + private fun normalizeRating(rating: Double): Double = (rating / MAX_RATING_VALUE).coerceIn(0.0, 1.0) + + private fun decadeOf(year: Int): String = "${year / 10 * 10}s" + + private fun tokenize(text: String): List = + normalize(text) + .split(tokenSeparatorRegex) + .asSequence() + .filter { it.length >= MIN_TOKEN_LENGTH } + .filterNot { it in stopWords } + .distinct() + .toList() + + private fun feature( + namespace: String, + value: String, + ): String = "$namespace:${normalize(value)}" + + private fun normalize(value: String): String = + value + .trim() + .lowercase(Locale.getDefault()) + + private fun cosineSimilarity( + left: SparseVector, + right: SparseVector, + ): Double { + if (left.values.isEmpty() || right.values.isEmpty()) { + return 0.0 + } + + val dot = + left.values + .entries + .sumOf { (feature, weight) -> weight * (right.values[feature] ?: 0.0) } + val leftNorm = sqrt(left.values.values.sumOf { it * it }) + val rightNorm = sqrt(right.values.values.sumOf { it * it }) + if (leftNorm == 0.0 || rightNorm == 0.0) { + return 0.0 + } + + return dot / (leftNorm * rightNorm) + } + + private fun roundScore(score: Double): Double = kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR + + private fun Iterable.averageOrNull(): Double? { + val values = toList() + return values.takeIf { it.isNotEmpty() }?.average() + } + + private data class SparseVector( + val values: Map, + ) { + fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight }) + } + + private class MutableSparseVector { + private val values = mutableMapOf() + + fun add( + feature: String, + weight: Double, + ) { + if (weight == 0.0) { + return + } + values[feature] = (values[feature] ?: 0.0) + weight + } + + fun add(vector: SparseVector) { + vector.values.forEach { (feature, weight) -> add(feature, weight) } + } + + fun toSparseVector(): SparseVector = SparseVector(values.filterValues { it != 0.0 }) + } + + private companion object { + private const val RECOMMENDATION_COMPLETED_LOG = + "Recommendation request completed: userId='{}', contentType='{}', moodPresent={}, " + + "libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}" + private const val RECOMMENDATION_FEEDBACK_SAVED_LOG = + "Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'" + + private const val MAX_PREFERENCE_WEIGHT = 5.0 + private const val MAX_RATING_VALUE = 10.0 + private const val MIN_USER_RATING = 1 + private const val MAX_USER_RATING = 10 + private const val MIN_TOKEN_LENGTH = 3 + private const val MAX_REASON_ITEMS = 2 + private const val SCORE_ROUNDING_FACTOR = 1000.0 + + private const val CONTENT_TYPE_VECTOR_WEIGHT = 0.05 + private const val GENRE_VECTOR_WEIGHT = 0.25 + private const val PLOT_VECTOR_WEIGHT = 0.35 + private const val MOOD_VECTOR_WEIGHT = 0.15 + private const val ERA_VECTOR_WEIGHT = 0.10 + private const val PEOPLE_VECTOR_WEIGHT = 0.10 + + private const val PREFERENCE_PLOT_WEIGHT = 0.6 + private const val PREFERENCE_ERA_WEIGHT = 0.7 + private const val PREFERENCE_PERSON_WEIGHT = 0.8 + private const val PREFERENCE_MOOD_WEIGHT = 0.8 + private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 + private const val LIBRARY_SIGNAL_WEIGHT = 0.25 + + private const val RELEVANCE_WEIGHT = 0.55 + private const val QUALITY_WEIGHT = 0.15 + private const val CONTEXT_WEIGHT = 0.10 + private const val NOVELTY_WEIGHT = 0.10 + private const val DIVERSITY_WEIGHT = 0.10 + + private const val LIBRARY_NOVELTY_SCORE = 0.85 + private const val CATALOG_NOVELTY_SCORE = 0.65 + private const val BASE_CONTEXT_SCORE = 0.5 + private const val BASE_QUALITY_SCORE = 0.5 + private const val BASE_DIVERSITY_SCORE = 0.5 + private const val HIGH_DIVERSITY_SCORE = 1.0 + private const val MEDIUM_DIVERSITY_SCORE = 0.6 + private const val LOW_DIVERSITY_SCORE = 0.3 + private const val STRONG_REASON_THRESHOLD = 0.15 + private const val QUALITY_REASON_THRESHOLD = 0.75 + + private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") + private val stopWords = + setOf( + "and", + "the", + "for", + "with", + "about", + "into", + "from", + ) + private val moodLexicon = + mapOf( + "tense" to listOf("thriller", "suspense", "tension", "rescue", "crime"), + "slow-burn" to listOf("slow", "meditative", "grounded"), + "feel-good" to listOf("comedy", "family", "summer", "kind", "warm"), + "dark" to listOf("dark", "noir", "horror", "murder", "crime"), + "romantic" to listOf("romance", "love", "relationship"), + "focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"), + ) } } diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt index 5a59400..a4dc555 100644 --- a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties data class JellyfinIntegrationProperties( val enabled: Boolean = false, val baseUrl: String = "", + val webUrl: String = "", val apiKey: String = "", val syncIntervalMs: Long = 1_800_000, val requestTimeoutMs: Long = 20_000, diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt index 1049513..142754a 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt @@ -6,6 +6,7 @@ data class RecommendationContext( val userId: UUID, val contentType: ContentType? = null, val mood: String? = null, + val libraryOnly: Boolean = false, val limit: Int = 10, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt new file mode 100644 index 0000000..aff907d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt @@ -0,0 +1,19 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class RecommendationEvent( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val eventType: RecommendationEventType, + val score: Double? = null, + val createdAt: LocalDateTime = LocalDateTime.now(), +) + +enum class RecommendationEventType { + RECOMMENDED, + ACCEPTED, + REJECTED, +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9f562dc..c2af423 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -99,6 +99,7 @@ integrations: jellyfin: enabled: ${JELLYFIN_SYNC_ENABLED:false} base-url: ${JELLYFIN_BASE_URL:} + web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} api-key: ${JELLYFIN_API_KEY:} sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} diff --git a/src/main/resources/db/migration/V7__recommendation_events.sql b/src/main/resources/db/migration/V7__recommendation_events.sql new file mode 100644 index 0000000..3ecd8ba --- /dev/null +++ b/src/main/resources/db/migration/V7__recommendation_events.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS public.recommendation_events ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + event_type VARCHAR(64) NOT NULL, + score DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT recommendation_events_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT recommendation_events_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_created + ON public.recommendation_events(user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_film + ON public.recommendation_events(film_id); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_type + ON public.recommendation_events(event_type); diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index a7ab288..25f0211 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -76,6 +76,7 @@ class RecommendationSmokeTest { imdbRating = 8.7, platformRating = 9.0, externalUrl = "https://example.com/orbital-drift", + jellyfinItemId = "orbital-drift-item", ), ) }.andExpect { @@ -154,11 +155,43 @@ class RecommendationSmokeTest { param("limit", "2") }.andExpect { status { isOk() } + jsonPath("$[0].filmId") { value(firstFilmId.toString()) } jsonPath("$[0].film.id") { value(firstFilmId.toString()) } + jsonPath("$[0].watchUrl") { + value("https://jellyfin.example.test/web/#/details?id=orbital-drift-item") + } + jsonPath("$[0].reasons[0]") { exists() } + } + + mockMvc + .post("/api/users/$userId/recommendations/$firstFilmId/accept") + .andExpect { + status { isOk() } + jsonPath("$.filmId") { value(firstFilmId.toString()) } + jsonPath("$.eventType") { value("ACCEPTED") } + } + + mockMvc + .post("/api/users/$userId/recommendations/$firstFilmId/reject") + .andExpect { + status { isOk() } + jsonPath("$.filmId") { value(firstFilmId.toString()) } + jsonPath("$.eventType") { value("REJECTED") } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("libraryOnly", "true") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$") { isEmpty() } } } private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM recommendation_events") jdbcTemplate.execute("DELETE FROM film_ratings") jdbcTemplate.execute("DELETE FROM user_preferences") jdbcTemplate.execute("DELETE FROM favorites") diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 9aabcb5..03e6517 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -26,3 +26,7 @@ services: - censored - epstein - python + +integrations: + jellyfin: + web-url: https://jellyfin.example.test -- 2.54.0 From a615450896afc0c332282fe32cb058b0f74fab5f Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 21 May 2026 14:57:41 +0300 Subject: [PATCH 70/77] =?UTF-8?q?=D0=9F=D0=BE=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D1=80=D0=BE=D0=B1=D0=BB=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/web/RecommendationController.kt | 31 ++++++++++--------- .../services/RecommendationService.kt | 23 +++++++++++--- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 5f306b0..6d5bdd9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -5,9 +5,9 @@ import com.project.movienight.adapters.web.dto.response.RecommendationResponse import com.project.movienight.application.ports.input.AcceptRecommendationCommand import com.project.movienight.application.ports.input.AcceptRecommendationUseCase import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.input.RejectRecommendationCommand import com.project.movienight.application.ports.input.RejectRecommendationUseCase -import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType import org.springframework.web.bind.annotation.GetMapping @@ -36,20 +36,21 @@ class RecommendationController( @RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean, @RequestParam(required = false, defaultValue = "10") limit: Int, ): List = - getRecommendationsUseCase.recommend( - RecommendationQuery( - userId = userId, - contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() }, - mood = mood, - libraryOnly = libraryOnly, - limit = limit, - ), - ).map { recommendation -> - RecommendationResponse.fromDomain( - recommendation = recommendation, - watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), - ) - } + getRecommendationsUseCase + .recommend( + RecommendationQuery( + userId = userId, + contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() }, + mood = mood, + libraryOnly = libraryOnly, + limit = limit, + ), + ).map { recommendation -> + RecommendationResponse.fromDomain( + recommendation = recommendation, + watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), + ) + } @PostMapping("/{filmId}/accept") fun accept( diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index 1f9b104..cff99d2 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -4,9 +4,9 @@ import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AcceptRecommendationCommand import com.project.movienight.application.ports.input.AcceptRecommendationUseCase import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.input.RejectRecommendationCommand import com.project.movienight.application.ports.input.RejectRecommendationUseCase -import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort @@ -179,7 +179,15 @@ class RecommendationService( preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) } preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } - preferences?.contentTypes.orEmpty().forEach { profile.add(feature("type", it.name), PREFERENCE_CONTENT_TYPE_WEIGHT) } + preferences + ?.contentTypes + .orEmpty() + .forEach { + profile.add( + feature("type", it.name), + PREFERENCE_CONTENT_TYPE_WEIGHT, + ) + } ratings.forEach { rating -> val film = filmsById[rating.filmId] ?: return@forEach @@ -309,7 +317,13 @@ class RecommendationService( film: Film, preferences: UserPreferences?, ): Double { - val preferredGenres = preferences?.weightedGenres.orEmpty().keys.map(::normalize).toSet() + val preferredGenres = + preferences + ?.weightedGenres + .orEmpty() + .keys + .map(::normalize) + .toSet() val filmGenres = film.genres.map(::normalize).toSet() return when { preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE @@ -423,7 +437,8 @@ class RecommendationService( return dot / (leftNorm * rightNorm) } - private fun roundScore(score: Double): Double = kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR + private fun roundScore(score: Double): Double = + kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR private fun Iterable.averageOrNull(): Double? { val values = toList() -- 2.54.0 From 0dca1f70318347eb409cbfdb042466d68730cc92 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 21 May 2026 17:03:33 +0300 Subject: [PATCH 71/77] =?UTF-8?q?-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=81=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B5=D0=B9=20=D0=B8=20=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC=D0=B8=20-=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20user=5Frecommendation=5Fweights=20=D0=B8=20breakdown-=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5=D0=B9=20recommendation=5Fevents=20-=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=D1=8E=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B5=20score/vector=20=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20-=20feedback=20ACCEPTED/REJECTED=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20score-=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=D0=B4=D0=BD=D0=B5=D0=B9=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B8=20-=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20API=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D1=80=D1=83?= =?UTF-8?q?=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B9=20-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20onboarding=20endpoint=20=D0=B4=D0=BB=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B1=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20?= =?UTF-8?q?-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9:=20balanced,=20quali?= =?UTF-8?q?ty=20first,=20mood=20first,=20discovery,=20similar=20to=20favor?= =?UTF-8?q?ites=20-=20onboarding=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BB=D0=B0=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8/=D0=B4=D0=B8=D0=B7=D0=BB=D0=B0=D0=B9=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA=D1=83,?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=84=D0=B8=D0=BB=D1=8C=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=BE=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=81=D0=B0=20-=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?smoke/unit=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/BusinessMetricsService.kt | 11 +- .../jdbc/RecommendationEventRepository.kt | 53 +++- .../UserRecommendationWeightsRepository.kt | 130 ++++++++++ .../web/RecommendationOnboardingController.kt | 52 ++++ .../UserRecommendationWeightsController.kt | 53 ++++ .../RecommendationOnboardingRequest.kt | 17 ++ .../UpdateUserRecommendationWeightsRequest.kt | 15 ++ .../response/RecommendationEventResponse.kt | 10 + .../RecommendationOnboardingResponse.kt | 27 ++ .../UserRecommendationWeightsResponse.kt | 40 +++ .../input/RecommendationOnboardingUseCase.kt | 36 +++ .../input/UserRecommendationWeightsUseCase.kt | 27 ++ .../RecommendationEventRepositoryPort.kt | 5 + ...UserRecommendationWeightsRepositoryPort.kt | 10 + .../RecommendationOnboardingService.kt | 150 +++++++++++ .../services/RecommendationService.kt | 219 ++++++++++++---- .../UserRecommendationWeightsService.kt | 51 ++++ .../domain/model/RecommendationEvent.kt | 5 + .../domain/model/RecommendationStyle.kt | 9 + .../domain/model/UserRecommendationWeights.kt | 233 ++++++++++++++++++ .../V8__user_recommendation_weights.sql | 35 +++ .../movienight/RecommendationSmokeTest.kt | 208 ++++++++++++++++ .../model/UserRecommendationWeightsTest.kt | 74 ++++++ 23 files changed, 1425 insertions(+), 45 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt create mode 100644 src/main/resources/db/migration/V8__user_recommendation_weights.sql create mode 100644 src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index 6a8f2d0..b3912f1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -1,6 +1,7 @@ package com.project.movienight.adapters.metrics import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.RecommendationEventType import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.Timer @@ -9,7 +10,7 @@ import java.util.concurrent.atomic.AtomicInteger @Service class BusinessMetricsService( - meterRegistry: MeterRegistry, + private val meterRegistry: MeterRegistry, ) { private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total") private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total") @@ -36,6 +37,14 @@ class BusinessMetricsService( recommendationRequests.increment() } + fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) { + Counter + .builder("recommendation_weights_updated_total") + .tag("eventType", eventType.name) + .register(meterRegistry) + .increment() + } + fun recordRatingSubmitted() { ratingsSubmitted.increment() } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt index 339c05c..ab0f0f8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt @@ -19,6 +19,11 @@ class RecommendationEventRepository( filmId = UUID.fromString(rs.getString("film_id")), eventType = RecommendationEventType.valueOf(rs.getString("event_type")), score = rs.getObject("score")?.let { (it as Number).toDouble() }, + relevanceScore = rs.getObject("relevance_score")?.let { (it as Number).toDouble() }, + qualityScore = rs.getObject("quality_score")?.let { (it as Number).toDouble() }, + contextScore = rs.getObject("context_score")?.let { (it as Number).toDouble() }, + noveltyScore = rs.getObject("novelty_score")?.let { (it as Number).toDouble() }, + diversityScore = rs.getObject("diversity_score")?.let { (it as Number).toDouble() }, createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } @@ -32,15 +37,25 @@ class RecommendationEventRepository( film_id, event_type, score, + relevance_score, + quality_score, + context_score, + novelty_score, + diversity_score, created_at ) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), event.id, event.userId, event.filmId, event.eventType.name, event.score, + event.relevanceScore, + event.qualityScore, + event.contextScore, + event.noveltyScore, + event.diversityScore, event.createdAt, ) return event @@ -54,6 +69,11 @@ class RecommendationEventRepository( film_id, event_type, score, + relevance_score, + quality_score, + context_score, + novelty_score, + diversity_score, created_at FROM recommendation_events WHERE user_id = ? @@ -62,4 +82,35 @@ class RecommendationEventRepository( rowMapper, userId, ) + + override fun findLatestRecommended( + userId: UUID, + filmId: UUID, + ): RecommendationEvent? = + jdbc + .query( + """ + SELECT id, + user_id, + film_id, + event_type, + score, + relevance_score, + quality_score, + context_score, + novelty_score, + diversity_score, + created_at + FROM recommendation_events + WHERE user_id = ? + AND film_id = ? + AND event_type = ? + ORDER BY created_at DESC + LIMIT 1 + """.trimIndent(), + rowMapper, + userId, + filmId, + RecommendationEventType.RECOMMENDED.name, + ).firstOrNull() } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt new file mode 100644 index 0000000..c549925 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt @@ -0,0 +1,130 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort +import com.project.movienight.domain.model.UserRecommendationWeights +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.time.LocalDateTime +import java.util.UUID + +@Repository +class UserRecommendationWeightsRepository( + private val jdbc: JdbcTemplate, +) : UserRecommendationWeightsRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + UserRecommendationWeights( + userId = UUID.fromString(rs.getString("user_id")), + relevanceWeight = rs.getDouble("relevance_weight"), + qualityWeight = rs.getDouble("quality_weight"), + contextWeight = rs.getDouble("context_weight"), + noveltyWeight = rs.getDouble("novelty_weight"), + diversityWeight = rs.getDouble("diversity_weight"), + genreVectorWeight = rs.getDouble("genre_vector_weight"), + plotVectorWeight = rs.getDouble("plot_vector_weight"), + moodVectorWeight = rs.getDouble("mood_vector_weight"), + eraVectorWeight = rs.getDouble("era_vector_weight"), + peopleVectorWeight = rs.getDouble("people_vector_weight"), + contentTypeVectorWeight = rs.getDouble("content_type_vector_weight"), + updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(), + ) + } + + override fun findByUserId(userId: UUID): UserRecommendationWeights? = + jdbc + .query( + """ + SELECT user_id, + relevance_weight, + quality_weight, + context_weight, + novelty_weight, + diversity_weight, + genre_vector_weight, + plot_vector_weight, + mood_vector_weight, + era_vector_weight, + people_vector_weight, + content_type_vector_weight, + updated_at + FROM user_recommendation_weights + WHERE user_id = ? + """.trimIndent(), + rowMapper, + userId, + ).firstOrNull() + + override fun save(weights: UserRecommendationWeights): UserRecommendationWeights { + val normalized = weights.normalized(updatedAt = LocalDateTime.now()) + val updatedRows = + jdbc.update( + """ + UPDATE user_recommendation_weights + SET relevance_weight = ?, + quality_weight = ?, + context_weight = ?, + novelty_weight = ?, + diversity_weight = ?, + genre_vector_weight = ?, + plot_vector_weight = ?, + mood_vector_weight = ?, + era_vector_weight = ?, + people_vector_weight = ?, + content_type_vector_weight = ?, + updated_at = ? + WHERE user_id = ? + """.trimIndent(), + normalized.relevanceWeight, + normalized.qualityWeight, + normalized.contextWeight, + normalized.noveltyWeight, + normalized.diversityWeight, + normalized.genreVectorWeight, + normalized.plotVectorWeight, + normalized.moodVectorWeight, + normalized.eraVectorWeight, + normalized.peopleVectorWeight, + normalized.contentTypeVectorWeight, + normalized.updatedAt, + normalized.userId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO user_recommendation_weights ( + user_id, + relevance_weight, + quality_weight, + context_weight, + novelty_weight, + diversity_weight, + genre_vector_weight, + plot_vector_weight, + mood_vector_weight, + era_vector_weight, + people_vector_weight, + content_type_vector_weight, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + normalized.userId, + normalized.relevanceWeight, + normalized.qualityWeight, + normalized.contextWeight, + normalized.noveltyWeight, + normalized.diversityWeight, + normalized.genreVectorWeight, + normalized.plotVectorWeight, + normalized.moodVectorWeight, + normalized.eraVectorWeight, + normalized.peopleVectorWeight, + normalized.contentTypeVectorWeight, + normalized.updatedAt, + ) + } + + return normalized + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt new file mode 100644 index 0000000..0ff38f6 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt @@ -0,0 +1,52 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest +import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationStyle +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.Locale +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/recommendation-onboarding") +class RecommendationOnboardingController( + private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase, +) { + @PostMapping + fun complete( + @PathVariable userId: UUID, + @RequestBody request: RecommendationOnboardingRequest, + ): RecommendationOnboardingResponse = + RecommendationOnboardingResponse.fromApplication( + completeRecommendationOnboardingUseCase.complete( + CompleteRecommendationOnboardingCommand( + userId = userId, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = request.contentTypes.mapNotNull(::parseContentType), + likedFilmIds = request.likedFilmIds, + dislikedFilmIds = request.dislikedFilmIds, + libraryFilmIds = request.libraryFilmIds, + watchedFilmIds = request.watchedFilmIds, + recommendationStyle = parseRecommendationStyle(request.recommendationStyle), + ), + ), + ) + + private fun parseContentType(value: String): ContentType? = + runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull() + + private fun parseRecommendationStyle(value: String): RecommendationStyle = + runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) } + .getOrDefault(RecommendationStyle.BALANCED) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt new file mode 100644 index 0000000..4c5dc1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt @@ -0,0 +1,53 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest +import com.project.movienight.adapters.web.dto.response.UserRecommendationWeightsResponse +import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase +import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand +import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/recommendation-weights") +class UserRecommendationWeightsController( + private val getUserRecommendationWeightsUseCase: GetUserRecommendationWeightsUseCase, + private val updateUserRecommendationWeightsUseCase: UpdateUserRecommendationWeightsUseCase, +) { + @GetMapping + fun get( + @PathVariable userId: UUID, + ): UserRecommendationWeightsResponse = + UserRecommendationWeightsResponse.fromDomain( + getUserRecommendationWeightsUseCase.get(userId), + ) + + @PutMapping + fun update( + @PathVariable userId: UUID, + @RequestBody request: UpdateUserRecommendationWeightsRequest, + ): UserRecommendationWeightsResponse = + UserRecommendationWeightsResponse.fromDomain( + updateUserRecommendationWeightsUseCase.update( + UpdateUserRecommendationWeightsCommand( + userId = userId, + relevanceWeight = request.relevanceWeight, + qualityWeight = request.qualityWeight, + contextWeight = request.contextWeight, + noveltyWeight = request.noveltyWeight, + diversityWeight = request.diversityWeight, + genreVectorWeight = request.genreVectorWeight, + plotVectorWeight = request.plotVectorWeight, + moodVectorWeight = request.moodVectorWeight, + eraVectorWeight = request.eraVectorWeight, + peopleVectorWeight = request.peopleVectorWeight, + contentTypeVectorWeight = request.contentTypeVectorWeight, + ), + ), + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt new file mode 100644 index 0000000..0480531 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt @@ -0,0 +1,17 @@ +package com.project.movienight.adapters.web.dto.request + +import java.util.UUID + +data class RecommendationOnboardingRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), + val likedFilmIds: List = emptyList(), + val dislikedFilmIds: List = emptyList(), + val libraryFilmIds: List = emptyList(), + val watchedFilmIds: List = emptyList(), + val recommendationStyle: String = "BALANCED", +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt new file mode 100644 index 0000000..3c0846b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt @@ -0,0 +1,15 @@ +package com.project.movienight.adapters.web.dto.request + +data class UpdateUserRecommendationWeightsRequest( + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt index 2b90ef8..4fc12ca 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt @@ -11,6 +11,11 @@ data class RecommendationEventResponse( val filmId: UUID, val eventType: RecommendationEventType, val score: Double?, + val relevanceScore: Double?, + val qualityScore: Double?, + val contextScore: Double?, + val noveltyScore: Double?, + val diversityScore: Double?, val createdAt: LocalDateTime, ) { companion object { @@ -21,6 +26,11 @@ data class RecommendationEventResponse( filmId = event.filmId, eventType = event.eventType, score = event.score, + relevanceScore = event.relevanceScore, + qualityScore = event.qualityScore, + contextScore = event.contextScore, + noveltyScore = event.noveltyScore, + diversityScore = event.diversityScore, createdAt = event.createdAt, ) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt new file mode 100644 index 0000000..6cd2810 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt @@ -0,0 +1,27 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.application.ports.input.RecommendationOnboardingResult +import java.util.UUID + +data class RecommendationOnboardingResponse( + val userId: UUID, + val preferences: UserPreferencesResponse, + val weights: UserRecommendationWeightsResponse, + val likedFilmsCount: Int, + val dislikedFilmsCount: Int, + val libraryFilmsCount: Int, + val watchedFilmsCount: Int, +) { + companion object { + fun fromApplication(result: RecommendationOnboardingResult): RecommendationOnboardingResponse = + RecommendationOnboardingResponse( + userId = result.userId, + preferences = UserPreferencesResponse.fromDomain(result.preferences), + weights = UserRecommendationWeightsResponse.fromDomain(result.weights), + likedFilmsCount = result.likedFilmsCount, + dislikedFilmsCount = result.dislikedFilmsCount, + libraryFilmsCount = result.libraryFilmsCount, + watchedFilmsCount = result.watchedFilmsCount, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt new file mode 100644 index 0000000..0b22037 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt @@ -0,0 +1,40 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.time.LocalDateTime +import java.util.UUID + +data class UserRecommendationWeightsResponse( + val userId: UUID, + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, + val updatedAt: LocalDateTime, +) { + companion object { + fun fromDomain(weights: UserRecommendationWeights): UserRecommendationWeightsResponse = + UserRecommendationWeightsResponse( + userId = weights.userId, + relevanceWeight = weights.relevanceWeight, + qualityWeight = weights.qualityWeight, + contextWeight = weights.contextWeight, + noveltyWeight = weights.noveltyWeight, + diversityWeight = weights.diversityWeight, + genreVectorWeight = weights.genreVectorWeight, + plotVectorWeight = weights.plotVectorWeight, + moodVectorWeight = weights.moodVectorWeight, + eraVectorWeight = weights.eraVectorWeight, + peopleVectorWeight = weights.peopleVectorWeight, + contentTypeVectorWeight = weights.contentTypeVectorWeight, + updatedAt = weights.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt new file mode 100644 index 0000000..0d54caf --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt @@ -0,0 +1,36 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationStyle +import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface CompleteRecommendationOnboardingUseCase { + fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult +} + +data class CompleteRecommendationOnboardingCommand( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), + val likedFilmIds: List = emptyList(), + val dislikedFilmIds: List = emptyList(), + val libraryFilmIds: List = emptyList(), + val watchedFilmIds: List = emptyList(), + val recommendationStyle: RecommendationStyle = RecommendationStyle.BALANCED, +) + +data class RecommendationOnboardingResult( + val userId: UUID, + val preferences: UserPreferences, + val weights: UserRecommendationWeights, + val likedFilmsCount: Int, + val dislikedFilmsCount: Int, + val libraryFilmsCount: Int, + val watchedFilmsCount: Int, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt new file mode 100644 index 0000000..9bfaa6b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt @@ -0,0 +1,27 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface GetUserRecommendationWeightsUseCase { + fun get(userId: UUID): UserRecommendationWeights +} + +interface UpdateUserRecommendationWeightsUseCase { + fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights +} + +data class UpdateUserRecommendationWeightsCommand( + val userId: UUID, + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt index aaf37f6..5903a4c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt @@ -7,4 +7,9 @@ interface RecommendationEventRepositoryPort { fun save(event: RecommendationEvent): RecommendationEvent fun findByUserId(userId: UUID): List + + fun findLatestRecommended( + userId: UUID, + filmId: UUID, + ): RecommendationEvent? } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt new file mode 100644 index 0000000..f4bade2 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface UserRecommendationWeightsRepositoryPort { + fun findByUserId(userId: UUID): UserRecommendationWeights? + + fun save(weights: UserRecommendationWeights): UserRecommendationWeights +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt new file mode 100644 index 0000000..8f9fe04 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt @@ -0,0 +1,150 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase +import com.project.movienight.application.ports.input.RecommendationOnboardingResult +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmRating +import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.UUID + +@Service +class RecommendationOnboardingService( + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, + private val idGenerator: IdGenerator, +) : CompleteRecommendationOnboardingUseCase { + override fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult { + userRepository.findById(command.userId) + ?: throw EntityNotFoundException(entity = "User", id = command.userId.toString()) + + val filmIds = + ( + command.likedFilmIds + + command.dislikedFilmIds + + command.libraryFilmIds + + command.watchedFilmIds + ).distinct() + ensureFilmsExist(filmIds) + + val preferences = + userPreferencesRepository.save( + UserPreferences( + userId = command.userId, + weightedGenres = command.weightedGenres, + plotTypes = command.plotTypes, + eras = command.eras, + castAndDirectors = command.castAndDirectors, + moods = command.moods, + contentTypes = command.contentTypes, + ), + ) + + command.likedFilmIds.distinct().forEach { filmId -> + saveRating(userId = command.userId, filmId = filmId, score = LIKED_SCORE, note = ONBOARDING_LIKED_NOTE) + } + command.dislikedFilmIds.distinct().forEach { filmId -> + saveRating( + userId = command.userId, + filmId = filmId, + score = DISLIKED_SCORE, + note = ONBOARDING_DISLIKED_NOTE, + ) + } + command.libraryFilmIds.distinct().forEach { filmId -> + saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = false) + } + command.watchedFilmIds.distinct().forEach { filmId -> + saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = true) + } + + val weights = + userRecommendationWeightsRepository.save( + UserRecommendationWeights.forStyle( + userId = command.userId, + style = command.recommendationStyle, + ), + ) + + return RecommendationOnboardingResult( + userId = command.userId, + preferences = preferences, + weights = weights, + likedFilmsCount = command.likedFilmIds.distinct().size, + dislikedFilmsCount = command.dislikedFilmIds.distinct().size, + libraryFilmsCount = command.libraryFilmIds.distinct().size, + watchedFilmsCount = command.watchedFilmIds.distinct().size, + ) + } + + private fun ensureFilmsExist(filmIds: List) { + filmIds.forEach { filmId -> + filmRepository.findById(filmId) + ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + } + } + + private fun saveRating( + userId: UUID, + filmId: UUID, + score: Int, + note: String, + ): FilmRating { + val now = LocalDateTime.now() + val existing = filmRatingRepository.findByUserIdAndFilmId(userId, filmId) + return filmRatingRepository.save( + existing?.copy(score = score, note = note, updatedAt = now) + ?: FilmRating( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = now, + updatedAt = now, + ), + ) + } + + private fun saveLibraryEntry( + userId: UUID, + filmId: UUID, + isViewed: Boolean, + ): FilmLibrary { + val watchedAt = LocalDateTime.now().takeIf { isViewed } + val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId) + return filmLibraryRepository.save( + existing?.copy(isViewed = isViewed, watchedAt = watchedAt) + ?: FilmLibrary( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + comment = null, + isViewed = isViewed, + watchedAt = watchedAt, + ), + ) + } + + private companion object { + private const val LIKED_SCORE = 10 + private const val DISLIKED_SCORE = 2 + private const val ONBOARDING_LIKED_NOTE = "Onboarding liked" + private const val ONBOARDING_DISLIKED_NOTE = "Onboarding disliked" + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index cff99d2..e8a7722 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -13,6 +13,7 @@ import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film @@ -22,6 +23,7 @@ import com.project.movienight.domain.model.RecommendationEvent import com.project.movienight.domain.model.RecommendationEventType import com.project.movienight.domain.model.RecommendationResult import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -37,6 +39,7 @@ class RecommendationService( private val userPreferencesRepository: UserPreferencesRepositoryPort, private val userRepository: UserRepositoryPort, private val recommendationEventRepository: RecommendationEventRepositoryPort, + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, private val idGenerator: IdGenerator, private val businessMetricsService: BusinessMetricsService, ) : GetRecommendationsUseCase, @@ -56,7 +59,8 @@ class RecommendationService( val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet() val films = filmRepository.findAll() val filmsById = films.associateBy { it.id } - val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById) + val weights = findWeights(query.userId) + val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights) val candidates = films @@ -65,20 +69,30 @@ class RecommendationService( .filter { film -> film.id !in watchedFilmIds } .filter { film -> !query.libraryOnly || film.id in libraryFilmIds } .toList() - val recommendations = - candidates - .asSequence() - .map { film -> scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds) } - .sortedWith(compareByDescending { it.score }.thenBy { it.film.title }) + val scoredCandidates = + candidates.map { film -> + scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights) + } + val recommendationComparator = + compareByDescending { it.result.score }.thenBy { + it.result.film.title + } + val scoredRecommendations = + scoredCandidates + .sortedWith(recommendationComparator) .take(query.limit.coerceAtLeast(1)) - .toList() - recommendations.forEach { recommendation -> + scoredRecommendations.forEach { recommendation -> saveEvent( userId = query.userId, - filmId = recommendation.film.id, + filmId = recommendation.result.film.id, eventType = RecommendationEventType.RECOMMENDED, - score = recommendation.score, + score = recommendation.result.score, + relevanceScore = recommendation.relevanceScore, + qualityScore = recommendation.qualityScore, + contextScore = recommendation.contextScore, + noveltyScore = recommendation.noveltyScore, + diversityScore = recommendation.diversityScore, ) } @@ -90,17 +104,17 @@ class RecommendationService( query.libraryOnly, query.limit, candidates.size, - recommendations.size, + scoredRecommendations.size, ) if (log.isDebugEnabled) { log.debug( "Recommendation top results: userId='{}', results='{}'", query.userId, - recommendations.joinToString(separator = ",") { "${it.film.id}:${it.score}" }, + scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" }, ) } - return recommendations + return scoredRecommendations.map { it.result } } override fun accept(command: AcceptRecommendationCommand): RecommendationEvent = @@ -127,14 +141,35 @@ class RecommendationService( filmRepository.findById(filmId) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId) val event = saveEvent( userId = userId, filmId = filmId, eventType = eventType, - score = null, + score = lastRecommendation?.score, + relevanceScore = lastRecommendation?.relevanceScore, + qualityScore = lastRecommendation?.qualityScore, + contextScore = lastRecommendation?.contextScore, + noveltyScore = lastRecommendation?.noveltyScore, + diversityScore = lastRecommendation?.diversityScore, ) + if (lastRecommendation != null) { + updateRecommendationWeights( + userId = userId, + eventType = eventType, + recommendation = lastRecommendation, + ) + } else { + log.info( + "Recommendation feedback saved without weight update: userId='{}', filmId='{}', eventType='{}'", + userId, + filmId, + eventType, + ) + } + log.info( RECOMMENDATION_FEEDBACK_SAVED_LOG, userId, @@ -150,6 +185,11 @@ class RecommendationService( filmId: UUID, eventType: RecommendationEventType, score: Double?, + relevanceScore: Double? = null, + qualityScore: Double? = null, + contextScore: Double? = null, + noveltyScore: Double? = null, + diversityScore: Double? = null, ): RecommendationEvent = recommendationEventRepository.save( RecommendationEvent( @@ -158,15 +198,89 @@ class RecommendationService( filmId = filmId, eventType = eventType, score = score, + relevanceScore = relevanceScore, + qualityScore = qualityScore, + contextScore = contextScore, + noveltyScore = noveltyScore, + diversityScore = diversityScore, createdAt = LocalDateTime.now(), ), ) + private fun findWeights(userId: UUID): UserRecommendationWeights = + ( + userRecommendationWeightsRepository.findByUserId(userId) + ?: UserRecommendationWeights.defaultFor(userId) + ).normalized() + + private fun updateRecommendationWeights( + userId: UUID, + eventType: RecommendationEventType, + recommendation: RecommendationEvent, + ) { + val current = findWeights(userId) + val contributions = scoreContributions(recommendation, current) ?: return + val direction = + when (eventType) { + RecommendationEventType.ACCEPTED -> 1.0 + RecommendationEventType.REJECTED -> -1.0 + RecommendationEventType.RECOMMENDED -> return + } + + val updated = + current + .copy( + relevanceWeight = current.relevanceWeight + direction * LEARNING_RATE * contributions.relevance, + qualityWeight = current.qualityWeight + direction * LEARNING_RATE * contributions.quality, + contextWeight = current.contextWeight + direction * LEARNING_RATE * contributions.context, + noveltyWeight = current.noveltyWeight + direction * LEARNING_RATE * contributions.novelty, + diversityWeight = current.diversityWeight + direction * LEARNING_RATE * contributions.diversity, + ).normalized(updatedAt = LocalDateTime.now()) + + val saved = userRecommendationWeightsRepository.save(updated) + businessMetricsService.recordRecommendationWeightsUpdated(eventType) + log.info( + RECOMMENDATION_WEIGHTS_UPDATED_LOG, + userId, + eventType, + current.hashCode(), + saved.hashCode(), + ) + } + + private fun scoreContributions( + recommendation: RecommendationEvent, + weights: UserRecommendationWeights, + ): ScoreContributions? { + val rawContributions = + listOf( + weights.relevanceWeight to recommendation.relevanceScore, + weights.qualityWeight to recommendation.qualityScore, + weights.contextWeight to recommendation.contextScore, + weights.noveltyWeight to recommendation.noveltyScore, + weights.diversityWeight to recommendation.diversityScore, + ).map { (weight, score) -> + weight * (score?.takeIf { value -> value.isFinite() }?.coerceAtLeast(0.0) ?: 0.0) + } + val total = rawContributions.sum() + if (total <= 0.0) { + return null + } + return ScoreContributions( + relevance = rawContributions[0] / total, + quality = rawContributions[1] / total, + context = rawContributions[2] / total, + novelty = rawContributions[3] / total, + diversity = rawContributions[4] / total, + ) + } + private fun buildUserProfile( preferences: UserPreferences?, ratings: List, libraryEntries: List, filmsById: Map, + weights: UserRecommendationWeights, ): SparseVector { val profile = MutableSparseVector() @@ -192,12 +306,12 @@ class RecommendationService( ratings.forEach { rating -> val film = filmsById[rating.filmId] ?: return@forEach val signal = ratingSignal(rating.score) - profile.add(buildFilmVector(film).scale(signal)) + profile.add(buildFilmVector(film, weights).scale(signal)) } libraryEntries.filterNot { it.isViewed }.forEach { entry -> val film = filmsById[entry.filmId] ?: return@forEach - profile.add(buildFilmVector(film).scale(LIBRARY_SIGNAL_WEIGHT)) + profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) } return profile.toSparseVector() @@ -209,20 +323,21 @@ class RecommendationService( preferences: UserPreferences?, userProfile: SparseVector, inLibrary: Boolean, - ): RecommendationResult { + weights: UserRecommendationWeights, + ): ScoredRecommendation { val reasons = mutableListOf() - val filmVector = buildFilmVector(film) + val filmVector = buildFilmVector(film, weights) val preferenceScore = cosineSimilarity(userProfile, filmVector) val qualityScore = qualityScore(film) val contextScore = contextScore(film, query, preferences) val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE val diversityScore = diversityScore(film, preferences) val score = - RELEVANCE_WEIGHT * preferenceScore + - QUALITY_WEIGHT * qualityScore + - CONTEXT_WEIGHT * contextScore + - NOVELTY_WEIGHT * noveltyScore + - DIVERSITY_WEIGHT * diversityScore + weights.relevanceWeight * preferenceScore + + weights.qualityWeight * qualityScore + + weights.contextWeight * contextScore + + weights.noveltyWeight * noveltyScore + + weights.diversityWeight * diversityScore if (preferenceScore > STRONG_REASON_THRESHOLD) { reasons += "Similar to user preferences and rating history" @@ -252,22 +367,32 @@ class RecommendationService( reasons += "Baseline recommendation from catalog quality" } - return RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()) + return ScoredRecommendation( + result = RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()), + relevanceScore = preferenceScore, + qualityScore = qualityScore, + contextScore = contextScore, + noveltyScore = noveltyScore, + diversityScore = diversityScore, + ) } - private fun buildFilmVector(film: Film): SparseVector { + private fun buildFilmVector( + film: Film, + weights: UserRecommendationWeights, + ): SparseVector { val vector = MutableSparseVector() val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } val plotTokens = tokenize("${film.title} ${film.description}") val moods = inferredMoods(film) val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() } - vector.add(feature("type", film.contentType.name), CONTENT_TYPE_VECTOR_WEIGHT) - distribute(vector, "genre", normalizedGenres, GENRE_VECTOR_WEIGHT) - distribute(vector, "plot", plotTokens, PLOT_VECTOR_WEIGHT) - distribute(vector, "mood", moods, MOOD_VECTOR_WEIGHT) - film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), ERA_VECTOR_WEIGHT) } - distribute(vector, "person", people, PEOPLE_VECTOR_WEIGHT) + vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight) + distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight) + distribute(vector, "plot", plotTokens, weights.plotVectorWeight) + distribute(vector, "mood", moods, weights.moodVectorWeight) + film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) } + distribute(vector, "person", people, weights.peopleVectorWeight) return vector.toSparseVector() } @@ -445,6 +570,23 @@ class RecommendationService( return values.takeIf { it.isNotEmpty() }?.average() } + private data class ScoredRecommendation( + val result: RecommendationResult, + val relevanceScore: Double, + val qualityScore: Double, + val contextScore: Double, + val noveltyScore: Double, + val diversityScore: Double, + ) + + private data class ScoreContributions( + val relevance: Double, + val quality: Double, + val context: Double, + val novelty: Double, + val diversity: Double, + ) + private data class SparseVector( val values: Map, ) { @@ -477,6 +619,8 @@ class RecommendationService( "libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}" private const val RECOMMENDATION_FEEDBACK_SAVED_LOG = "Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'" + private const val RECOMMENDATION_WEIGHTS_UPDATED_LOG = + "Recommendation weights updated: userId='{}', eventType='{}', oldWeightsHash={}, newWeightsHash={}" private const val MAX_PREFERENCE_WEIGHT = 5.0 private const val MAX_RATING_VALUE = 10.0 @@ -486,13 +630,6 @@ class RecommendationService( private const val MAX_REASON_ITEMS = 2 private const val SCORE_ROUNDING_FACTOR = 1000.0 - private const val CONTENT_TYPE_VECTOR_WEIGHT = 0.05 - private const val GENRE_VECTOR_WEIGHT = 0.25 - private const val PLOT_VECTOR_WEIGHT = 0.35 - private const val MOOD_VECTOR_WEIGHT = 0.15 - private const val ERA_VECTOR_WEIGHT = 0.10 - private const val PEOPLE_VECTOR_WEIGHT = 0.10 - private const val PREFERENCE_PLOT_WEIGHT = 0.6 private const val PREFERENCE_ERA_WEIGHT = 0.7 private const val PREFERENCE_PERSON_WEIGHT = 0.8 @@ -500,11 +637,7 @@ class RecommendationService( private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 private const val LIBRARY_SIGNAL_WEIGHT = 0.25 - private const val RELEVANCE_WEIGHT = 0.55 - private const val QUALITY_WEIGHT = 0.15 - private const val CONTEXT_WEIGHT = 0.10 - private const val NOVELTY_WEIGHT = 0.10 - private const val DIVERSITY_WEIGHT = 0.10 + private const val LEARNING_RATE = 0.03 private const val LIBRARY_NOVELTY_SCORE = 0.85 private const val CATALOG_NOVELTY_SCORE = 0.65 diff --git a/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt new file mode 100644 index 0000000..fc30d72 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt @@ -0,0 +1,51 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase +import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand +import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase +import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.UserRecommendationWeights +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class UserRecommendationWeightsService( + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, + private val userRepository: UserRepositoryPort, +) : GetUserRecommendationWeightsUseCase, + UpdateUserRecommendationWeightsUseCase { + override fun get(userId: UUID): UserRecommendationWeights { + ensureUserExists(userId) + return ( + userRecommendationWeightsRepository.findByUserId(userId) + ?: UserRecommendationWeights.defaultFor(userId) + ).normalized() + } + + override fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights { + ensureUserExists(command.userId) + return userRecommendationWeightsRepository.save( + UserRecommendationWeights( + userId = command.userId, + relevanceWeight = command.relevanceWeight, + qualityWeight = command.qualityWeight, + contextWeight = command.contextWeight, + noveltyWeight = command.noveltyWeight, + diversityWeight = command.diversityWeight, + genreVectorWeight = command.genreVectorWeight, + plotVectorWeight = command.plotVectorWeight, + moodVectorWeight = command.moodVectorWeight, + eraVectorWeight = command.eraVectorWeight, + peopleVectorWeight = command.peopleVectorWeight, + contentTypeVectorWeight = command.contentTypeVectorWeight, + ), + ) + } + + private fun ensureUserExists(userId: UUID) { + userRepository.findById(userId) + ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + } +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt index aff907d..3549398 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt @@ -9,6 +9,11 @@ data class RecommendationEvent( val filmId: UUID, val eventType: RecommendationEventType, val score: Double? = null, + val relevanceScore: Double? = null, + val qualityScore: Double? = null, + val contextScore: Double? = null, + val noveltyScore: Double? = null, + val diversityScore: Double? = null, val createdAt: LocalDateTime = LocalDateTime.now(), ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt new file mode 100644 index 0000000..fda5b1d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt @@ -0,0 +1,9 @@ +package com.project.movienight.domain.model + +enum class RecommendationStyle { + BALANCED, + QUALITY_FIRST, + MOOD_FIRST, + DISCOVERY, + SIMILAR_TO_FAVORITES, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt new file mode 100644 index 0000000..ebc8635 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt @@ -0,0 +1,233 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class UserRecommendationWeights( + val userId: UUID, + val relevanceWeight: Double = DEFAULT_RELEVANCE_WEIGHT, + val qualityWeight: Double = DEFAULT_QUALITY_WEIGHT, + val contextWeight: Double = DEFAULT_CONTEXT_WEIGHT, + val noveltyWeight: Double = DEFAULT_NOVELTY_WEIGHT, + val diversityWeight: Double = DEFAULT_DIVERSITY_WEIGHT, + val genreVectorWeight: Double = DEFAULT_GENRE_VECTOR_WEIGHT, + val plotVectorWeight: Double = DEFAULT_PLOT_VECTOR_WEIGHT, + val moodVectorWeight: Double = DEFAULT_MOOD_VECTOR_WEIGHT, + val eraVectorWeight: Double = DEFAULT_ERA_VECTOR_WEIGHT, + val peopleVectorWeight: Double = DEFAULT_PEOPLE_VECTOR_WEIGHT, + val contentTypeVectorWeight: Double = DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT, + val updatedAt: LocalDateTime = LocalDateTime.now(), +) { + fun normalized(updatedAt: LocalDateTime = this.updatedAt): UserRecommendationWeights { + val scoreWeights = + normalizeBounded( + values = + listOf( + relevanceWeight, + qualityWeight, + contextWeight, + noveltyWeight, + diversityWeight, + ), + defaults = DEFAULT_SCORE_WEIGHTS, + min = MIN_SCORE_WEIGHT, + max = MAX_SCORE_WEIGHT, + ) + val vectorWeights = + normalizeBounded( + values = + listOf( + genreVectorWeight, + plotVectorWeight, + moodVectorWeight, + eraVectorWeight, + peopleVectorWeight, + contentTypeVectorWeight, + ), + defaults = DEFAULT_VECTOR_WEIGHTS, + min = MIN_VECTOR_WEIGHT, + max = MAX_VECTOR_WEIGHT, + ) + + return copy( + relevanceWeight = scoreWeights[0], + qualityWeight = scoreWeights[1], + contextWeight = scoreWeights[2], + noveltyWeight = scoreWeights[3], + diversityWeight = scoreWeights[4], + genreVectorWeight = vectorWeights[0], + plotVectorWeight = vectorWeights[1], + moodVectorWeight = vectorWeights[2], + eraVectorWeight = vectorWeights[3], + peopleVectorWeight = vectorWeights[4], + contentTypeVectorWeight = vectorWeights[5], + updatedAt = updatedAt, + ) + } + + companion object { + const val DEFAULT_RELEVANCE_WEIGHT = 0.55 + const val DEFAULT_QUALITY_WEIGHT = 0.15 + const val DEFAULT_CONTEXT_WEIGHT = 0.10 + const val DEFAULT_NOVELTY_WEIGHT = 0.10 + const val DEFAULT_DIVERSITY_WEIGHT = 0.10 + + const val DEFAULT_GENRE_VECTOR_WEIGHT = 0.25 + const val DEFAULT_PLOT_VECTOR_WEIGHT = 0.35 + const val DEFAULT_MOOD_VECTOR_WEIGHT = 0.15 + const val DEFAULT_ERA_VECTOR_WEIGHT = 0.10 + const val DEFAULT_PEOPLE_VECTOR_WEIGHT = 0.10 + const val DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT = 0.05 + + const val MIN_SCORE_WEIGHT = 0.05 + const val MAX_SCORE_WEIGHT = 0.75 + const val MIN_VECTOR_WEIGHT = 0.03 + const val MAX_VECTOR_WEIGHT = 0.60 + + private val DEFAULT_SCORE_WEIGHTS = + listOf( + DEFAULT_RELEVANCE_WEIGHT, + DEFAULT_QUALITY_WEIGHT, + DEFAULT_CONTEXT_WEIGHT, + DEFAULT_NOVELTY_WEIGHT, + DEFAULT_DIVERSITY_WEIGHT, + ) + private val DEFAULT_VECTOR_WEIGHTS = + listOf( + DEFAULT_GENRE_VECTOR_WEIGHT, + DEFAULT_PLOT_VECTOR_WEIGHT, + DEFAULT_MOOD_VECTOR_WEIGHT, + DEFAULT_ERA_VECTOR_WEIGHT, + DEFAULT_PEOPLE_VECTOR_WEIGHT, + DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT, + ) + + fun defaultFor(userId: UUID): UserRecommendationWeights = UserRecommendationWeights(userId = userId) + + fun forStyle( + userId: UUID, + style: RecommendationStyle, + ): UserRecommendationWeights = + when (style) { + RecommendationStyle.BALANCED -> { + defaultFor(userId) + } + + RecommendationStyle.QUALITY_FIRST -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.40, + qualityWeight = 0.35, + contextWeight = 0.10, + noveltyWeight = 0.05, + diversityWeight = 0.10, + ) + } + + RecommendationStyle.MOOD_FIRST -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.45, + qualityWeight = 0.10, + contextWeight = 0.25, + noveltyWeight = 0.10, + diversityWeight = 0.10, + moodVectorWeight = 0.30, + ) + } + + RecommendationStyle.DISCOVERY -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.30, + qualityWeight = 0.10, + contextWeight = 0.10, + noveltyWeight = 0.25, + diversityWeight = 0.25, + ) + } + + RecommendationStyle.SIMILAR_TO_FAVORITES -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.70, + qualityWeight = 0.10, + contextWeight = 0.10, + noveltyWeight = 0.05, + diversityWeight = 0.05, + genreVectorWeight = 0.30, + plotVectorWeight = 0.40, + peopleVectorWeight = 0.15, + ) + } + }.normalized() + + private fun normalizeBounded( + values: List, + defaults: List, + min: Double, + max: Double, + ): List { + val sanitized = values.map { value -> if (value.isFinite() && value > 0.0) value else 0.0 } + val source = sanitized.takeIf { it.sum() > 0.0 } ?: defaults + val normalized = source.map { it / source.sum() } + return projectToBounds(normalized, min, max) + } + + private fun projectToBounds( + values: List, + min: Double, + max: Double, + ): List { + val result = values.map { it.coerceIn(min, max) }.toMutableList() + var iterations = 0 + var adjusting = true + + while (iterations < values.size * 2 && adjusting) { + iterations += 1 + val diff = 1.0 - result.sum() + if (kotlin.math.abs(diff) <= NORMALIZATION_EPSILON) { + adjusting = false + } else { + adjusting = redistribute(result, diff, min, max) + } + } + + return result + } + + private fun redistribute( + result: MutableList, + diff: Double, + min: Double, + max: Double, + ): Boolean = + if (diff > 0.0) { + val candidates = result.indices.filter { result[it] < max } + val capacity = candidates.sumOf { max - result[it] } + if (capacity > 0.0) { + candidates.forEach { index -> + val increment = diff * ((max - result[index]) / capacity) + result[index] = (result[index] + increment).coerceAtMost(max) + } + true + } else { + false + } + } else { + val candidates = result.indices.filter { result[it] > min } + val capacity = candidates.sumOf { result[it] - min } + if (capacity > 0.0) { + candidates.forEach { index -> + val decrement = -diff * ((result[index] - min) / capacity) + result[index] = (result[index] - decrement).coerceAtLeast(min) + } + true + } else { + false + } + } + + private const val NORMALIZATION_EPSILON = 0.0000001 + } +} diff --git a/src/main/resources/db/migration/V8__user_recommendation_weights.sql b/src/main/resources/db/migration/V8__user_recommendation_weights.sql new file mode 100644 index 0000000..238870e --- /dev/null +++ b/src/main/resources/db/migration/V8__user_recommendation_weights.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS public.user_recommendation_weights ( + user_id UUID PRIMARY KEY, + relevance_weight DOUBLE PRECISION NOT NULL DEFAULT 0.55, + quality_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15, + context_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + novelty_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + diversity_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + genre_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.25, + plot_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.35, + mood_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15, + era_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + people_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + content_type_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.05, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_recommendation_weights_user_fk + FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS relevance_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS quality_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS context_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS novelty_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS diversity_score DOUBLE PRECISION; + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_film_type_created + ON public.recommendation_events(user_id, film_id, event_type, created_at DESC); diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index 25f0211..c8aaae2 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -4,8 +4,12 @@ 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 com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest +import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -110,6 +114,39 @@ class RecommendationSmokeTest { val firstFilmId = filmIdByTitle.getValue("Orbital Drift") val secondFilmId = filmIdByTitle.getValue("Small Town Summer") + mockMvc + .get("/api/users/$userId/recommendation-weights") + .andExpect { + status { isOk() } + jsonPath("$.relevanceWeight") { value(0.55) } + jsonPath("$.plotVectorWeight") { value(0.35) } + } + + mockMvc + .put("/api/users/$userId/recommendation-weights") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpdateUserRecommendationWeightsRequest( + relevanceWeight = 0.60, + qualityWeight = 0.10, + contextWeight = 0.15, + noveltyWeight = 0.10, + diversityWeight = 0.05, + genreVectorWeight = 0.30, + plotVectorWeight = 0.30, + moodVectorWeight = 0.20, + eraVectorWeight = 0.05, + peopleVectorWeight = 0.10, + contentTypeVectorWeight = 0.05, + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.relevanceWeight") { value(0.6) } + jsonPath("$.genreVectorWeight") { value(0.3) } + } + mockMvc .put("/api/users/$userId/preferences") { contentType = MediaType.APPLICATION_JSON @@ -163,14 +200,38 @@ class RecommendationSmokeTest { jsonPath("$[0].reasons[0]") { exists() } } + val recommendedBreakdownCount = + jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM recommendation_events + WHERE user_id = ? + AND film_id = ? + AND event_type = 'RECOMMENDED' + AND relevance_score IS NOT NULL + AND quality_score IS NOT NULL + """.trimIndent(), + Int::class.java, + userId, + firstFilmId, + ) + assertTrue((recommendedBreakdownCount ?: 0) > 0) + + val weightsBeforeFeedback = findScoreWeights(userId) + mockMvc .post("/api/users/$userId/recommendations/$firstFilmId/accept") .andExpect { status { isOk() } jsonPath("$.filmId") { value(firstFilmId.toString()) } jsonPath("$.eventType") { value("ACCEPTED") } + jsonPath("$.relevanceScore") { exists() } } + val weightsAfterAccept = findScoreWeights(userId) + assertNotEquals(weightsBeforeFeedback, weightsAfterAccept) + assertTrue(weightsAfterAccept.all { it in 0.05..0.75 }) + mockMvc .post("/api/users/$userId/recommendations/$firstFilmId/reject") .andExpect { @@ -190,12 +251,159 @@ class RecommendationSmokeTest { } } + @Test + fun `should complete recommendation onboarding`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Alex", email = "alex@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "alex@example.com", + ), + ) + + val likedFilmId = createFilm(title = "Neon Rescue", genres = listOf("SCI-FI"), imdbRating = 8.8) + val dislikedFilmId = createFilm(title = "Quiet Village", genres = listOf("DRAMA"), imdbRating = 5.0) + val libraryFilmId = createFilm(title = "Space Trial", genres = listOf("SCI-FI"), imdbRating = 7.8) + val watchedFilmId = createFilm(title = "Old Mission", genres = listOf("THRILLER"), imdbRating = 8.1) + + mockMvc + .post("/api/users/$userId/recommendation-onboarding") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + RecommendationOnboardingRequest( + weightedGenres = mapOf("SCI-FI" to 5, "THRILLER" to 3), + moods = listOf("focused", "tense"), + contentTypes = listOf("FILM"), + likedFilmIds = listOf(likedFilmId), + dislikedFilmIds = listOf(dislikedFilmId), + libraryFilmIds = listOf(libraryFilmId), + watchedFilmIds = listOf(watchedFilmId), + recommendationStyle = "DISCOVERY", + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.preferences.weightedGenres['SCI-FI']") { value(5) } + jsonPath("$.weights.noveltyWeight") { value(0.25) } + jsonPath("$.weights.diversityWeight") { value(0.25) } + jsonPath("$.likedFilmsCount") { value(1) } + jsonPath("$.dislikedFilmsCount") { value(1) } + jsonPath("$.libraryFilmsCount") { value(1) } + jsonPath("$.watchedFilmsCount") { value(1) } + } + + assertDatabaseCount( + """ + SELECT COUNT(*) + FROM film_ratings + WHERE user_id = ? + AND film_id IN (?, ?) + """.trimIndent(), + userId, + likedFilmId, + dislikedFilmId, + ) + assertDatabaseCount( + """ + SELECT COUNT(*) + FROM favorites + WHERE user_id = ? + AND film_id = ? + AND is_viewed = TRUE + """.trimIndent(), + userId, + watchedFilmId, + ) + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "3") + }.andExpect { + status { isOk() } + jsonPath("$[0].reasons[0]") { exists() } + } + } + private fun cleanDatabase() { jdbcTemplate.execute("DELETE FROM recommendation_events") + jdbcTemplate.execute("DELETE FROM user_recommendation_weights") jdbcTemplate.execute("DELETE FROM film_ratings") jdbcTemplate.execute("DELETE FROM user_preferences") jdbcTemplate.execute("DELETE FROM favorites") jdbcTemplate.execute("DELETE FROM films") jdbcTemplate.execute("DELETE FROM users") } + + private fun findScoreWeights(userId: UUID): List = + jdbcTemplate + .queryForMap( + """ + SELECT relevance_weight, + quality_weight, + context_weight, + novelty_weight, + diversity_weight + FROM user_recommendation_weights + WHERE user_id = ? + """.trimIndent(), + userId, + ).let { row -> + listOf( + row.getValue("RELEVANCE_WEIGHT"), + row.getValue("QUALITY_WEIGHT"), + row.getValue("CONTEXT_WEIGHT"), + row.getValue("NOVELTY_WEIGHT"), + row.getValue("DIVERSITY_WEIGHT"), + ).map { (it as Number).toDouble() } + } + + private fun createFilm( + title: String, + genres: List, + imdbRating: Double, + ): UUID { + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = title, + description = "$title description", + contentType = "FILM", + genres = genres, + imdbRating = imdbRating, + ), + ) + }.andExpect { + status { isCreated() } + } + + return UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM films WHERE title = ?", + String::class.java, + title, + ), + ) + } + + private fun assertDatabaseCount( + sql: String, + vararg args: Any, + ) { + val count = jdbcTemplate.queryForObject(sql, Int::class.java, *args) + assertTrue((count ?: 0) > 0) + } } diff --git a/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt b/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt new file mode 100644 index 0000000..7f2ee9e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt @@ -0,0 +1,74 @@ +package com.project.movienight.domain.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.UUID + +class UserRecommendationWeightsTest { + @Test + fun `should keep default weights normalized`() { + val weights = UserRecommendationWeights.defaultFor(UUID.randomUUID()).normalized() + + assertEquals(1.0, weights.scoreWeightSum(), EPSILON) + assertEquals(1.0, weights.vectorWeightSum(), EPSILON) + assertEquals(0.55, weights.relevanceWeight, EPSILON) + assertEquals(0.35, weights.plotVectorWeight, EPSILON) + } + + @Test + fun `should normalize and bound invalid weights`() { + val weights = + UserRecommendationWeights( + userId = UUID.randomUUID(), + relevanceWeight = 100.0, + qualityWeight = -5.0, + contextWeight = 0.0, + noveltyWeight = 0.0, + diversityWeight = 0.0, + genreVectorWeight = 100.0, + plotVectorWeight = 0.0, + moodVectorWeight = 0.0, + eraVectorWeight = 0.0, + peopleVectorWeight = 0.0, + contentTypeVectorWeight = 0.0, + ).normalized() + + assertEquals(1.0, weights.scoreWeightSum(), EPSILON) + assertEquals(1.0, weights.vectorWeightSum(), EPSILON) + assertTrue( + listOf( + weights.relevanceWeight, + weights.qualityWeight, + weights.contextWeight, + weights.noveltyWeight, + weights.diversityWeight, + ).all { it in UserRecommendationWeights.MIN_SCORE_WEIGHT..UserRecommendationWeights.MAX_SCORE_WEIGHT }, + ) + assertTrue( + listOf( + weights.genreVectorWeight, + weights.plotVectorWeight, + weights.moodVectorWeight, + weights.eraVectorWeight, + weights.peopleVectorWeight, + weights.contentTypeVectorWeight, + ).all { it in UserRecommendationWeights.MIN_VECTOR_WEIGHT..UserRecommendationWeights.MAX_VECTOR_WEIGHT }, + ) + } + + private fun UserRecommendationWeights.scoreWeightSum(): Double = + relevanceWeight + qualityWeight + contextWeight + noveltyWeight + diversityWeight + + private fun UserRecommendationWeights.vectorWeightSum(): Double = + genreVectorWeight + + plotVectorWeight + + moodVectorWeight + + eraVectorWeight + + peopleVectorWeight + + contentTypeVectorWeight + + private companion object { + private const val EPSILON = 0.000001 + } +} -- 2.54.0 From c3fc2568e0667433bf56fe94b424513d6232ec98 Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 14:20:45 +0300 Subject: [PATCH 72/77] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + .../services/FilmLibraryService.kt | 168 +++++++++------ .../application/services/FilmService.kt | 198 ++++++++++-------- .../application/services/UserService.kt | 81 ++++--- src/main/resources/application.yaml | 7 +- src/main/resources/logback-spring.xml | 23 ++ 6 files changed, 295 insertions(+), 184 deletions(-) create mode 100644 src/main/resources/logback-spring.xml diff --git a/build.gradle.kts b/build.gradle.kts index c0f9dfd..468b2ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { implementation(libs.flyway.database.postgresql) implementation(libs.kotlin.reflect) + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation(libs.micrometer.tracing.bridge.otel) implementation(libs.opentelemetry.exporter.otlp) implementation(libs.sentry.spring.boot.starter) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 2924922..b7f24dd 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -17,6 +17,7 @@ import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.FilmLibrary +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -31,98 +32,133 @@ class FilmLibraryService( RemoveFilmFromLibraryUseCase, GetFilmLibraryUseCase, ListFilmLibraryEntriesUseCase { + + private val log = LoggerFactory.getLogger(javaClass) + override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - findByUserId(command.userId)?.let { return it } + log.info("Creating film library for user: {}", command.userId) + log.debug("Create library request: userId={}, name={}", command.userId, command.name) + + val existing = findByUserId(command.userId) + if (existing != null) { + log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) + return existing + } + + log.warn("Library not found for user {}, cannot create", command.userId) throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { + log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) if (existingEntry != null) { - val saved = - filmLibraryRepository.save( - existingEntry.copy( - isViewed = false, - watchedAt = null, - ), + log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) + val saved = filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, ) + ) businessMetricsService.recordLibraryEvent() + log.info("Film re-added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) return saved } - val saved = + val saved = filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = false, + watchedAt = null, + ) + ) + businessMetricsService.recordLibraryEvent() + log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) + return saved + } + + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { + log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) + + val existingLibrary = if (command.libraryId != null) { + log.debug("Looking up by libraryId: {}", command.libraryId) + filmLibraryRepository.findById(command.libraryId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + } else { + log.debug("Looking up by userId and filmId") + findByUserAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + } + + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { + log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) + throw DomainException("Film with id ${command.filmId} not found in user's library") + } + + filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() + log.info("Film removed from library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, existingLibrary.id) + return existingLibrary + } + + override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { + log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + log.debug("Marking as viewed at: {}", watchedAt) + + val saved = if (existingEntry == null) { + log.debug("Film not in library, creating new entry as viewed") filmLibraryRepository.save( FilmLibrary( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, comment = null, - isViewed = false, - watchedAt = null, - ), + isViewed = true, + watchedAt = watchedAt, + ) + ) + } else { + log.debug("Updating existing entry: entryId={}, was viewed={}", existingEntry.id, existingEntry.isViewed) + filmLibraryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ) ) - businessMetricsService.recordLibraryEvent() - return saved - } - - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - val existingLibrary = - if (command.libraryId != null) { - filmLibraryRepository.findById(command.libraryId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } else { - findByUserAndFilmId(command.userId, command.filmId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) - } - - if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { - throw DomainException("Film with id ${command.filmId} not found in user's library") } - - filmLibraryRepository.deleteById(existingLibrary.id) - businessMetricsService.recordLibraryEvent() - return existingLibrary - } - - override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() - - val saved = - if (existingEntry == null) { - filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ), - ) - } else { - filmLibraryRepository.save( - existingEntry.copy( - isViewed = true, - watchedAt = watchedAt, - ), - ) - } businessMetricsService.recordLibraryEvent() + log.info("Film marked as viewed: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) return saved } - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = - findByUserId(query.userId) + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { + log.debug("Getting library for user: {}", query.userId) + val library = findByUserId(query.userId) ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) + return library + } - override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + override fun list(userId: UUID): List { + log.debug("Listing all library entries for user: {}", userId) + val entries = filmLibraryRepository.findAll().filter { it.userId == userId } + log.info("User {} has {} films in library", userId, entries.size) + return entries + } - private fun findByUserId(userId: UUID): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + private fun findByUserId(userId: UUID): FilmLibrary? { + return filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + } - private fun findByUserAndFilmId( - userId: UUID, - filmId: UUID, - ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } + private fun findByUserAndFilmId(userId: UUID, filmId: UUID): FilmLibrary? { + return filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } + } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index d69a6c8..c1efda4 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -33,70 +33,68 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { + private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateFilmCommand): Film { + log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType) + log.debug("Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", + command.title, command.description.length, command.genres, command.releaseYear) + val sample = Timer.start(meterRegistry) try { - log.debug( - "Create film request received: title='{}', descriptionLength={}", - command.title, - command.description.length, - ) - if (filmConfig.isBlocked(command.title)) { - log.debug("Create film blocked by title policy: title='{}'", command.title) + log.warn("Film creation blocked: title contains blocked pattern '{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.debug("Create film blocked by description policy") + log.warn("Film creation blocked: description contains blocked pattern") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + val film = Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) val saved = filmRepository.save(film) filmCreatedCounter.increment() + log.info("Film created successfully: id={}, title='{}'", saved.id, saved.title) return saved } finally { sample.stop(createFilmTimer) } } - override fun edit( - id: UUID, - command: EditFilmCommand, - ): Film { + override fun edit(id: UUID, command: EditFilmCommand): Film { + log.info("Editing film: id={}", id) + log.debug("Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", + id, command.title, command.description.length, command.genres) + val sample = Timer.start(meterRegistry) try { - log.debug("Edit film with id: {}", id) - if (filmConfig.isBlocked(command.title)) { - log.debug("Edit film blocked by title policy: title='{}'", command.title) + log.warn("Film edit blocked: title contains blocked pattern '{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.debug("Edit film blocked by description policy") + log.warn("Film edit blocked: description contains blocked pattern") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } @@ -104,28 +102,30 @@ class FilmService( var film = filmRepository.findById(id) if (film == null) { - log.debug("Film not found for edit: id='{}'", id) + log.warn("Film not found for edit: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - film = - film.copy( - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + log.debug("Existing film found: id={}, current title='{}'", film.id, film.title) + + film = film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) val saved = filmRepository.save(film) filmEditedCounter.increment() + log.info("Film edited successfully: id={}, new title='{}'", saved.id, saved.title) return saved } finally { sample.stop(editFilmTimer) @@ -133,74 +133,86 @@ class FilmService( } override fun delete(id: UUID) { + log.info("Deleting film: id={}", id) + val sample = Timer.start(meterRegistry) try { - log.debug("Delete film with id: {}", id) - val film = filmRepository.findById(id) if (film == null) { - log.debug("Film not found for delete: id='{}'", id) + log.warn("Film not found for delete: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } + log.debug("Film found for deletion: id={}, title='{}'", film.id, film.title) + filmRepository.deleteById(id) - filmDeletedCounter.increment() - - log.info("Film deleted: id='{}'", id) + log.info("Film deleted successfully: id={}, title='{}'", id, film.title) } finally { sample.stop(deleteFilmTimer) } } - override fun getById(id: UUID): Film = - filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + override fun getById(id: UUID): Film { + log.debug("Fetching film by id: {}", id) + val film = filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + log.debug("Film found: id={}, title='{}'", film.id, film.title) + return film + } - override fun getAll(): List = filmRepository.findAll() + override fun getAll(): List { + log.debug("Fetching all films") + val films = filmRepository.findAll() + log.info("Retrieved {} films from database", films.size) + return films + } - override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) + override fun searchByTitle(title: String): Film? { + log.debug("Searching film by title: '{}'", title) + val film = filmRepository.findByTitle(title) + if (film != null) { + log.info("Film found by title '{}': id={}", title, film.id) + } else { + log.debug("No film found with title: '{}'", title) + } + return film + } - private val filmCreatedCounter = - Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) + private val filmCreatedCounter = Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) - private val filmEditedCounter = - Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) + private val filmEditedCounter = Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) - private val filmDeletedCounter = - Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) + private val filmDeletedCounter = Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) - private val filmBlockedCounter = - Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) + private val filmBlockedCounter = Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) - private val createFilmTimer = - Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) + private val createFilmTimer = Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) - private val editFilmTimer = - Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) + private val editFilmTimer = Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) - private val deleteFilmTimer = - Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) + private val deleteFilmTimer = Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 684da5f..8926742 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -13,6 +13,7 @@ import com.project.movienight.config.UserServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -26,48 +27,80 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { + + private val log = LoggerFactory.getLogger(javaClass) + override fun create(command: CreateUserCommand): User { + log.info("Creating new user with email: {}", command.email) + log.debug("Create user request: name='{}', email='{}'", command.name, command.email) + if (userConfig.isBlocked(command.name)) { + log.warn("User creation blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } - val user = - User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - jellyfinUserId = null, - ) - return userRepository.save(user) + val user = User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + jellyfinUserId = null, + ) + val saved = userRepository.save(user) + + log.info("User created successfully: id={}, email='{}'", saved.id, saved.email) + return saved } - override fun edit( - id: UUID, - command: EditUserCommand, - ): User { + override fun edit(id: UUID, command: EditUserCommand): User { + log.info("Editing user: id={}", id) + log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId) + if (userConfig.isBlocked(command.name)) { + log.warn("User edit blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } - var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + var user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = - user.copy( - name = command.name, - jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, - ) + log.debug("Existing user found: id={}, current name='{}'", user.id, user.name) - return userRepository.save(user) + user = user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) + + val saved = userRepository.save(user) + log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name) + return saved } override fun delete(id: UUID) { - userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + log.info("Deleting user: id={}", id) + log.debug("Delete user request: id={}", id) + + val user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + + log.debug("User found for deletion: id={}, email='{}'", user.id, user.email) + userRepository.deleteById(id) + log.info("User deleted successfully: id={}", id) } - override fun getById(id: UUID): User = - userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + override fun getById(id: UUID): User { + log.debug("Fetching user by id: {}", id) + val user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email) + return user + } - override fun getAll(): List = userRepository.findAll() + override fun getAll(): List { + log.debug("Fetching all users") + val users = userRepository.findAll() + log.info("Retrieved {} users from database", users.size) + return users + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c2af423..e277aa3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -115,6 +115,11 @@ services: - censored - epstein - python + logging: + level: + com.project.movienight: DEBUG + org.springframework: WARN + org.flywaydb: WARN pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n" + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n" diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..dd704c8 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,23 @@ + + + + %d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n + + + + + logs/app.json + + logs/app-%d{yyyy-MM-dd}.json + 30 + + + + + + + + + + + -- 2.54.0 From df8d3ee404c154104201cf9a7a32f6165e94962b Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:07:03 +0300 Subject: [PATCH 73/77] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../movienight/application/services/FilmLibraryService.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index b7f24dd..bdc184e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -62,7 +62,9 @@ class FilmLibraryService( ) ) businessMetricsService.recordLibraryEvent() - log.info("Film re-added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) + log.info( + "Film re-added to library: userId={}, filmId={}, entryId={}", + command.userId, command.filmId, saved.id) return saved } @@ -101,7 +103,9 @@ class FilmLibraryService( filmLibraryRepository.deleteById(existingLibrary.id) businessMetricsService.recordLibraryEvent() - log.info("Film removed from library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, existingLibrary.id) + log.info( + "Film removed from library: userId={}, filmId={}, entryId={}", + command.userId, command.filmId, existingLibrary.id) return existingLibrary } -- 2.54.0 From 9e9e2244abbfdb7190c9663eeac2f8d81579b5d3 Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:38:28 +0300 Subject: [PATCH 74/77] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/FilmLibraryService.kt | 360 ++++++++++-------- .../application/services/FilmService.kt | 152 ++++---- .../application/services/UserService.kt | 45 ++- 3 files changed, 305 insertions(+), 252 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index bdc184e..07d8d5a 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,168 +1,192 @@ -package com.project.movienight.application.services - -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase -import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort -import com.project.movienight.application.ports.output.IdGenerator -import com.project.movienight.domain.exception.DomainException -import com.project.movienight.domain.exception.EntityNotFoundException -import com.project.movienight.domain.model.FilmLibrary -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service -import java.util.UUID - -@Service -class FilmLibraryService( - private val filmLibraryRepository: FilmLibraryRepositoryPort, - private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) : CreateFilmLibraryUseCase, - AddFilmToLibraryUseCase, - MarkFilmViewedUseCase, - RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase, - ListFilmLibraryEntriesUseCase { - - private val log = LoggerFactory.getLogger(javaClass) - - override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - log.info("Creating film library for user: {}", command.userId) - log.debug("Create library request: userId={}, name={}", command.userId, command.name) - - val existing = findByUserId(command.userId) - if (existing != null) { - log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) - return existing - } - - log.warn("Library not found for user {}, cannot create", command.userId) - throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) - } - - override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) - - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - if (existingEntry != null) { - log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) - val saved = filmLibraryRepository.save( - existingEntry.copy( - isViewed = false, - watchedAt = null, - ) - ) - businessMetricsService.recordLibraryEvent() - log.info( - "Film re-added to library: userId={}, filmId={}, entryId={}", - command.userId, command.filmId, saved.id) - return saved - } - - val saved = filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = false, - watchedAt = null, - ) - ) - businessMetricsService.recordLibraryEvent() - log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) - return saved - } - - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) - - val existingLibrary = if (command.libraryId != null) { - log.debug("Looking up by libraryId: {}", command.libraryId) - filmLibraryRepository.findById(command.libraryId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } else { - log.debug("Looking up by userId and filmId") - findByUserAndFilmId(command.userId, command.filmId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) - } - - if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { - log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) - throw DomainException("Film with id ${command.filmId} not found in user's library") - } - - filmLibraryRepository.deleteById(existingLibrary.id) - businessMetricsService.recordLibraryEvent() - log.info( - "Film removed from library: userId={}, filmId={}, entryId={}", - command.userId, command.filmId, existingLibrary.id) - return existingLibrary - } - - override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) - - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() - - log.debug("Marking as viewed at: {}", watchedAt) - - val saved = if (existingEntry == null) { - log.debug("Film not in library, creating new entry as viewed") - filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ) - ) - } else { - log.debug("Updating existing entry: entryId={}, was viewed={}", existingEntry.id, existingEntry.isViewed) - filmLibraryRepository.save( - existingEntry.copy( - isViewed = true, - watchedAt = watchedAt, - ) - ) - } - businessMetricsService.recordLibraryEvent() - log.info("Film marked as viewed: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) - return saved - } - - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { - log.debug("Getting library for user: {}", query.userId) - val library = findByUserId(query.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) - log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) - return library - } - - override fun list(userId: UUID): List { - log.debug("Listing all library entries for user: {}", userId) - val entries = filmLibraryRepository.findAll().filter { it.userId == userId } - log.info("User {} has {} films in library", userId, entries.size) - return entries - } - - private fun findByUserId(userId: UUID): FilmLibrary? { - return filmLibraryRepository.findAll().firstOrNull { it.userId == userId } - } - - private fun findByUserAndFilmId(userId: UUID, filmId: UUID): FilmLibrary? { - return filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } - } -} +//package com.project.movienight.application.services +// +//import com.project.movienight.adapters.metrics.BusinessMetricsService +//import com.project.movienight.application.ports.input.AddFilmToLibraryCommand +//import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase +//import com.project.movienight.application.ports.input.CreateFilmLibraryCommand +//import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +//import com.project.movienight.application.ports.input.GetFilmLibraryQuery +//import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +//import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +//import com.project.movienight.application.ports.input.MarkFilmViewedCommand +//import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand +//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +//import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +//import com.project.movienight.application.ports.output.IdGenerator +//import com.project.movienight.domain.exception.DomainException +//import com.project.movienight.domain.exception.EntityNotFoundException +//import com.project.movienight.domain.model.FilmLibrary +//import org.slf4j.LoggerFactory +//import org.springframework.stereotype.Service +//import java.util.UUID +// +//@Service +//class FilmLibraryService( +// private val filmLibraryRepository: FilmLibraryRepositoryPort, +// private val idGenerator: IdGenerator, +// private val businessMetricsService: BusinessMetricsService, +//) : CreateFilmLibraryUseCase, +// AddFilmToLibraryUseCase, +// MarkFilmViewedUseCase, +// RemoveFilmFromLibraryUseCase, +// GetFilmLibraryUseCase, +// ListFilmLibraryEntriesUseCase { +// private val log = LoggerFactory.getLogger(javaClass) +// +// override fun create(command: CreateFilmLibraryCommand): FilmLibrary { +// log.info("Creating film library for user: {}", command.userId) +// log.debug("Create library request: userId={}, name={}", command.userId, command.name) +// +// val existing = findByUserId(command.userId) +// if (existing != null) { +// log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) +// return existing +// } +// +// log.warn("Library not found for user {}, cannot create", command.userId) +// throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) +// } +// +// override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { +// log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) +// if (existingEntry != null) { +// log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) +// val saved = +// filmLibraryRepository.save( +// existingEntry.copy( +// isViewed = false, +// watchedAt = null, +// ), +// ) +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film re-added to library: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// saved.id, +// ) +// return saved +// } +// +// val saved = +// filmLibraryRepository.save( +// FilmLibrary( +// id = idGenerator.generateId(), +// userId = command.userId, +// filmId = command.filmId, +// comment = null, +// isViewed = false, +// watchedAt = null, +// ), +// ) +// businessMetricsService.recordLibraryEvent() +// log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) +// return saved +// } +// +// override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { +// log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingLibrary = +// if (command.libraryId != null) { +// log.debug("Looking up by libraryId: {}", command.libraryId) +// filmLibraryRepository.findById(command.libraryId) +// ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) +// } else { +// log.debug("Looking up by userId and filmId") +// findByUserAndFilmId(command.userId, command.filmId) +// ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) +// } +// +// if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { +// log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) +// throw DomainException("Film with id ${command.filmId} not found in user's library") +// } +// +// filmLibraryRepository.deleteById(existingLibrary.id) +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film removed from library: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// existingLibrary.id, +// ) +// return existingLibrary +// } +// +// override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { +// log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) +// val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() +// +// log.debug("Marking as viewed at: {}", watchedAt) +// +// val saved = +// if (existingEntry == null) { +// log.debug("Film not in library, creating new entry as viewed") +// filmLibraryRepository.save( +// FilmLibrary( +// id = idGenerator.generateId(), +// userId = command.userId, +// filmId = command.filmId, +// comment = null, +// isViewed = true, +// watchedAt = watchedAt, +// ), +// ) +// } else { +// log.debug( +// "Updating existing entry: entryId={}, was viewed={}", +// existingEntry.id, +// existingEntry.isViewed, +// ) +// filmLibraryRepository.save( +// existingEntry.copy( +// isViewed = true, +// watchedAt = watchedAt, +// ), +// ) +// } +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film marked as viewed: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// saved.id, +// ) +// return saved +// } +// +// override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { +// log.debug("Getting library for user: {}", query.userId) +// val library = +// findByUserId(query.userId) +// ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) +// log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) +// return library +// } +// +// override fun list(userId: UUID): List { +// log.debug("Listing all library entries for user: {}", userId) +// val entries = filmLibraryRepository.findAll().filter { it.userId == userId } +// log.info("User {} has {} films in library", userId, entries.size) +// return entries +// } +// +// private fun findByUserId(userId: UUID): FilmLibrary? = +// filmLibraryRepository.findAll().firstOrNull { +// it.userId == userId +// } +// +// private fun findByUserAndFilmId( +// userId: UUID, +// filmId: UUID, +// ): FilmLibrary? = +// filmLibraryRepository.findAll().firstOrNull { +// it.userId == userId && it.filmId == filmId +// } +//} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index c1efda4..93de089 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -33,13 +33,17 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateFilmCommand): Film { log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType) - log.debug("Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", - command.title, command.description.length, command.genres, command.releaseYear) + log.debug( + "Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", + command.title, + command.description.length, + command.genres, + command.releaseYear, + ) val sample = Timer.start(meterRegistry) @@ -55,21 +59,22 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) val saved = filmRepository.save(film) filmCreatedCounter.increment() @@ -80,10 +85,18 @@ class FilmService( } } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { log.info("Editing film: id={}", id) - log.debug("Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", - id, command.title, command.description.length, command.genres) + log.debug( + "Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", + id, + command.title, + command.description.length, + command.genres, + ) val sample = Timer.start(meterRegistry) @@ -108,20 +121,21 @@ class FilmService( log.debug("Existing film found: id={}, current title='{}'", film.id, film.title) - film = film.copy( - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) val saved = filmRepository.save(film) filmEditedCounter.increment() @@ -157,8 +171,9 @@ class FilmService( override fun getById(id: UUID): Film { log.debug("Fetching film by id: {}", id) - val film = filmRepository.findById(id) - ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + val film = + filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) log.debug("Film found: id={}, title='{}'", film.id, film.title) return film } @@ -181,38 +196,45 @@ class FilmService( return film } - private val filmCreatedCounter = Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) + private val filmCreatedCounter = + Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) - private val filmEditedCounter = Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) + private val filmEditedCounter = + Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) - private val filmDeletedCounter = Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) + private val filmDeletedCounter = + Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) - private val filmBlockedCounter = Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) + private val filmBlockedCounter = + Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) - private val createFilmTimer = Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) + private val createFilmTimer = + Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) - private val editFilmTimer = Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) + private val editFilmTimer = + Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) - private val deleteFilmTimer = Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) + private val deleteFilmTimer = + Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 8926742..ba8fd76 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -27,7 +27,6 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateUserCommand): User { @@ -39,20 +38,24 @@ class UserService( throw BlockedValueException(target = "User", field = "name") } - val user = User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - jellyfinUserId = null, - ) + val user = + User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + jellyfinUserId = null, + ) val saved = userRepository.save(user) log.info("User created successfully: id={}, email='{}'", saved.id, saved.email) return saved } - override fun edit(id: UUID, command: EditUserCommand): User { + override fun edit( + id: UUID, + command: EditUserCommand, + ): User { log.info("Editing user: id={}", id) log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId) @@ -61,15 +64,17 @@ class UserService( throw BlockedValueException(target = "User", field = "name") } - var user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + var user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("Existing user found: id={}, current name='{}'", user.id, user.name) - user = user.copy( - name = command.name, - jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, - ) + user = + user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) val saved = userRepository.save(user) log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name) @@ -80,8 +85,9 @@ class UserService( log.info("Deleting user: id={}", id) log.debug("Delete user request: id={}", id) - val user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + val user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("User found for deletion: id={}, email='{}'", user.id, user.email) @@ -91,8 +97,9 @@ class UserService( override fun getById(id: UUID): User { log.debug("Fetching user by id: {}", id) - val user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + val user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email) return user } -- 2.54.0 From 6d35b2f5a61568533e71122051b625b5feb3c35d Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:41:24 +0300 Subject: [PATCH 75/77] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/FilmLibraryService.kt | 389 +++++++++--------- 1 file changed, 197 insertions(+), 192 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 07d8d5a..381f3bf 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,192 +1,197 @@ -//package com.project.movienight.application.services -// -//import com.project.movienight.adapters.metrics.BusinessMetricsService -//import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -//import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -//import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -//import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -//import com.project.movienight.application.ports.input.GetFilmLibraryQuery -//import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -//import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase -//import com.project.movienight.application.ports.input.MarkFilmViewedCommand -//import com.project.movienight.application.ports.input.MarkFilmViewedUseCase -//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -//import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort -//import com.project.movienight.application.ports.output.IdGenerator -//import com.project.movienight.domain.exception.DomainException -//import com.project.movienight.domain.exception.EntityNotFoundException -//import com.project.movienight.domain.model.FilmLibrary -//import org.slf4j.LoggerFactory -//import org.springframework.stereotype.Service -//import java.util.UUID -// -//@Service -//class FilmLibraryService( -// private val filmLibraryRepository: FilmLibraryRepositoryPort, -// private val idGenerator: IdGenerator, -// private val businessMetricsService: BusinessMetricsService, -//) : CreateFilmLibraryUseCase, -// AddFilmToLibraryUseCase, -// MarkFilmViewedUseCase, -// RemoveFilmFromLibraryUseCase, -// GetFilmLibraryUseCase, -// ListFilmLibraryEntriesUseCase { -// private val log = LoggerFactory.getLogger(javaClass) -// -// override fun create(command: CreateFilmLibraryCommand): FilmLibrary { -// log.info("Creating film library for user: {}", command.userId) -// log.debug("Create library request: userId={}, name={}", command.userId, command.name) -// -// val existing = findByUserId(command.userId) -// if (existing != null) { -// log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) -// return existing -// } -// -// log.warn("Library not found for user {}, cannot create", command.userId) -// throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) -// } -// -// override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { -// log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) -// if (existingEntry != null) { -// log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) -// val saved = -// filmLibraryRepository.save( -// existingEntry.copy( -// isViewed = false, -// watchedAt = null, -// ), -// ) -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film re-added to library: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// saved.id, -// ) -// return saved -// } -// -// val saved = -// filmLibraryRepository.save( -// FilmLibrary( -// id = idGenerator.generateId(), -// userId = command.userId, -// filmId = command.filmId, -// comment = null, -// isViewed = false, -// watchedAt = null, -// ), -// ) -// businessMetricsService.recordLibraryEvent() -// log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) -// return saved -// } -// -// override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { -// log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingLibrary = -// if (command.libraryId != null) { -// log.debug("Looking up by libraryId: {}", command.libraryId) -// filmLibraryRepository.findById(command.libraryId) -// ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) -// } else { -// log.debug("Looking up by userId and filmId") -// findByUserAndFilmId(command.userId, command.filmId) -// ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) -// } -// -// if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { -// log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) -// throw DomainException("Film with id ${command.filmId} not found in user's library") -// } -// -// filmLibraryRepository.deleteById(existingLibrary.id) -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film removed from library: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// existingLibrary.id, -// ) -// return existingLibrary -// } -// -// override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { -// log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) -// val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() -// -// log.debug("Marking as viewed at: {}", watchedAt) -// -// val saved = -// if (existingEntry == null) { -// log.debug("Film not in library, creating new entry as viewed") -// filmLibraryRepository.save( -// FilmLibrary( -// id = idGenerator.generateId(), -// userId = command.userId, -// filmId = command.filmId, -// comment = null, -// isViewed = true, -// watchedAt = watchedAt, -// ), -// ) -// } else { -// log.debug( -// "Updating existing entry: entryId={}, was viewed={}", -// existingEntry.id, -// existingEntry.isViewed, -// ) -// filmLibraryRepository.save( -// existingEntry.copy( -// isViewed = true, -// watchedAt = watchedAt, -// ), -// ) -// } -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film marked as viewed: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// saved.id, -// ) -// return saved -// } -// -// override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { -// log.debug("Getting library for user: {}", query.userId) -// val library = -// findByUserId(query.userId) -// ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) -// log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) -// return library -// } -// -// override fun list(userId: UUID): List { -// log.debug("Listing all library entries for user: {}", userId) -// val entries = filmLibraryRepository.findAll().filter { it.userId == userId } -// log.info("User {} has {} films in library", userId, entries.size) -// return entries -// } -// -// private fun findByUserId(userId: UUID): FilmLibrary? = -// filmLibraryRepository.findAll().firstOrNull { -// it.userId == userId -// } -// -// private fun findByUserAndFilmId( -// userId: UUID, -// filmId: UUID, -// ): FilmLibrary? = -// filmLibraryRepository.findAll().firstOrNull { -// it.userId == userId && it.filmId == filmId -// } -//} +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.AddFilmToLibraryCommand +import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase +import com.project.movienight.application.ports.input.CreateFilmLibraryCommand +import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +import com.project.movienight.application.ports.input.GetFilmLibraryQuery +import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand +import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmLibrary +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class FilmLibraryService( + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, +) : CreateFilmLibraryUseCase, + AddFilmToLibraryUseCase, + MarkFilmViewedUseCase, + RemoveFilmFromLibraryUseCase, + GetFilmLibraryUseCase, + ListFilmLibraryEntriesUseCase { + private val log = LoggerFactory.getLogger(javaClass) + + override fun create(command: CreateFilmLibraryCommand): FilmLibrary { + log.info("Creating film library for user: {}", command.userId) + log.debug("Create library request: userId={}, name={}", command.userId, command.name) + + val existing = findByUserId(command.userId) + if (existing != null) { + log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) + return existing + } + + log.warn("Library not found for user {}, cannot create", command.userId) + throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) + } + + override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { + log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + if (existingEntry != null) { + log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) + val saved = + filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + log.info( + "Film re-added to library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + val saved = + filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + log.info( + "Film added to library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { + log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) + + val existingLibrary = + if (command.libraryId != null) { + log.debug("Looking up by libraryId: {}", command.libraryId) + filmLibraryRepository.findById(command.libraryId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + } else { + log.debug("Looking up by userId and filmId") + findByUserAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + } + + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { + log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) + throw DomainException("Film with id ${command.filmId} not found in user's library") + } + + filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() + log.info( + "Film removed from library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + existingLibrary.id, + ) + return existingLibrary + } + + override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { + log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + log.debug("Marking as viewed at: {}", watchedAt) + + val saved = + if (existingEntry == null) { + log.debug("Film not in library, creating new entry as viewed") + filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } else { + log.debug( + "Updating existing entry: entryId={}, was viewed={}", + existingEntry.id, + existingEntry.isViewed, + ) + filmLibraryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + businessMetricsService.recordLibraryEvent() + log.info( + "Film marked as viewed: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { + log.debug("Getting library for user: {}", query.userId) + val library = + findByUserId(query.userId) + ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) + return library + } + + override fun list(userId: UUID): List { + log.debug("Listing all library entries for user: {}", userId) + val entries = filmLibraryRepository.findAll().filter { it.userId == userId } + log.info("User {} has {} films in library", userId, entries.size) + return entries + } + + private fun findByUserId(userId: UUID): FilmLibrary? = + filmLibraryRepository.findAll().firstOrNull { + it.userId == userId + } + + private fun findByUserAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? = + filmLibraryRepository.findAll().firstOrNull { + it.userId == userId && it.filmId == filmId + } +} -- 2.54.0 From 3d5f48aada656506d1b27c30fada5a89354e8857 Mon Sep 17 00:00:00 2001 From: ITQ Date: Thu, 21 May 2026 01:03:29 +0300 Subject: [PATCH 76/77] refactor(): overall refactoring and improvements --- .../adapters/jellyfin/JellyfinApiClient.kt | 30 ++-------- .../metrics/BusinessMetricsService.kt | 13 +++-- .../jdbc/JellyfinEventRepository.kt | 7 ++- .../ports/output/BusinessMetricsPort.kt | 19 +++++++ .../output/JellyfinEventRepositoryPort.kt | 17 ++++++ .../ports/output/JellyfinSyncSourcePort.kt | 30 ++++++++++ .../services/FilmLibraryService.kt | 4 +- .../application/services/FilmRatingService.kt | 4 +- .../services/JellyfinEventService.kt | 8 +-- .../services/JellyfinSyncService.kt | 12 ++-- .../domain/model/RecommendationContext.kt | 17 ------ .../domain/model/RecommendationResult.kt | 7 +++ .../web/RecommendationControllerTest.kt | 26 +++++++++ .../services/RecommendationServiceTest.kt | 56 +++++++++++++++++++ 14 files changed, 186 insertions(+), 64 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt delete mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt create mode 100644 src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt create mode 100644 src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index 71670be..1a107da 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot +import com.project.movienight.application.ports.output.JellyfinRemoteUser +import com.project.movienight.application.ports.output.JellyfinSyncSourcePort import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType import org.springframework.stereotype.Service @@ -11,39 +14,18 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration -data class JellyfinRemoteUser( - val id: String, - val name: String, -) - -data class JellyfinLibraryItemSnapshot( - val jellyfinItemId: String, - val title: String, - val description: String, - val contentType: ContentType, - val releaseYear: Int?, - val genres: List, - val cast: List, - val directors: List, - val platformRating: Double?, - val imdbRating: Double?, - val externalUrl: String?, - val jellyfinLibraryId: String?, - val isPlayed: Boolean, -) - @Service class JellyfinApiClient( private val properties: JellyfinIntegrationProperties, private val objectMapper: ObjectMapper, -) { +) : JellyfinSyncSourcePort { private val httpClient: HttpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofMillis(properties.requestTimeoutMs)) .build() - fun fetchUsers(): List = + override fun fetchUsers(): List = request("Users") .asItems() .mapNotNull { node -> @@ -51,7 +33,7 @@ class JellyfinApiClient( JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) } - fun fetchLibraryItems(userId: String): List = + override fun fetchLibraryItems(userId: String): List = @Suppress("MaxLineLength") request( "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index b3912f1..d06d815 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.metrics +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.RecommendationEventType import io.micrometer.core.instrument.Counter @@ -33,7 +34,7 @@ class BusinessMetricsService( private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total") - fun recordRecommendationRequest() { + override fun recordRecommendationRequest() { recommendationRequests.increment() } @@ -49,11 +50,11 @@ class BusinessMetricsService( ratingsSubmitted.increment() } - fun recordLibraryEvent() { + override fun recordLibraryEvent() { libraryEvents.increment() } - fun recordJellyfinSync(summary: JellyfinSyncSummary) { + override fun recordJellyfinSync(summary: JellyfinSyncSummary) { jellyfinSyncRuns.increment() jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble()) jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble()) @@ -61,15 +62,15 @@ class BusinessMetricsService( jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS) } - fun recordJellyfinSyncFailure() { + override fun recordJellyfinSyncFailure() { jellyfinSyncFailures.increment() } - fun recordJellyfinUnmappedUser() { + override fun recordJellyfinUnmappedUser() { jellyfinUnmappedUsersGaugeValue.incrementAndGet() } - fun recordBackendWriteFailure() { + override fun recordBackendWriteFailure() { backendWriteFailures.increment() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 153eba7..80334de 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort import org.springframework.jdbc.core.namedparam.MapSqlParameterSource import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import org.springframework.stereotype.Repository @@ -7,8 +8,8 @@ import org.springframework.stereotype.Repository @Repository class JellyfinEventRepository( private val jdbc: NamedParameterJdbcTemplate, -) { - fun save( +) : JellyfinEventRepositoryPort { + override fun save( eventId: String, serverId: String?, eventType: String, @@ -37,7 +38,7 @@ class JellyfinEventRepository( return jdbc.update(sql, params) } - fun delete(eventId: String) { + override fun delete(eventId: String) { val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId" val params = MapSqlParameterSource().addValue("eventId", eventId) jdbc.update(sql, params) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt new file mode 100644 index 0000000..34766f7 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt @@ -0,0 +1,19 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncSummary + +interface BusinessMetricsPort { + fun recordRecommendationRequest() + + fun recordRatingSubmitted() + + fun recordLibraryEvent() + + fun recordJellyfinSync(summary: JellyfinSyncSummary) + + fun recordJellyfinSyncFailure() + + fun recordJellyfinUnmappedUser() + + fun recordBackendWriteFailure() +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt new file mode 100644 index 0000000..eb084f3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt @@ -0,0 +1,17 @@ +package com.project.movienight.application.ports.output + +import java.time.OffsetDateTime + +interface JellyfinEventRepositoryPort { + fun save( + eventId: String, + serverId: String?, + eventType: String, + occurredAt: OffsetDateTime?, + jellyfinUserId: String?, + jellyfinItemId: String?, + payload: String?, + ): Int + + fun delete(eventId: String) +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt new file mode 100644 index 0000000..4113a7d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.ContentType + +data class JellyfinRemoteUser( + val id: String, + val name: String, +) + +data class JellyfinLibraryItemSnapshot( + val jellyfinItemId: String, + val title: String, + val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) + +interface JellyfinSyncSourcePort { + fun fetchUsers(): List + + fun fetchLibraryItems(userId: String): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 381f3bf..af4ed13 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,6 +1,5 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryCommand @@ -12,6 +11,7 @@ import com.project.movienight.application.ports.input.MarkFilmViewedCommand import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.domain.exception.DomainException @@ -25,7 +25,7 @@ import java.util.UUID class FilmLibraryService( private val filmLibraryRepository: FilmLibraryRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) : CreateFilmLibraryUseCase, AddFilmToLibraryUseCase, MarkFilmViewedUseCase, diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt index 738bada..54f7d72 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -1,9 +1,9 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.GetFilmRatingsUseCase import com.project.movienight.application.ports.input.RateFilmCommand import com.project.movienight.application.ports.input.RateFilmUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator @@ -19,7 +19,7 @@ class FilmRatingService( private val filmRepository: FilmRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) : RateFilmUseCase, GetFilmRatingsUseCase { override fun rate(command: RateFilmCommand): FilmRating { diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 64be033..d4fe6a0 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -1,10 +1,10 @@ package com.project.movienight.application.services import com.fasterxml.jackson.databind.ObjectMapper -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository import com.project.movienight.application.ports.input.MarkFilmViewedCommand import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort import org.springframework.stereotype.Service @@ -12,12 +12,12 @@ import java.time.OffsetDateTime @Service class JellyfinEventService( - private val jellyfinEventRepository: JellyfinEventRepository, + private val jellyfinEventRepository: JellyfinEventRepositoryPort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val objectMapper: ObjectMapper, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) { private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed") diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index 46faa88..a6776d1 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -1,13 +1,13 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.jellyfin.JellyfinApiClient -import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot -import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser -import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort +import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot +import com.project.movienight.application.ports.output.JellyfinRemoteUser +import com.project.movienight.application.ports.output.JellyfinSyncSourcePort import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType @@ -24,13 +24,13 @@ import java.time.LocalDateTime @Service class JellyfinSyncService( private val properties: JellyfinIntegrationProperties, - private val jellyfinApiClient: JellyfinApiClient, + private val jellyfinApiClient: JellyfinSyncSourcePort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, private val filmLibraryRepository: FilmLibraryRepositoryPort, private val syncStateRepository: JellyfinSyncStateRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) { @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") fun scheduledSync() { diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt deleted file mode 100644 index 142754a..0000000 --- a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.project.movienight.domain.model - -import java.util.UUID - -data class RecommendationContext( - val userId: UUID, - val contentType: ContentType? = null, - val mood: String? = null, - val libraryOnly: Boolean = false, - val limit: Int = 10, -) - -data class RecommendationResult( - val film: Film, - val score: Double, - val reasons: List, -) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt new file mode 100644 index 0000000..5b5b0a4 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt @@ -0,0 +1,7 @@ +package com.project.movienight.domain.model + +data class RecommendationResult( + val film: Film, + val score: Double, + val reasons: List, +) \ No newline at end of file diff --git a/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt new file mode 100644 index 0000000..75e222e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.web + +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.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import java.util.UUID + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +class RecommendationControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Test + fun `invalid content type should return bad request`() { + mockMvc + .get("/api/users/${UUID.randomUUID()}/recommendations") { + param("contentType", "INVALID") + }.andExpect { + status { isBadRequest() } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt new file mode 100644 index 0000000..ea8e84a --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt @@ -0,0 +1,56 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmRating +import com.project.movienight.domain.model.UserPreferences +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.UUID + +class RecommendationServiceTest { + private val filmRepository: FilmRepositoryPort = mockk() + private val filmLibraryRepository: FilmLibraryRepositoryPort = mockk() + private val filmRatingRepository: FilmRatingRepositoryPort = mockk() + private val userPreferencesRepository: UserPreferencesRepositoryPort = mockk() + private val businessMetricsPort: BusinessMetricsPort = mockk(relaxed = true) + + private val recommendationService = + RecommendationService( + filmRepository = filmRepository, + filmLibraryRepository = filmLibraryRepository, + filmRatingRepository = filmRatingRepository, + userPreferencesRepository = userPreferencesRepository, + businessMetricsService = businessMetricsPort, + ) + + @Test + fun `should rank highly rated films above poorly rated films`() { + val userId = UUID.randomUUID() + val lowRatedFilmId = UUID.randomUUID() + val highRatedFilmId = UUID.randomUUID() + + val lowRatedFilm = Film(id = lowRatedFilmId, title = "Low", description = "Low") + val highRatedFilm = Film(id = highRatedFilmId, title = "High", description = "High") + + every { userPreferencesRepository.findByUserId(userId) } returns UserPreferences(userId = userId) + every { filmRatingRepository.findByUserId(userId) } returns + listOf( + FilmRating(id = UUID.randomUUID(), userId = userId, filmId = lowRatedFilmId, score = 1), + FilmRating(id = UUID.randomUUID(), userId = userId, filmId = highRatedFilmId, score = 10), + ) + every { filmLibraryRepository.findAll() } returns emptyList() + every { filmRepository.findAll() } returns listOf(lowRatedFilm, highRatedFilm) + + val result = recommendationService.recommend(RecommendationQuery(userId = userId)) + + assertEquals(highRatedFilmId, result.first().film.id) + } +} \ No newline at end of file -- 2.54.0 From d98c4edf196e4e0e49b047e843566e8a7789d06d Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 11:22:10 +0300 Subject: [PATCH 77/77] feat(misc): added script to generate library for Jellyfin --- scripts/generate_library.py | 182 ++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 scripts/generate_library.py diff --git a/scripts/generate_library.py b/scripts/generate_library.py new file mode 100644 index 0000000..1f84920 --- /dev/null +++ b/scripts/generate_library.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + +import os +import sys +import urllib.request +import argparse +import logging +import re +import random + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + +DEFAULT_DATASET_URL = "https://raw.githubusercontent.com/sidooms/MovieTweetings/master/latest/movies.dat" +DEFAULT_OUTPUT_DIR = "./Jellyfin_Movies" +DEFAULT_COUNT = 1000 + +def download_dataset(url): + logger.info(f"Downloading dataset from {url}...") + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req) as response: + data = response.read().decode('utf-8') + logger.info("Dataset downloaded successfully.") + return data.splitlines() + except Exception as e: + logger.error(f"Failed to download dataset: {e}") + sys.exit(1) + +def parse_movies(lines): + """ + Parse the movies.dat file. + Format: IMDbID::Title (Year)::Genres + Example: 0000008::Edison Kinetoscopic Record of a Sneeze (1894)::Documentary|Short + """ + movies = [] + # Regex to extract Title and Year from "Title (Year)" + title_year_pattern = re.compile(r'(.*)\s+\((\d{4})\)$') + + for line in lines: + line = line.strip() + if not line: + continue + + parts = line.split('::') + if len(parts) >= 2: + imdb_id_raw = parts[0] + title_year_raw = parts[1] + + # Format IMDb ID to ttXXXXXXX + if imdb_id_raw.isdigit(): + imdb_id = f"tt{imdb_id_raw.zfill(7)}" + else: + continue + + match = title_year_pattern.match(title_year_raw) + if match: + title = match.group(1).strip() + year = match.group(2) + + # Clean title for filesystem (remove invalid characters) + safe_title = re.sub(r'[\\/*?:"<>|]', "", title) + safe_title = safe_title.strip() + + if safe_title: + movies.append({ + 'imdb_id': imdb_id, + 'title': safe_title, + 'year': year + }) + + logger.info(f"Parsed {len(movies)} valid movies from dataset.") + return movies + +def create_dummy_video(filepath): + """Create a minimal valid dummy video file (mp4).""" + try: + mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom\x00\x00\x00\x00moov\x00\x00\x00\x08mvhd" + with open(filepath, 'wb') as f: + f.write(mp4_header) + return True + except Exception as e: + logger.error(f"Failed to create dummy video {filepath}: {e}") + return False + +def generate_library(movies, output_dir, count): + """Generate the folder structure and dummy files.""" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + logger.info(f"Created output directory: {output_dir}") + + generated_imdb_ids = set() + created_count = 0 + skipped_count = 0 + failed_count = 0 + + logger.info(f"Starting generation of up to {count} movies...") + + # Shuffle to get diverse movies + random.shuffle(movies) + + for movie in movies: + if created_count >= count: + break + + if movie['imdb_id'] in generated_imdb_ids: + skipped_count += 1 + continue + + # Jellyfin naming convention: Movie Name (year) [imdbid-tt1234567] + folder_name = f"{movie['title']} ({movie['year']}) [imdbid-{movie['imdb_id']}]" + folder_path = os.path.join(output_dir, folder_name) + + file_name = f"{folder_name}.mp4" + file_path = os.path.join(folder_path, file_name) + + if os.path.exists(file_path): + skipped_count += 1 + generated_imdb_ids.add(movie['imdb_id']) + continue + + try: + os.makedirs(folder_path, exist_ok=True) + if create_dummy_video(file_path): + created_count += 1 + generated_imdb_ids.add(movie['imdb_id']) + else: + failed_count += 1 + except Exception as e: + logger.error(f"Error processing {folder_name}: {e}") + failed_count += 1 + + logger.info("--- Generation Summary ---") + logger.info(f"Target count: {count}") + logger.info(f"Successfully created: {created_count}") + logger.info(f"Skipped (already exists or duplicate): {skipped_count}") + logger.info(f"Failed: {failed_count}") + + return created_count > 0 + +def main(): + parser = argparse.ArgumentParser(description="Generate a dummy Jellyfin movie library.") + parser.add_argument("--output-dir", type=str, default=DEFAULT_OUTPUT_DIR, + help=f"Directory to create the library in (default: {DEFAULT_OUTPUT_DIR})") + parser.add_argument("--count", type=int, default=DEFAULT_COUNT, + help=f"Number of movies to generate (default: {DEFAULT_COUNT})") + parser.add_argument("--dataset-url", type=str, default=DEFAULT_DATASET_URL, + help="URL to the movies.dat file") + + args = parser.parse_args() + + lines = download_dataset(args.dataset_url) + if not lines: + logger.error("No dataset lines to process.") + sys.exit(1) + + movies = parse_movies(lines) + + if not movies: + logger.error("No movies parsed from the dataset.") + sys.exit(1) + + if len(movies) < args.count: + logger.warning(f"Requested {args.count} movies, but only {len(movies)} available.") + args.count = len(movies) + + success = generate_library(movies, args.output_dir, args.count) + + if success: + logger.info(f"Library generation complete. You can now mount '{os.path.abspath(args.output_dir)}' into Jellyfin.") + else: + logger.error("Library generation failed.") + sys.exit(1) + +if __name__ == "__main__": + main() -- 2.54.0