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 a5b9744..f20ddff 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" @@ -57,6 +59,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/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/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt new file mode 100644 index 0000000..0beda74 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -0,0 +1,37 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import java.time.LocalDateTime +import java.util.UUID + +data class UserEntity( + val id: UUID, + val name: String, + val email: String, + val provider: String?, + val providerId: String?, + val createdAt: LocalDateTime, +) + +fun UserEntity.toDomain(): User = + User( + id = id, + name = name, + email = email, + library = null, + ) + +fun User.toEntity( + provider: AuthProvider? = null, + providerId: String? = null, + createdAt: LocalDateTime = LocalDateTime.now(), +): UserEntity = + UserEntity( + id = id, + name = name, + email = email, + provider = provider?.name, + providerId = providerId, + createdAt = createdAt, + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 420dbd0..cb866d9 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,8 +15,8 @@ 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"), @@ -22,6 +26,7 @@ class UserRepository( } override fun save(user: User): User { + val entity = user.toEntity() val updatedRows = jdbc.update( """ @@ -50,13 +55,13 @@ class UserRepository( } override fun findById(id: UUID): User? { - val users = + val entities = jdbc.query( "SELECT id, name, email, password FROM users WHERE id = ?", userRowMapper, id, ) - return users.firstOrNull() + return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { 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 0e45c1e..678cea0 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -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 @@ -17,4 +18,9 @@ interface UserRepositoryPort { fun findAll(): List fun deleteById(id: UUID) + + fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? } diff --git a/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt b/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt new file mode 100644 index 0000000..4926b93 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt @@ -0,0 +1,7 @@ +package com.project.movienight.domain.model + +enum class AuthProvider { + GOOGLE, + YANDEX, + VK, +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt new file mode 100644 index 0000000..bec7f20 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -0,0 +1,81 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UserEntityMappingTest { + + @Test + fun `toDomain maps UserEntity correctly`() { + val entity = UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) + val user = entity.toDomain() + + assertEquals(entity.id, user.id) + assertEquals(entity.name, user.name) + assertEquals(entity.email, user.email) + assertNull(user.library) + } + + @Test + fun `toEntity maps User with OAuth provider`() { + val user = User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null + ) + + val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") + + assertEquals(user.id, entity.id) + assertEquals(user.name, entity.name) + assertEquals(user.email, entity.email) + assertEquals("YANDEX", entity.provider) + assertEquals("yandex456", entity.providerId) + } + + @Test + fun `toEntity maps User without OAuth provider`() { + val user = User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null + ) + + val entity = user.toEntity() + + assertNull(entity.provider) + assertNull(entity.providerId) + } + + @Test + fun `mapping is reversible for basic fields`() { + val original = User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null + ) + + val mapped = original.toEntity().toDomain() + + assertEquals(original.id, mapped.id) + assertEquals(original.name, mapped.name) + assertEquals(original.email, mapped.email) + } + + +} 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