diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..8cd8d59 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,54 @@ +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 + permissions: + contents: read + 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: 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/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..ece7eb1 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,86 @@ +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: + 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 + packages: write + with: + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + secrets: inherit + + notify-main: + name: Notify Main Build + needs: docker + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Send Telegram notification + env: + TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + DIGEST: ${{ needs.docker.outputs.image-digest }} + run: | + 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 + curl -sf -X POST \ + "https://api.telegram.org/bot${TOKEN}/sendMessage" \ + -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 diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..0854d36 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,77 @@ +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: Build image (no push) + if: inputs.push == false + uses: docker/build-push-action@v7 + with: + context: . + file: ./Containerfile + push: false + provenance: false + cache-from: type=gha + cache-to: type=gha,mode=max + + - 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: ./Containerfile + 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/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..0fdfcb1 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,58 @@ +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: | + 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: + 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}" 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/.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 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..72e9333 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" @@ -56,6 +58,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/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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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 new file mode 100644 index 0000000..58e2c3c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -0,0 +1,41 @@ +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 jellyfinUserId: String?, + val createdAt: LocalDateTime, +) + +fun UserEntity.toDomain(): User = + User( + id = id, + name = name, + email = email, + library = null, + preferences = null, + jellyfinUserId = jellyfinUserId, + ) + +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, + 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 }), + ) 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 a6607e5..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 @@ -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,87 @@ 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"), + jellyfinUserId = rs.getString("jellyfin_user_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 = ?, jellyfin_user_id = ? WHERE id = ? """.trimIndent(), - user.name, - user.email, - user.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email) - VALUES (?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), - user.id, - user.name, - user.email, + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + 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, jellyfin_user_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, - ) + jdbc + .query( + "SELECT id, name, email, provider, provider_id, jellyfin_user_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, jellyfin_user_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/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) +} 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/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/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11017d1..e98c8ff 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,13 +1,27 @@ 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), + 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 ( @@ -16,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); 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..cdf0c42 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -0,0 +1,82 @@ +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) + } +} 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..d880f02 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt @@ -0,0 +1,220 @@ +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..3c38a23 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt @@ -0,0 +1,153 @@ +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..02b588a --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -0,0 +1,160 @@ +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/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..1556f83 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -0,0 +1,301 @@ +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()) } + } +} 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