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