From 05e8df5d66dd901d26c881c77be0dcdc9d8785f4 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:31:41 +0100 Subject: [PATCH 01/24] Add test endpoint. --- build.gradle.kts | 5 +++-- .../accommodation/AccommodationApplication.java | 3 ++- .../accommodation/controller/TestController.java | 15 +++++++++++++++ src/main/resources/application.properties | 1 + 4 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/devoops/accommodation/controller/TestController.java diff --git a/build.gradle.kts b/build.gradle.kts index bf4a4a7..54218b4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,12 +20,13 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-actuator") + //implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.flywaydb:flyway-database-postgresql") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") - testImplementation("org.springframework.boot:spring-boot-starter-security-test") + //testImplementation("org.springframework.boot:spring-boot-starter-security-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/main/java/com/devoops/accommodation/AccommodationApplication.java b/src/main/java/com/devoops/accommodation/AccommodationApplication.java index 498a89a..161bcf8 100644 --- a/src/main/java/com/devoops/accommodation/AccommodationApplication.java +++ b/src/main/java/com/devoops/accommodation/AccommodationApplication.java @@ -2,8 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -@SpringBootApplication +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) public class AccommodationApplication { public static void main(String[] args) { diff --git a/src/main/java/com/devoops/accommodation/controller/TestController.java b/src/main/java/com/devoops/accommodation/controller/TestController.java new file mode 100644 index 0000000..bd7bb96 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/controller/TestController.java @@ -0,0 +1,15 @@ +package com.devoops.accommodation.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/accommodation") +public class TestController { + + @GetMapping("test") + public String test() { + return "Accommodation Service is up and running!"; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8e8f0d6..5484595 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=accommodation +server.port=8082 From 06615ea62977a2310e0e8488c8e340c743dc43cc Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:00:10 +0100 Subject: [PATCH 02/24] feat: Add Dockerfile --- .gitignore | 5 ++++- Dockerfile | 12 ++++++++++++ gradlew | 0 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 Dockerfile mode change 100644 => 100755 gradlew diff --git a/.gitignore b/.gitignore index df0b53a..eac1f4d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ ### Mac ### -.DS_Store \ No newline at end of file +.DS_Store + +### Environment variables ### +*.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7440a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM eclipse-temurin:25-jre-alpine + +WORKDIR /app + +COPY build/libs/*SNAPSHOT.jar app.jar + +RUN addgroup -S spring && adduser -S spring -G spring +USER spring:spring + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 1f1d45eb35ffc408e633b99cddd44736668a5eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 17 Jan 2026 22:57:36 +0100 Subject: [PATCH 03/24] feat: Add Logstash logging integration and enhance logging Integrated Logstash Logback encoder for structured logging by updating dependencies and adding logback-spring.xml. Enhanced TestController with SLF4J logging and MDC for request tracing. Updated application.properties for logging configuration and added logback-test.xml for test logging setup. --- build.gradle.kts | 1 + .../controller/TestController.java | 23 +++++- src/main/resources/application.properties | 6 ++ src/main/resources/logback-spring.xml | 75 +++++++++++++++++++ src/test/resources/logback-test.xml | 18 +++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/resources/logback-test.xml diff --git a/build.gradle.kts b/build.gradle.kts index 54218b4..94fbd22 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { //implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.flywaydb:flyway-database-postgresql") + implementation("net.logstash.logback:logstash-logback-encoder:8.0") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") //testImplementation("org.springframework.boot:spring-boot-starter-security-test") diff --git a/src/main/java/com/devoops/accommodation/controller/TestController.java b/src/main/java/com/devoops/accommodation/controller/TestController.java index bd7bb96..811ac67 100644 --- a/src/main/java/com/devoops/accommodation/controller/TestController.java +++ b/src/main/java/com/devoops/accommodation/controller/TestController.java @@ -1,15 +1,36 @@ package com.devoops.accommodation.controller; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RestController @RequestMapping("api/accommodation") public class TestController { + private static final Logger logger = LoggerFactory.getLogger(TestController.class); + @GetMapping("test") public String test() { - return "Accommodation Service is up and running!"; + String requestId = UUID.randomUUID().toString(); + MDC.put("requestId", requestId); + + try { + logger.info("Test endpoint called - Accommodation Service health check"); + logger.debug("Processing test request with ID: {}", requestId); + + String response = "Accommodation Service is up and running!"; + + logger.info("Test endpoint successfully processed request {}", requestId); + return response; + + } finally { + MDC.remove("requestId"); + } } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5484595..565f0b4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,8 @@ spring.application.name=accommodation server.port=8082 + +# Logging configuration +logging.logstash.host=${LOGSTASH_HOST:localhost:5000} +logging.level.root=INFO +logging.level.com.devoops=DEBUG +logging.level.org.springframework.web=INFO \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..4529bcb --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,75 @@ + + + + + + + + + {"service.name":"${applicationName}","host.name":"${HOSTNAME:-unknown}"} + + @timestamp + message + logger + thread + level + + + + + + + ${logstashHost} + + {"service.name":"${applicationName}","host.name":"${HOSTNAME:-unknown}"} + + @timestamp + message + logger + thread + level + [ignore] + + requestId + userId + + + + + + + 512 + 0 + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..8c647eb --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + From 48b26521be6f56331e3203e0cd3193de7503248f Mon Sep 17 00:00:00 2001 From: Dusan Date: Sat, 17 Jan 2026 22:07:01 +0100 Subject: [PATCH 04/24] feat: Add zipkin tracing --- build.gradle.kts | 8 +++++++- src/main/resources/application.properties | 7 ++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 94fbd22..1f0d792 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,7 +25,13 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.flywaydb:flyway-database-postgresql") implementation("net.logstash.logback:logstash-logback-encoder:8.0") - runtimeOnly("org.postgresql:postgresql") + //zipkin(tracing) + implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") + implementation("org.springframework.boot:spring-boot-starter-zipkin") + implementation("io.micrometer:micrometer-tracing-bridge-brave") + implementation("io.zipkin.reporter2:zipkin-reporter-brave") + + runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") //testImplementation("org.springframework.boot:spring-boot-starter-security-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 565f0b4..d1790b9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,9 @@ spring.application.name=accommodation -server.port=8082 - +server.port=${SERVER_PORT:8080} # Logging configuration logging.logstash.host=${LOGSTASH_HOST:localhost:5000} logging.level.root=INFO logging.level.com.devoops=DEBUG -logging.level.org.springframework.web=INFO \ No newline at end of file +logging.level.org.springframework.web=INFO +management.tracing.sampling.probability=1.0 +management.tracing.export.zipkin.endpoint=${ZIPKIN_HOST:http://zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans From 881f06f1cbc4ada57ee6069b7470b84d4585473b Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:09:51 +0100 Subject: [PATCH 05/24] fix: Remove local environment variables from gitignore. --- .gitignore | 3 --- environment/.local.env | 5 +++++ src/main/resources/application.properties | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 environment/.local.env diff --git a/.gitignore b/.gitignore index eac1f4d..070c2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,3 @@ out/ ### Mac ### .DS_Store - -### Environment variables ### -*.env \ No newline at end of file diff --git a/environment/.local.env b/environment/.local.env new file mode 100644 index 0000000..67ed5c9 --- /dev/null +++ b/environment/.local.env @@ -0,0 +1,5 @@ +SERVER_PORT=8080 +LOGSTASH_HOST=logstash:5000 +ZIPKIN_HOST=zipkin +ZIPKIN_PORT=9411 + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d1790b9..84e3cda 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -6,4 +6,4 @@ logging.level.root=INFO logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO management.tracing.sampling.probability=1.0 -management.tracing.export.zipkin.endpoint=${ZIPKIN_HOST:http://zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans +management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans From d7611e41e98076cdc93daa3734d3305c40e78794 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:43:15 +0100 Subject: [PATCH 06/24] feat: Add Prometheus Integration --- build.gradle.kts | 2 ++ src/main/resources/application.properties | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 1f0d792..698ecb9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-zipkin") implementation("io.micrometer:micrometer-tracing-bridge-brave") implementation("io.zipkin.reporter2:zipkin-reporter-brave") + //prometheus(metrics) + implementation("io.micrometer:micrometer-registry-prometheus") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 84e3cda..912e3e1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,5 +5,10 @@ logging.logstash.host=${LOGSTASH_HOST:localhost:5000} logging.level.root=INFO logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO +# Tracing configuration management.tracing.sampling.probability=1.0 management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans +# Actuator endpoints configuration +management.endpoints.web.exposure.include=health,info,prometheus +management.endpoint.health.show-details=always +management.prometheus.metrics.export.enabled=true From d3e79a3a95dba442e39e9f5670c7b6eb575948cd Mon Sep 17 00:00:00 2001 From: Dusan Date: Mon, 9 Feb 2026 23:16:36 +0100 Subject: [PATCH 07/24] feat: Add CI pipeline --- .github/workflows/ci.yml | 92 ++++++++++++++++++++++++++++++++++ .github/workflows/pr-check.yml | 27 ++++++++++ 2 files changed, 119 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-check.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..81b5476 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI Pipeline + +on: + push: + branches: + - develop + - main + tags: + - 'v*' + + +env: + DOCKERHUB_USERNAME: threeamigoscoding + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Run tests + run: ./gradlew test + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: build-artifact + path: build/libs/*.jar + retention-days: 1 + + publish: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: build-artifact + path: build/libs + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate version tag + id: version + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION_TAG=${GITHUB_REF#refs/tags/v} + elif [[ $GITHUB_REF == refs/heads/main ]]; then + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION_TAG="main-${SHORT_SHA}" + else + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION_TAG="develop-${SHORT_SHA}" + fi + echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT + echo "Generated version tag: $VERSION_TAG" + + - name: Build and push Docker image + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 + with: + context: . + push: true + tags: | + ${{ env.DOCKERHUB_USERNAME }}/devoops-accommodation-service:${{ steps.version.outputs.tag }} + ${{ env.DOCKERHUB_USERNAME }}/devoops-accommodation-service:latest + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..e6f3777 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,27 @@ +name: PR Check + +on: + pull_request: + branches: + - develop + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build with Gradle + run: ./gradlew clean build \ No newline at end of file From 089be864983fbf9241b00abcc7156efb539998b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Wed, 11 Feb 2026 23:20:13 +0100 Subject: [PATCH 08/24] feat: Add accommodation API, domain & persistence Add accommodation feature surface: REST controller, service, DTOs, MapStruct mapper, JPA entities (Accommodation, Amenity) and enums, repositories, and related exceptions/exception handler. Introduce request argument resolver and role-based HandlerInterceptor (RequireRole, UserContext, UserContextResolver, RoleAuthorizationInterceptor) and WebConfig to register them. Add Flyway migration to create DB types/tables and indexes. Update build.gradle.kts to include web, JPA, Postgres, Flyway, MapStruct, Lombok, Prometheus, Zipkin/tracing and test deps. Add DB configuration to application.properties and local env variables. Also remove DataSourceAutoConfiguration exclusion and change main() visibility; update tests by removing @SpringBootTest. --- build.gradle.kts | 48 +++++-- environment/.local.env | 5 +- .../AccommodationApplication.java | 5 +- .../accommodation/config/RequireRole.java | 12 ++ .../config/RoleAuthorizationInterceptor.java | 50 +++++++ .../accommodation/config/UserContext.java | 5 + .../config/UserContextResolver.java | 41 ++++++ .../accommodation/config/WebConfig.java | 27 ++++ .../controller/AccommodationController.java | 59 +++++++++ .../request/CreateAccommodationRequest.java | 35 +++++ .../request/UpdateAccommodationRequest.java | 27 ++++ .../dto/response/AccommodationResponse.java | 24 ++++ .../accommodation/entity/Accommodation.java | 65 +++++++++ .../devoops/accommodation/entity/Amenity.java | 29 ++++ .../accommodation/entity/AmenityType.java | 14 ++ .../accommodation/entity/ApprovalMode.java | 6 + .../accommodation/entity/BaseEntity.java | 36 +++++ .../accommodation/entity/PricingMode.java | 6 + .../AccommodationNotFoundException.java | 8 ++ .../exception/ForbiddenException.java | 8 ++ .../exception/GlobalExceptionHandler.java | 48 +++++++ .../exception/UnauthorizedException.java | 8 ++ .../mapper/AccommodationMapper.java | 39 ++++++ .../repository/AccommodationRepository.java | 12 ++ .../repository/AmenityRepository.java | 9 ++ .../service/AccommodationService.java | 125 ++++++++++++++++++ src/main/resources/application.properties | 17 +++ .../db/migration/V1__init_schema.sql | 38 ++++++ .../AccommodationApplicationTests.java | 2 - 29 files changed, 788 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/devoops/accommodation/config/RequireRole.java create mode 100644 src/main/java/com/devoops/accommodation/config/RoleAuthorizationInterceptor.java create mode 100644 src/main/java/com/devoops/accommodation/config/UserContext.java create mode 100644 src/main/java/com/devoops/accommodation/config/UserContextResolver.java create mode 100644 src/main/java/com/devoops/accommodation/config/WebConfig.java create mode 100644 src/main/java/com/devoops/accommodation/controller/AccommodationController.java create mode 100644 src/main/java/com/devoops/accommodation/dto/request/CreateAccommodationRequest.java create mode 100644 src/main/java/com/devoops/accommodation/dto/request/UpdateAccommodationRequest.java create mode 100644 src/main/java/com/devoops/accommodation/dto/response/AccommodationResponse.java create mode 100644 src/main/java/com/devoops/accommodation/entity/Accommodation.java create mode 100644 src/main/java/com/devoops/accommodation/entity/Amenity.java create mode 100644 src/main/java/com/devoops/accommodation/entity/AmenityType.java create mode 100644 src/main/java/com/devoops/accommodation/entity/ApprovalMode.java create mode 100644 src/main/java/com/devoops/accommodation/entity/BaseEntity.java create mode 100644 src/main/java/com/devoops/accommodation/entity/PricingMode.java create mode 100644 src/main/java/com/devoops/accommodation/exception/AccommodationNotFoundException.java create mode 100644 src/main/java/com/devoops/accommodation/exception/ForbiddenException.java create mode 100644 src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/devoops/accommodation/exception/UnauthorizedException.java create mode 100644 src/main/java/com/devoops/accommodation/mapper/AccommodationMapper.java create mode 100644 src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java create mode 100644 src/main/java/com/devoops/accommodation/repository/AmenityRepository.java create mode 100644 src/main/java/com/devoops/accommodation/service/AccommodationService.java create mode 100644 src/main/resources/db/migration/V1__init_schema.sql diff --git a/build.gradle.kts b/build.gradle.kts index 698ecb9..39bd436 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,24 +19,44 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.springframework.boot:spring-boot-starter-actuator") - //implementation("org.springframework.boot:spring-boot-starter-security") + // Web and Core implementation("org.springframework.boot:spring-boot-starter-webmvc") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") + + + // Prometheus + implementation("io.micrometer:micrometer-registry-prometheus") + + // Database + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") + + // Lombok + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + // MapStruct + implementation("org.mapstruct:mapstruct:1.6.3") + annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") + annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") + + // Tracing (Zipkin) + implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") + implementation("org.springframework.boot:spring-boot-starter-zipkin") + implementation("io.micrometer:micrometer-tracing-bridge-brave") + implementation("io.zipkin.reporter2:zipkin-reporter-brave") + + // Logging implementation("net.logstash.logback:logstash-logback-encoder:8.0") - //zipkin(tracing) - implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") - implementation("org.springframework.boot:spring-boot-starter-zipkin") - implementation("io.micrometer:micrometer-tracing-bridge-brave") - implementation("io.zipkin.reporter2:zipkin-reporter-brave") - //prometheus(metrics) - implementation("io.micrometer:micrometer-registry-prometheus") - - runtimeOnly("org.postgresql:postgresql") - testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") - //testImplementation("org.springframework.boot:spring-boot-starter-security-test") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/environment/.local.env b/environment/.local.env index 67ed5c9..9b21147 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -2,4 +2,7 @@ SERVER_PORT=8080 LOGSTASH_HOST=logstash:5000 ZIPKIN_HOST=zipkin ZIPKIN_PORT=9411 - +POSTGRES_HOST=devoops-postgres +POSGTES_PORT=5432 +DB_USERNAME=accommodation-service +DB_PASSWORD=accommodation-service-pass diff --git a/src/main/java/com/devoops/accommodation/AccommodationApplication.java b/src/main/java/com/devoops/accommodation/AccommodationApplication.java index 161bcf8..019cb9b 100644 --- a/src/main/java/com/devoops/accommodation/AccommodationApplication.java +++ b/src/main/java/com/devoops/accommodation/AccommodationApplication.java @@ -2,12 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +@SpringBootApplication public class AccommodationApplication { - public static void main(String[] args) { + static void main(String[] args) { SpringApplication.run(AccommodationApplication.class, args); } diff --git a/src/main/java/com/devoops/accommodation/config/RequireRole.java b/src/main/java/com/devoops/accommodation/config/RequireRole.java new file mode 100644 index 0000000..34be880 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/config/RequireRole.java @@ -0,0 +1,12 @@ +package com.devoops.accommodation.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireRole { + String[] value(); +} \ No newline at end of file diff --git a/src/main/java/com/devoops/accommodation/config/RoleAuthorizationInterceptor.java b/src/main/java/com/devoops/accommodation/config/RoleAuthorizationInterceptor.java new file mode 100644 index 0000000..957c348 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/config/RoleAuthorizationInterceptor.java @@ -0,0 +1,50 @@ +package com.devoops.accommodation.config; + +import com.devoops.accommodation.exception.ForbiddenException; +import com.devoops.accommodation.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Arrays; + +@Component +public class RoleAuthorizationInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) + { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + RequireRole methodAnnotation = handlerMethod.getMethodAnnotation(RequireRole.class); + RequireRole classAnnotation = handlerMethod.getBeanType().getAnnotation(RequireRole.class); + + RequireRole requireRole = methodAnnotation != null ? methodAnnotation : classAnnotation; + if (requireRole == null) { + return true; + } + + String role = request.getHeader("X-User-Role"); + if (role == null) { + throw new UnauthorizedException("Missing authentication headers"); + } + + boolean hasRole = Arrays.stream(requireRole.value()) + .anyMatch(r -> r.equalsIgnoreCase(role)); + + if (!hasRole) { + throw new ForbiddenException("Insufficient permissions"); + } + + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/accommodation/config/UserContext.java b/src/main/java/com/devoops/accommodation/config/UserContext.java new file mode 100644 index 0000000..27d4774 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/config/UserContext.java @@ -0,0 +1,5 @@ +package com.devoops.accommodation.config; + +import java.util.UUID; + +public record UserContext(UUID userId, String role) { } diff --git a/src/main/java/com/devoops/accommodation/config/UserContextResolver.java b/src/main/java/com/devoops/accommodation/config/UserContextResolver.java new file mode 100644 index 0000000..9924bf8 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/config/UserContextResolver.java @@ -0,0 +1,41 @@ +package com.devoops.accommodation.config; + +import com.devoops.accommodation.exception.UnauthorizedException; +import org.jspecify.annotations.NonNull; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.UUID; + +public class UserContextResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return UserContext.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument( + @NonNull MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) + { + String userId = webRequest.getHeader("X-User-Id"); + String role = webRequest.getHeader("X-User-Role"); + + if (userId == null || role == null) { + throw new UnauthorizedException("Missing authentication headers"); + } + + try { + return new UserContext(UUID.fromString(userId), role); + } catch (IllegalArgumentException e) { + throw new UnauthorizedException("Invalid user ID format"); + } + } +} diff --git a/src/main/java/com/devoops/accommodation/config/WebConfig.java b/src/main/java/com/devoops/accommodation/config/WebConfig.java new file mode 100644 index 0000000..51dc1eb --- /dev/null +++ b/src/main/java/com/devoops/accommodation/config/WebConfig.java @@ -0,0 +1,27 @@ +package com.devoops.accommodation.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final RoleAuthorizationInterceptor roleAuthorizationInterceptor; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new UserContextResolver()); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(roleAuthorizationInterceptor); + } + +} \ No newline at end of file diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java new file mode 100644 index 0000000..805971c --- /dev/null +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java @@ -0,0 +1,59 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RequireRole; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAccommodationRequest; +import com.devoops.accommodation.dto.request.UpdateAccommodationRequest; +import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.service.AccommodationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/accommodation") +@RequiredArgsConstructor +public class AccommodationController { + + private final AccommodationService accommodationService; + + @PostMapping + @RequireRole("HOST") + public ResponseEntity create( + @Valid @RequestBody CreateAccommodationRequest request, + UserContext userContext) { + AccommodationResponse response = accommodationService.create(request, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable UUID id) { + return ResponseEntity.ok(accommodationService.getById(id)); + } + + @GetMapping("/host/{hostId}") + public ResponseEntity> getByHostId(@PathVariable UUID hostId) { + return ResponseEntity.ok(accommodationService.getByHostId(hostId)); + } + + @PutMapping("/{id}") + @RequireRole("HOST") + public ResponseEntity update( + @PathVariable UUID id, + @Valid @RequestBody UpdateAccommodationRequest request, + UserContext userContext) { + return ResponseEntity.ok(accommodationService.update(id, request, userContext)); + } + + @DeleteMapping("/{id}") + @RequireRole("HOST") + public ResponseEntity delete(@PathVariable UUID id, UserContext userContext) { + accommodationService.delete(id, userContext); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/devoops/accommodation/dto/request/CreateAccommodationRequest.java b/src/main/java/com/devoops/accommodation/dto/request/CreateAccommodationRequest.java new file mode 100644 index 0000000..9100390 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/request/CreateAccommodationRequest.java @@ -0,0 +1,35 @@ +package com.devoops.accommodation.dto.request; + +import com.devoops.accommodation.entity.AmenityType; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.Set; + +public record CreateAccommodationRequest( + @NotBlank(message = "Name is required") + String name, + + @NotBlank(message = "Address is required") + String address, + + @NotNull(message = "Minimum guests is required") + @Min(value = 1, message = "Minimum guests must be at least 1") + Integer minGuests, + + @NotNull(message = "Maximum guests is required") + @Min(value = 1, message = "Maximum guests must be at least 1") + Integer maxGuests, + + @NotNull(message = "Pricing mode is required") + PricingMode pricingMode, + + @NotNull(message = "Approval mode is required") + ApprovalMode approvalMode, + + Set amenities +) { +} diff --git a/src/main/java/com/devoops/accommodation/dto/request/UpdateAccommodationRequest.java b/src/main/java/com/devoops/accommodation/dto/request/UpdateAccommodationRequest.java new file mode 100644 index 0000000..131d73b --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/request/UpdateAccommodationRequest.java @@ -0,0 +1,27 @@ +package com.devoops.accommodation.dto.request; + +import com.devoops.accommodation.entity.AmenityType; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; +import jakarta.validation.constraints.Min; + +import java.util.Set; + +public record UpdateAccommodationRequest( + String name, + + String address, + + @Min(value = 1, message = "Minimum guests must be at least 1") + Integer minGuests, + + @Min(value = 1, message = "Maximum guests must be at least 1") + Integer maxGuests, + + PricingMode pricingMode, + + ApprovalMode approvalMode, + + Set amenities +) { +} diff --git a/src/main/java/com/devoops/accommodation/dto/response/AccommodationResponse.java b/src/main/java/com/devoops/accommodation/dto/response/AccommodationResponse.java new file mode 100644 index 0000000..f72709f --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/response/AccommodationResponse.java @@ -0,0 +1,24 @@ +package com.devoops.accommodation.dto.response; + +import com.devoops.accommodation.entity.AmenityType; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record AccommodationResponse( + UUID id, + UUID hostId, + String name, + String address, + int minGuests, + int maxGuests, + PricingMode pricingMode, + ApprovalMode approvalMode, + List amenities, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/devoops/accommodation/entity/Accommodation.java b/src/main/java/com/devoops/accommodation/entity/Accommodation.java new file mode 100644 index 0000000..b3e8dda --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/Accommodation.java @@ -0,0 +1,65 @@ +package com.devoops.accommodation.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.SQLRestriction; +import org.hibernate.type.SqlTypes; + +import lombok.Builder; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "accommodations") +@SQLRestriction("is_deleted = false") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public class Accommodation extends BaseEntity { + + @Column(nullable = false) + private UUID hostId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String address; + + @Column(nullable = false) + private int minGuests; + + @Column(nullable = false) + private int maxGuests; + + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(nullable = false, columnDefinition = "pricing_mode") + private PricingMode pricingMode; + + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(nullable = false, columnDefinition = "approval_mode") + private ApprovalMode approvalMode; + + @Builder.Default + @OneToMany(mappedBy = "accommodation", cascade = CascadeType.ALL, orphanRemoval = true) + private List amenities = new ArrayList<>(); + + public void addAmenity(Amenity amenity) { + amenities.add(amenity); + amenity.setAccommodation(this); + } + + public void removeAmenity(Amenity amenity) { + amenities.remove(amenity); + amenity.setAccommodation(null); + } +} diff --git a/src/main/java/com/devoops/accommodation/entity/Amenity.java b/src/main/java/com/devoops/accommodation/entity/Amenity.java new file mode 100644 index 0000000..29137e3 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/Amenity.java @@ -0,0 +1,29 @@ +package com.devoops.accommodation.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.SQLRestriction; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "amenities") +@SQLRestriction("is_deleted = false") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public class Amenity extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "accommodation_id", nullable = false) + private Accommodation accommodation; + + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(nullable = false, columnDefinition = "amenity_type") + private AmenityType type; +} diff --git a/src/main/java/com/devoops/accommodation/entity/AmenityType.java b/src/main/java/com/devoops/accommodation/entity/AmenityType.java new file mode 100644 index 0000000..b120d49 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/AmenityType.java @@ -0,0 +1,14 @@ +package com.devoops.accommodation.entity; + +public enum AmenityType { + WIFI, + KITCHEN, + AC, + PARKING, + FREE_PARKING, + POOL, + TV, + WASHING_MACHINE, + HEATING, + BALCONY +} diff --git a/src/main/java/com/devoops/accommodation/entity/ApprovalMode.java b/src/main/java/com/devoops/accommodation/entity/ApprovalMode.java new file mode 100644 index 0000000..c1a5fc8 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/ApprovalMode.java @@ -0,0 +1,6 @@ +package com.devoops.accommodation.entity; + +public enum ApprovalMode { + AUTOMATIC, + MANUAL +} diff --git a/src/main/java/com/devoops/accommodation/entity/BaseEntity.java b/src/main/java/com/devoops/accommodation/entity/BaseEntity.java new file mode 100644 index 0000000..d47647c --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/BaseEntity.java @@ -0,0 +1,36 @@ +package com.devoops.accommodation.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @Builder.Default + @Column(nullable = false) + private boolean isDeleted = false; +} diff --git a/src/main/java/com/devoops/accommodation/entity/PricingMode.java b/src/main/java/com/devoops/accommodation/entity/PricingMode.java new file mode 100644 index 0000000..e356f20 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/PricingMode.java @@ -0,0 +1,6 @@ +package com.devoops.accommodation.entity; + +public enum PricingMode { + PER_GUEST, + PER_UNIT +} diff --git a/src/main/java/com/devoops/accommodation/exception/AccommodationNotFoundException.java b/src/main/java/com/devoops/accommodation/exception/AccommodationNotFoundException.java new file mode 100644 index 0000000..90a2768 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/AccommodationNotFoundException.java @@ -0,0 +1,8 @@ +package com.devoops.accommodation.exception; + +public class AccommodationNotFoundException extends RuntimeException { + + public AccommodationNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/ForbiddenException.java b/src/main/java/com/devoops/accommodation/exception/ForbiddenException.java new file mode 100644 index 0000000..cf74594 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package com.devoops.accommodation.exception; + +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..100e06d --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package com.devoops.accommodation.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(AccommodationNotFoundException.class) + public ProblemDetail handleNotFound(AccommodationNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(UnauthorizedException.class) + public ProblemDetail handleUnauthorized(UnauthorizedException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(ForbiddenException.class) + public ProblemDetail handleForbidden(ForbiddenException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed"); + Map fieldErrors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.toMap( + FieldError::getField, + fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value", + (a, b) -> a + )); + problemDetail.setProperty("fieldErrors", fieldErrors); + return problemDetail; + } + + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/UnauthorizedException.java b/src/main/java/com/devoops/accommodation/exception/UnauthorizedException.java new file mode 100644 index 0000000..82550b7 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package com.devoops.accommodation.exception; + +public class UnauthorizedException extends RuntimeException { + + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/mapper/AccommodationMapper.java b/src/main/java/com/devoops/accommodation/mapper/AccommodationMapper.java new file mode 100644 index 0000000..bf4a574 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/mapper/AccommodationMapper.java @@ -0,0 +1,39 @@ +package com.devoops.accommodation.mapper; + +import com.devoops.accommodation.dto.request.CreateAccommodationRequest; +import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.Amenity; +import com.devoops.accommodation.entity.AmenityType; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface AccommodationMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "hostId", ignore = true) + @Mapping(target = "amenities", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "isDeleted", ignore = true) + Accommodation toEntity(CreateAccommodationRequest request); + + @Mapping(target = "amenities", source = "amenities", qualifiedByName = "mapAmenities") + AccommodationResponse toResponse(Accommodation accommodation); + + List toResponseList(List accommodations); + + @Named("mapAmenities") + default List mapAmenities(List amenities) { + if (amenities == null) { + return List.of(); + } + return amenities.stream() + .map(Amenity::getType) + .toList(); + } +} diff --git a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java new file mode 100644 index 0000000..6b7b807 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java @@ -0,0 +1,12 @@ +package com.devoops.accommodation.repository; + +import com.devoops.accommodation.entity.Accommodation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AccommodationRepository extends JpaRepository { + + List findByHostId(UUID hostId); +} diff --git a/src/main/java/com/devoops/accommodation/repository/AmenityRepository.java b/src/main/java/com/devoops/accommodation/repository/AmenityRepository.java new file mode 100644 index 0000000..67ec853 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/repository/AmenityRepository.java @@ -0,0 +1,9 @@ +package com.devoops.accommodation.repository; + +import com.devoops.accommodation.entity.Amenity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface AmenityRepository extends JpaRepository { +} diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationService.java b/src/main/java/com/devoops/accommodation/service/AccommodationService.java new file mode 100644 index 0000000..64e97d2 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/service/AccommodationService.java @@ -0,0 +1,125 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAccommodationRequest; +import com.devoops.accommodation.dto.request.UpdateAccommodationRequest; +import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.Amenity; +import com.devoops.accommodation.entity.AmenityType; +import com.devoops.accommodation.exception.AccommodationNotFoundException; +import com.devoops.accommodation.exception.ForbiddenException; +import com.devoops.accommodation.mapper.AccommodationMapper; +import com.devoops.accommodation.repository.AccommodationRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AccommodationService { + + private final AccommodationRepository accommodationRepository; + private final AccommodationMapper accommodationMapper; + private final EntityManager entityManager; + + @Transactional + public AccommodationResponse create(CreateAccommodationRequest request, UserContext userContext) { + validateGuestCapacity(request.minGuests(), request.maxGuests()); + + Accommodation accommodation = accommodationMapper.toEntity(request); + accommodation.setHostId(userContext.userId()); + + if (request.amenities() != null) { + for (AmenityType type : request.amenities()) { + Amenity amenity = Amenity.builder().type(type).build(); + accommodation.addAmenity(amenity); + } + } + + accommodation = accommodationRepository.save(accommodation); + return accommodationMapper.toResponse(accommodation); + } + + @Transactional(readOnly = true) + public AccommodationResponse getById(UUID id) { + Accommodation accommodation = findAccommodationOrThrow(id); + return accommodationMapper.toResponse(accommodation); + } + + @Transactional(readOnly = true) + public List getByHostId(UUID hostId) { + List accommodations = accommodationRepository.findByHostId(hostId); + return accommodationMapper.toResponseList(accommodations); + } + + @Transactional + public AccommodationResponse update(UUID id, UpdateAccommodationRequest request, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(id); + validateOwnership(accommodation, userContext); + + if (request.name() != null) { + accommodation.setName(request.name()); + } + if (request.address() != null) { + accommodation.setAddress(request.address()); + } + if (request.minGuests() != null) { + accommodation.setMinGuests(request.minGuests()); + } + if (request.maxGuests() != null) { + accommodation.setMaxGuests(request.maxGuests()); + } + if (request.pricingMode() != null) { + accommodation.setPricingMode(request.pricingMode()); + } + if (request.approvalMode() != null) { + accommodation.setApprovalMode(request.approvalMode()); + } + + validateGuestCapacity(accommodation.getMinGuests(), accommodation.getMaxGuests()); + + if (request.amenities() != null) { + accommodation.getAmenities().clear(); + entityManager.flush(); + for (AmenityType type : request.amenities()) { + Amenity amenity = Amenity.builder().type(type).build(); + accommodation.addAmenity(amenity); + } + } + + accommodation = accommodationRepository.save(accommodation); + return accommodationMapper.toResponse(accommodation); + } + + @Transactional + public void delete(UUID id, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(id); + validateOwnership(accommodation, userContext); + + accommodation.setDeleted(true); + accommodation.getAmenities().forEach(amenity -> amenity.setDeleted(true)); + accommodationRepository.save(accommodation); + } + + private Accommodation findAccommodationOrThrow(UUID id) { + return accommodationRepository.findById(id) + .orElseThrow(() -> new AccommodationNotFoundException("Accommodation not found with id: " + id)); + } + + private void validateOwnership(Accommodation accommodation, UserContext userContext) { + if (!accommodation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You are not the owner of this accommodation"); + } + } + + private void validateGuestCapacity(int minGuests, int maxGuests) { + if (minGuests > maxGuests) { + throw new IllegalArgumentException("Minimum guests cannot exceed maximum guests"); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 912e3e1..5024948 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,13 +1,30 @@ spring.application.name=accommodation server.port=${SERVER_PORT:8080} + # Logging configuration logging.logstash.host=${LOGSTASH_HOST:localhost:5000} logging.level.root=INFO logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO + +# Database +spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:devoops-postgres}:${POSGTES_PORT:5432}/accommodation_db +spring.datasource.username=${DB_USERNAME:accommodation-service} +spring.datasource.password=${DB_PASSWORD:accommodation-service-pass} +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +# Flyway +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration + # Tracing configuration management.tracing.sampling.probability=1.0 management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans + # Actuator endpoints configuration management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.health.show-details=always diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..cccecca --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,38 @@ +-- PostgreSQL named enums +CREATE TYPE pricing_mode AS ENUM ('PER_GUEST', 'PER_UNIT'); +CREATE TYPE approval_mode AS ENUM ('AUTOMATIC', 'MANUAL'); +CREATE TYPE amenity_type AS ENUM ('WIFI', 'KITCHEN', 'AC', 'PARKING', 'FREE_PARKING', 'POOL', 'TV', 'WASHING_MACHINE', 'HEATING', 'BALCONY'); + +-- Accommodations table +CREATE TABLE accommodations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + min_guests INTEGER NOT NULL, + max_guests INTEGER NOT NULL, + pricing_mode pricing_mode NOT NULL, + approval_mode approval_mode NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Amenities table +CREATE TABLE amenities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + accommodation_id UUID NOT NULL REFERENCES accommodations(id) ON DELETE CASCADE, + type amenity_type NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_accommodations_host_id ON accommodations(host_id); +CREATE INDEX idx_amenities_accommodation_id ON amenities(accommodation_id); + +-- Partial unique index: one amenity type per accommodation (among non-deleted) +CREATE UNIQUE INDEX idx_amenities_unique_type + ON amenities(accommodation_id, type) + WHERE is_deleted = false; diff --git a/src/test/java/com/devoops/accommodation/AccommodationApplicationTests.java b/src/test/java/com/devoops/accommodation/AccommodationApplicationTests.java index 34fc206..f240f28 100644 --- a/src/test/java/com/devoops/accommodation/AccommodationApplicationTests.java +++ b/src/test/java/com/devoops/accommodation/AccommodationApplicationTests.java @@ -1,9 +1,7 @@ package com.devoops.accommodation; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest class AccommodationApplicationTests { @Test From 3b00a1dd842ae904e0e5f6414d3ab44124b2aab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 15 Feb 2026 16:57:18 +0100 Subject: [PATCH 09/24] feat: Replace Amenity entity with enum array Remove the Amenity entity and repository and store amenities directly on Accommodation as a List backed by a text[] column. Update Accommodation entity to use @Enumerated(EnumType.STRING) with JdbcTypeCode(SqlTypes.ARRAY) and columnDefinition "text[]". Adapt mapper and service: drop the custom amenity mapping and per-entity add/remove logic, replace EntityManager and per-amenity persistence with simple list assignment and saveAndFlush, and stop marking individual amenities as deleted on soft-delete. Add Flyway migrations: V2 migrates existing amenities into an amenity_type[] column and drops the amenities table; V3 converts the column to text[] for Hibernate compatibility. --- .../accommodation/entity/Accommodation.java | 16 +++------- .../devoops/accommodation/entity/Amenity.java | 29 ------------------- .../mapper/AccommodationMapper.java | 15 ---------- .../repository/AmenityRepository.java | 9 ------ .../service/AccommodationService.java | 22 ++++---------- .../migration/V2__amenities_to_enum_list.sql | 15 ++++++++++ .../V3__amenities_column_to_text_array.sql | 2 ++ 7 files changed, 26 insertions(+), 82 deletions(-) delete mode 100644 src/main/java/com/devoops/accommodation/entity/Amenity.java delete mode 100644 src/main/java/com/devoops/accommodation/repository/AmenityRepository.java create mode 100644 src/main/resources/db/migration/V2__amenities_to_enum_list.sql create mode 100644 src/main/resources/db/migration/V3__amenities_column_to_text_array.sql diff --git a/src/main/java/com/devoops/accommodation/entity/Accommodation.java b/src/main/java/com/devoops/accommodation/entity/Accommodation.java index b3e8dda..c3f1a8f 100644 --- a/src/main/java/com/devoops/accommodation/entity/Accommodation.java +++ b/src/main/java/com/devoops/accommodation/entity/Accommodation.java @@ -50,16 +50,8 @@ public class Accommodation extends BaseEntity { private ApprovalMode approvalMode; @Builder.Default - @OneToMany(mappedBy = "accommodation", cascade = CascadeType.ALL, orphanRemoval = true) - private List amenities = new ArrayList<>(); - - public void addAmenity(Amenity amenity) { - amenities.add(amenity); - amenity.setAccommodation(this); - } - - public void removeAmenity(Amenity amenity) { - amenities.remove(amenity); - amenity.setAccommodation(null); - } + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(nullable = false, columnDefinition = "text[]") + private List amenities = new ArrayList<>(); } diff --git a/src/main/java/com/devoops/accommodation/entity/Amenity.java b/src/main/java/com/devoops/accommodation/entity/Amenity.java deleted file mode 100644 index 29137e3..0000000 --- a/src/main/java/com/devoops/accommodation/entity/Amenity.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.devoops.accommodation.entity; - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.experimental.SuperBuilder; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.annotations.SQLRestriction; -import org.hibernate.type.SqlTypes; - -@Entity -@Table(name = "amenities") -@SQLRestriction("is_deleted = false") -@Getter -@Setter -@NoArgsConstructor -@SuperBuilder -public class Amenity extends BaseEntity { - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "accommodation_id", nullable = false) - private Accommodation accommodation; - - @Enumerated(EnumType.STRING) - @JdbcTypeCode(SqlTypes.NAMED_ENUM) - @Column(nullable = false, columnDefinition = "amenity_type") - private AmenityType type; -} diff --git a/src/main/java/com/devoops/accommodation/mapper/AccommodationMapper.java b/src/main/java/com/devoops/accommodation/mapper/AccommodationMapper.java index bf4a574..f209331 100644 --- a/src/main/java/com/devoops/accommodation/mapper/AccommodationMapper.java +++ b/src/main/java/com/devoops/accommodation/mapper/AccommodationMapper.java @@ -3,11 +3,8 @@ import com.devoops.accommodation.dto.request.CreateAccommodationRequest; import com.devoops.accommodation.dto.response.AccommodationResponse; import com.devoops.accommodation.entity.Accommodation; -import com.devoops.accommodation.entity.Amenity; -import com.devoops.accommodation.entity.AmenityType; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.Named; import java.util.List; @@ -16,24 +13,12 @@ public interface AccommodationMapper { @Mapping(target = "id", ignore = true) @Mapping(target = "hostId", ignore = true) - @Mapping(target = "amenities", ignore = true) @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) @Mapping(target = "isDeleted", ignore = true) Accommodation toEntity(CreateAccommodationRequest request); - @Mapping(target = "amenities", source = "amenities", qualifiedByName = "mapAmenities") AccommodationResponse toResponse(Accommodation accommodation); List toResponseList(List accommodations); - - @Named("mapAmenities") - default List mapAmenities(List amenities) { - if (amenities == null) { - return List.of(); - } - return amenities.stream() - .map(Amenity::getType) - .toList(); - } } diff --git a/src/main/java/com/devoops/accommodation/repository/AmenityRepository.java b/src/main/java/com/devoops/accommodation/repository/AmenityRepository.java deleted file mode 100644 index 67ec853..0000000 --- a/src/main/java/com/devoops/accommodation/repository/AmenityRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.devoops.accommodation.repository; - -import com.devoops.accommodation.entity.Amenity; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.UUID; - -public interface AmenityRepository extends JpaRepository { -} diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationService.java b/src/main/java/com/devoops/accommodation/service/AccommodationService.java index 64e97d2..df22199 100644 --- a/src/main/java/com/devoops/accommodation/service/AccommodationService.java +++ b/src/main/java/com/devoops/accommodation/service/AccommodationService.java @@ -5,17 +5,15 @@ import com.devoops.accommodation.dto.request.UpdateAccommodationRequest; import com.devoops.accommodation.dto.response.AccommodationResponse; import com.devoops.accommodation.entity.Accommodation; -import com.devoops.accommodation.entity.Amenity; -import com.devoops.accommodation.entity.AmenityType; import com.devoops.accommodation.exception.AccommodationNotFoundException; import com.devoops.accommodation.exception.ForbiddenException; import com.devoops.accommodation.mapper.AccommodationMapper; import com.devoops.accommodation.repository.AccommodationRepository; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -25,7 +23,6 @@ public class AccommodationService { private final AccommodationRepository accommodationRepository; private final AccommodationMapper accommodationMapper; - private final EntityManager entityManager; @Transactional public AccommodationResponse create(CreateAccommodationRequest request, UserContext userContext) { @@ -35,13 +32,10 @@ public AccommodationResponse create(CreateAccommodationRequest request, UserCont accommodation.setHostId(userContext.userId()); if (request.amenities() != null) { - for (AmenityType type : request.amenities()) { - Amenity amenity = Amenity.builder().type(type).build(); - accommodation.addAmenity(amenity); - } + accommodation.setAmenities(new ArrayList<>(request.amenities())); } - accommodation = accommodationRepository.save(accommodation); + accommodation = accommodationRepository.saveAndFlush(accommodation); return accommodationMapper.toResponse(accommodation); } @@ -84,15 +78,10 @@ public AccommodationResponse update(UUID id, UpdateAccommodationRequest request, validateGuestCapacity(accommodation.getMinGuests(), accommodation.getMaxGuests()); if (request.amenities() != null) { - accommodation.getAmenities().clear(); - entityManager.flush(); - for (AmenityType type : request.amenities()) { - Amenity amenity = Amenity.builder().type(type).build(); - accommodation.addAmenity(amenity); - } + accommodation.setAmenities(new ArrayList<>(request.amenities())); } - accommodation = accommodationRepository.save(accommodation); + accommodation = accommodationRepository.saveAndFlush(accommodation); return accommodationMapper.toResponse(accommodation); } @@ -102,7 +91,6 @@ public void delete(UUID id, UserContext userContext) { validateOwnership(accommodation, userContext); accommodation.setDeleted(true); - accommodation.getAmenities().forEach(amenity -> amenity.setDeleted(true)); accommodationRepository.save(accommodation); } diff --git a/src/main/resources/db/migration/V2__amenities_to_enum_list.sql b/src/main/resources/db/migration/V2__amenities_to_enum_list.sql new file mode 100644 index 0000000..cc42f87 --- /dev/null +++ b/src/main/resources/db/migration/V2__amenities_to_enum_list.sql @@ -0,0 +1,15 @@ +-- Migrate amenities from separate table to array column on accommodations + +-- Add amenities array column +ALTER TABLE accommodations ADD COLUMN amenities amenity_type[] NOT NULL DEFAULT '{}'; + +-- Migrate existing data +UPDATE accommodations a +SET amenities = ( + SELECT COALESCE(array_agg(am.type), '{}') + FROM amenities am + WHERE am.accommodation_id = a.id AND am.is_deleted = false +); + +-- Drop amenities table and its indexes +DROP TABLE amenities; diff --git a/src/main/resources/db/migration/V3__amenities_column_to_text_array.sql b/src/main/resources/db/migration/V3__amenities_column_to_text_array.sql new file mode 100644 index 0000000..52f1d1a --- /dev/null +++ b/src/main/resources/db/migration/V3__amenities_column_to_text_array.sql @@ -0,0 +1,2 @@ +-- Change amenities column from amenity_type[] to text[] for Hibernate compatibility +ALTER TABLE accommodations ALTER COLUMN amenities TYPE text[] USING amenities::text[]; From 9aec01c5cec41480fa61c8542b6ffc44f8ebc342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 15 Feb 2026 17:22:59 +0100 Subject: [PATCH 10/24] feat: Add tests, Testcontainers and JaCoCo config Introduce comprehensive tests and test tooling: add unit tests for AccommodationController and AccommodationService, plus an integration test using Testcontainers (Postgres) and MockMvc/RestAssured. Update build.gradle.kts to apply the JaCoCo plugin, add test dependencies (Testcontainers, Rest-Assured), and configure a jacocoTestReport (XML) to run after tests. Add application-test.properties for test profile settings and logging. --- build.gradle.kts | 13 + .../AccommodationControllerTest.java | 250 +++++++++++++ .../AccommodationIntegrationTest.java | 272 ++++++++++++++ .../service/AccommodationServiceTest.java | 331 ++++++++++++++++++ .../resources/application-test.properties | 7 + 5 files changed, 873 insertions(+) create mode 100644 src/test/java/com/devoops/accommodation/controller/AccommodationControllerTest.java create mode 100644 src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java create mode 100644 src/test/java/com/devoops/accommodation/service/AccommodationServiceTest.java create mode 100644 src/test/resources/application-test.properties diff --git a/build.gradle.kts b/build.gradle.kts index 39bd436..22aab14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { java + jacoco id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" } @@ -55,6 +56,10 @@ dependencies { // Test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.testcontainers:junit-jupiter:1.20.4") + testImplementation("org.testcontainers:postgresql:1.20.4") + testImplementation("io.rest-assured:rest-assured:5.5.0") + testCompileOnly("org.projectlombok:lombok") testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -62,4 +67,12 @@ dependencies { tasks.withType { useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + } } diff --git a/src/test/java/com/devoops/accommodation/controller/AccommodationControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AccommodationControllerTest.java new file mode 100644 index 0000000..669b1b7 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/controller/AccommodationControllerTest.java @@ -0,0 +1,250 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RoleAuthorizationInterceptor; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.config.UserContextResolver; +import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.exception.AccommodationNotFoundException; +import com.devoops.accommodation.exception.ForbiddenException; +import com.devoops.accommodation.exception.GlobalExceptionHandler; +import com.devoops.accommodation.service.AccommodationService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class AccommodationControllerTest { + + private MockMvc mockMvc; + + @Mock + private AccommodationService accommodationService; + + @InjectMocks + private AccommodationController accommodationController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(accommodationController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private AccommodationResponse createResponse() { + return new AccommodationResponse( + ACCOMMODATION_ID, HOST_ID, "Test Apartment", "123 Test St", + 1, 4, PricingMode.PER_GUEST, ApprovalMode.MANUAL, + List.of(), LocalDateTime.now(), LocalDateTime.now() + ); + } + + private Map validCreateRequest() { + return Map.of( + "name", "Test Apartment", + "address", "123 Test St", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + } + + @Nested + @DisplayName("POST /api/accommodation") + class CreateEndpoint { + + @Test + @DisplayName("With valid request returns 201") + void create_WithValidRequest_Returns201() throws Exception { + when(accommodationService.create(any(), any(UserContext.class))) + .thenReturn(createResponse()); + + mockMvc.perform(post("/api/accommodation") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(ACCOMMODATION_ID.toString())) + .andExpect(jsonPath("$.name").value("Test Apartment")); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void create_WithMissingAuthHeaders_Returns401() throws Exception { + mockMvc.perform(post("/api/accommodation") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With GUEST role returns 403") + void create_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(post("/api/accommodation") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With missing name returns 400") + void create_WithMissingName_Returns400() throws Exception { + var request = Map.of( + "address", "123 Test St", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + mockMvc.perform(post("/api/accommodation") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{id}") + class GetByIdEndpoint { + + @Test + @DisplayName("With existing ID returns 200") + void getById_WithExistingId_Returns200() throws Exception { + when(accommodationService.getById(ACCOMMODATION_ID)).thenReturn(createResponse()); + + mockMvc.perform(get("/api/accommodation/{id}", ACCOMMODATION_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(ACCOMMODATION_ID.toString())); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void getById_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + when(accommodationService.getById(id)) + .thenThrow(new AccommodationNotFoundException("Not found")); + + mockMvc.perform(get("/api/accommodation/{id}", id)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/host/{hostId}") + class GetByHostIdEndpoint { + + @Test + @DisplayName("Returns 200 with list") + void getByHostId_Returns200WithList() throws Exception { + when(accommodationService.getByHostId(HOST_ID)) + .thenReturn(List.of(createResponse())); + + mockMvc.perform(get("/api/accommodation/host/{hostId}", HOST_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(ACCOMMODATION_ID.toString())); + } + } + + @Nested + @DisplayName("PUT /api/accommodation/{id}") + class UpdateEndpoint { + + @Test + @DisplayName("With valid request returns 200") + void update_WithValidRequest_Returns200() throws Exception { + when(accommodationService.update(eq(ACCOMMODATION_ID), any(), any(UserContext.class))) + .thenReturn(createResponse()); + + var request = Map.of("name", "Updated Name"); + + mockMvc.perform(put("/api/accommodation/{id}", ACCOMMODATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void update_WithWrongOwner_Returns403() throws Exception { + when(accommodationService.update(eq(ACCOMMODATION_ID), any(), any(UserContext.class))) + .thenThrow(new ForbiddenException("Not the owner")); + + var request = Map.of("name", "Updated Name"); + + mockMvc.perform(put("/api/accommodation/{id}", ACCOMMODATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("DELETE /api/accommodation/{id}") + class DeleteEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void delete_WithValidRequest_Returns204() throws Exception { + doNothing().when(accommodationService).delete(eq(ACCOMMODATION_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/accommodation/{id}", ACCOMMODATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void delete_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + doThrow(new AccommodationNotFoundException("Not found")) + .when(accommodationService).delete(eq(id), any(UserContext.class)); + + mockMvc.perform(delete("/api/accommodation/{id}", id) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java new file mode 100644 index 0000000..a1e702c --- /dev/null +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java @@ -0,0 +1,272 @@ +package com.devoops.accommodation.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AccommodationIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("accommodation_db_test") + .withUsername("test") + .withPassword("test"); + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static String accommodationId; + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID OTHER_HOST_ID = UUID.randomUUID(); + + private static final String BASE_PATH = "/api/accommodation"; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + } + + private Map validCreateRequest() { + return Map.of( + "name", "Integration Test Apartment", + "address", "456 Integration St", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + } + + @Test + @Order(1) + @DisplayName("Create accommodation with valid request returns 201") + void create_WithValidRequest_Returns201WithResponse() throws Exception { + MvcResult result = mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Integration Test Apartment")) + .andExpect(jsonPath("$.address").value("456 Integration St")) + .andExpect(jsonPath("$.hostId").value(HOST_ID.toString())) + .andExpect(jsonPath("$.minGuests").value(1)) + .andExpect(jsonPath("$.maxGuests").value(4)) + .andExpect(jsonPath("$.pricingMode").value("PER_GUEST")) + .andExpect(jsonPath("$.approvalMode").value("MANUAL")) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andReturn(); + + accommodationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(2) + @DisplayName("Create accommodation with amenities stores them correctly") + void create_WithAmenities_StoresAmenitiesCorrectly() throws Exception { + var request = Map.of( + "name", "Amenity Apartment", + "address", "789 Amenity St", + "minGuests", 1, + "maxGuests", 2, + "pricingMode", "PER_UNIT", + "approvalMode", "AUTOMATIC", + "amenities", List.of("WIFI", "PARKING", "AC") + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.amenities", hasSize(3))) + .andExpect(jsonPath("$.amenities", containsInAnyOrder("WIFI", "PARKING", "AC"))); + } + + @Test + @Order(3) + @DisplayName("Create accommodation with missing name returns 400") + void create_WithMissingName_Returns400() throws Exception { + var request = Map.of( + "address", "123 St", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(4) + @DisplayName("Create accommodation with min exceeding max returns 400") + void create_WithMinExceedingMax_Returns400() throws Exception { + var request = Map.of( + "name", "Bad Capacity", + "address", "123 St", + "minGuests", 5, + "maxGuests", 2, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(5) + @DisplayName("Create accommodation without auth headers returns 401") + void create_WithoutAuthHeaders_Returns401() throws Exception { + mockMvc.perform(post(BASE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(6) + @DisplayName("Create accommodation with GUEST role returns 403") + void create_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(7) + @DisplayName("Get by ID with existing ID returns 200") + void getById_WithExistingId_Returns200() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + accommodationId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(accommodationId)) + .andExpect(jsonPath("$.name").value("Integration Test Apartment")); + } + + @Test + @Order(8) + @DisplayName("Get by ID with non-existing ID returns 404") + void getById_WithNonExistingId_Returns404() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + @Test + @Order(9) + @DisplayName("Get by host ID returns list of accommodations") + void getByHostId_ReturnsListOfAccommodations() throws Exception { + mockMvc.perform(get(BASE_PATH + "/host/" + HOST_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + @Order(10) + @DisplayName("Update accommodation with valid request returns 200") + void update_WithValidRequest_Returns200() throws Exception { + var request = Map.of("name", "Updated Apartment Name"); + + mockMvc.perform(put(BASE_PATH + "/" + accommodationId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Updated Apartment Name")) + .andExpect(jsonPath("$.address").value("456 Integration St")); + } + + @Test + @Order(11) + @DisplayName("Update accommodation with different host returns 403") + void update_WithDifferentHost_Returns403() throws Exception { + var request = Map.of("name", "Hacked Name"); + + mockMvc.perform(put(BASE_PATH + "/" + accommodationId) + .header("X-User-Id", OTHER_HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(12) + @DisplayName("Update accommodation with partial fields only updates provided") + void update_WithPartialFields_OnlyUpdatesProvided() throws Exception { + var request = Map.of("maxGuests", 8); + + mockMvc.perform(put(BASE_PATH + "/" + accommodationId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.maxGuests").value(8)) + .andExpect(jsonPath("$.name").value("Updated Apartment Name")); + } + + @Test + @Order(13) + @DisplayName("Delete accommodation with valid owner returns 204") + void delete_WithValidOwner_Returns204() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + accommodationId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @Order(14) + @DisplayName("After delete, get by ID returns 404 (soft-delete filters)") + void delete_ThenGetById_Returns404() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + accommodationId)) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/devoops/accommodation/service/AccommodationServiceTest.java b/src/test/java/com/devoops/accommodation/service/AccommodationServiceTest.java new file mode 100644 index 0000000..ecf01fa --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/AccommodationServiceTest.java @@ -0,0 +1,331 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAccommodationRequest; +import com.devoops.accommodation.dto.request.UpdateAccommodationRequest; +import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AmenityType; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.exception.AccommodationNotFoundException; +import com.devoops.accommodation.exception.ForbiddenException; +import com.devoops.accommodation.mapper.AccommodationMapper; +import com.devoops.accommodation.repository.AccommodationRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AccommodationServiceTest { + + @Mock + private AccommodationRepository accommodationRepository; + + @Mock + private AccommodationMapper accommodationMapper; + + @InjectMocks + private AccommodationService accommodationService; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UserContext HOST_CONTEXT = new UserContext(HOST_ID, "HOST"); + + private Accommodation createAccommodation() { + return Accommodation.builder() + .id(ACCOMMODATION_ID) + .hostId(HOST_ID) + .name("Test Apartment") + .address("123 Test St") + .minGuests(1) + .maxGuests(4) + .pricingMode(PricingMode.PER_GUEST) + .approvalMode(ApprovalMode.MANUAL) + .build(); + } + + private AccommodationResponse createResponse() { + return new AccommodationResponse( + ACCOMMODATION_ID, HOST_ID, "Test Apartment", "123 Test St", + 1, 4, PricingMode.PER_GUEST, ApprovalMode.MANUAL, + List.of(), LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Nested + @DisplayName("Create") + class CreateTests { + + @Test + @DisplayName("With valid request returns accommodation response") + void create_WithValidRequest_ReturnsAccommodationResponse() { + var request = new CreateAccommodationRequest( + "Test Apartment", "123 Test St", 1, 4, + PricingMode.PER_GUEST, ApprovalMode.MANUAL, null); + var accommodation = createAccommodation(); + var response = createResponse(); + + when(accommodationMapper.toEntity(request)).thenReturn(accommodation); + when(accommodationRepository.saveAndFlush(accommodation)).thenReturn(accommodation); + when(accommodationMapper.toResponse(accommodation)).thenReturn(response); + + AccommodationResponse result = accommodationService.create(request, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + verify(accommodationRepository).saveAndFlush(accommodation); + } + + @Test + @DisplayName("With amenities sets amenities on entity") + void create_WithAmenities_SetsAmenitiesOnEntity() { + var amenities = Set.of(AmenityType.WIFI, AmenityType.PARKING); + var request = new CreateAccommodationRequest( + "Test", "Addr", 1, 4, + PricingMode.PER_GUEST, ApprovalMode.MANUAL, amenities); + var accommodation = createAccommodation(); + var response = createResponse(); + + when(accommodationMapper.toEntity(request)).thenReturn(accommodation); + when(accommodationRepository.saveAndFlush(accommodation)).thenReturn(accommodation); + when(accommodationMapper.toResponse(accommodation)).thenReturn(response); + + accommodationService.create(request, HOST_CONTEXT); + + assertThat(accommodation.getAmenities()).containsExactlyInAnyOrderElementsOf(amenities); + } + + @Test + @DisplayName("With null amenities does not set amenities") + void create_WithNullAmenities_DoesNotSetAmenities() { + var request = new CreateAccommodationRequest( + "Test", "Addr", 1, 4, + PricingMode.PER_GUEST, ApprovalMode.MANUAL, null); + var accommodation = createAccommodation(); + var response = createResponse(); + + when(accommodationMapper.toEntity(request)).thenReturn(accommodation); + when(accommodationRepository.saveAndFlush(accommodation)).thenReturn(accommodation); + when(accommodationMapper.toResponse(accommodation)).thenReturn(response); + + accommodationService.create(request, HOST_CONTEXT); + + assertThat(accommodation.getAmenities()).isEmpty(); + } + + @Test + @DisplayName("With min guests exceeding max throws IllegalArgumentException") + void create_WithMinGuestsExceedingMax_ThrowsIllegalArgument() { + var request = new CreateAccommodationRequest( + "Test", "Addr", 5, 2, + PricingMode.PER_GUEST, ApprovalMode.MANUAL, null); + + assertThatThrownBy(() -> accommodationService.create(request, HOST_CONTEXT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Minimum guests cannot exceed maximum guests"); + } + } + + @Nested + @DisplayName("GetById") + class GetByIdTests { + + @Test + @DisplayName("With existing ID returns accommodation response") + void getById_WithExistingId_ReturnsAccommodationResponse() { + var accommodation = createAccommodation(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(accommodationMapper.toResponse(accommodation)).thenReturn(response); + + AccommodationResponse result = accommodationService.getById(ACCOMMODATION_ID); + + assertThat(result).isEqualTo(response); + } + + @Test + @DisplayName("With non-existing ID throws AccommodationNotFoundException") + void getById_WithNonExistingId_ThrowsAccommodationNotFoundException() { + UUID id = UUID.randomUUID(); + when(accommodationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> accommodationService.getById(id)) + .isInstanceOf(AccommodationNotFoundException.class); + } + } + + @Nested + @DisplayName("GetByHostId") + class GetByHostIdTests { + + @Test + @DisplayName("With existing host returns accommodation list") + void getByHostId_WithExistingHost_ReturnsAccommodationList() { + var accommodations = List.of(createAccommodation()); + var responses = List.of(createResponse()); + + when(accommodationRepository.findByHostId(HOST_ID)).thenReturn(accommodations); + when(accommodationMapper.toResponseList(accommodations)).thenReturn(responses); + + List result = accommodationService.getByHostId(HOST_ID); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("With no accommodations returns empty list") + void getByHostId_WithNoAccommodations_ReturnsEmptyList() { + UUID hostId = UUID.randomUUID(); + when(accommodationRepository.findByHostId(hostId)).thenReturn(List.of()); + when(accommodationMapper.toResponseList(List.of())).thenReturn(List.of()); + + List result = accommodationService.getByHostId(hostId); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("Update") + class UpdateTests { + + @Test + @DisplayName("With valid request returns updated response") + void update_WithValidRequest_ReturnsUpdatedResponse() { + var request = new UpdateAccommodationRequest( + "Updated Name", "New Address", 2, 6, + PricingMode.PER_UNIT, ApprovalMode.AUTOMATIC, null); + var accommodation = createAccommodation(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(accommodationRepository.saveAndFlush(accommodation)).thenReturn(accommodation); + when(accommodationMapper.toResponse(accommodation)).thenReturn(response); + + AccommodationResponse result = accommodationService.update(ACCOMMODATION_ID, request, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + assertThat(accommodation.getName()).isEqualTo("Updated Name"); + assertThat(accommodation.getAddress()).isEqualTo("New Address"); + assertThat(accommodation.getMinGuests()).isEqualTo(2); + assertThat(accommodation.getMaxGuests()).isEqualTo(6); + } + + @Test + @DisplayName("With partial request only updates non-null fields") + void update_WithPartialRequest_OnlyUpdatesNonNullFields() { + var request = new UpdateAccommodationRequest( + "New Name", null, null, null, null, null, null); + var accommodation = createAccommodation(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(accommodationRepository.saveAndFlush(accommodation)).thenReturn(accommodation); + when(accommodationMapper.toResponse(accommodation)).thenReturn(response); + + accommodationService.update(ACCOMMODATION_ID, request, HOST_CONTEXT); + + assertThat(accommodation.getName()).isEqualTo("New Name"); + assertThat(accommodation.getAddress()).isEqualTo("123 Test St"); + assertThat(accommodation.getMinGuests()).isEqualTo(1); + assertThat(accommodation.getMaxGuests()).isEqualTo(4); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void update_WithWrongOwner_ThrowsForbiddenException() { + var request = new UpdateAccommodationRequest( + "Name", null, null, null, null, null, null); + var accommodation = createAccommodation(); + var otherUser = new UserContext(UUID.randomUUID(), "HOST"); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> accommodationService.update(ACCOMMODATION_ID, request, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With non-existing ID throws AccommodationNotFoundException") + void update_WithNonExistingId_ThrowsAccommodationNotFoundException() { + UUID id = UUID.randomUUID(); + var request = new UpdateAccommodationRequest( + "Name", null, null, null, null, null, null); + + when(accommodationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> accommodationService.update(id, request, HOST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class); + } + + @Test + @DisplayName("With min guests exceeding max after partial update throws IllegalArgumentException") + void update_WithMinGuestsExceedingMax_ThrowsIllegalArgument() { + var request = new UpdateAccommodationRequest( + null, null, 10, null, null, null, null); + var accommodation = createAccommodation(); // maxGuests=4 + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> accommodationService.update(ACCOMMODATION_ID, request, HOST_CONTEXT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Minimum guests cannot exceed maximum guests"); + } + } + + @Nested + @DisplayName("Delete") + class DeleteTests { + + @Test + @DisplayName("With valid owner soft-deletes accommodation") + void delete_WithValidOwner_SoftDeletesAccommodation() { + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + accommodationService.delete(ACCOMMODATION_ID, HOST_CONTEXT); + + assertThat(accommodation.isDeleted()).isTrue(); + verify(accommodationRepository).save(accommodation); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void delete_WithWrongOwner_ThrowsForbiddenException() { + var accommodation = createAccommodation(); + var otherUser = new UserContext(UUID.randomUUID(), "HOST"); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> accommodationService.delete(ACCOMMODATION_ID, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With non-existing ID throws AccommodationNotFoundException") + void delete_WithNonExistingId_ThrowsAccommodationNotFoundException() { + UUID id = UUID.randomUUID(); + when(accommodationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> accommodationService.delete(id, HOST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class); + } + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..9e787a6 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.application.name=accommodation-test + +# Disable tracing in tests +management.tracing.enabled=false + +# Logging +logging.level.com.devoops=DEBUG From f76ace6ca241cc4aac2d741ef4c9e26a2f64cbb4 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:42:05 +0100 Subject: [PATCH 11/24] feat: Add photo storage logic. --- build.gradle.kts | 4 + environment/.local.env | 6 + .../accommodation/config/MinioConfig.java | 27 ++ .../AccommodationPhotoController.java | 72 ++++ .../response/AccommodationPhotoResponse.java | 15 + .../entity/AccommodationPhoto.java | 38 ++ .../exception/GlobalExceptionHandler.java | 26 ++ .../InvalidContentTypeException.java | 7 + .../PhotoLimitExceededException.java | 7 + .../exception/PhotoNotFoundException.java | 7 + .../exception/PhotoStorageException.java | 11 + .../mapper/AccommodationPhotoMapper.java | 15 + .../AccommodationPhotoRepository.java | 22 ++ .../service/AccommodationPhotoService.java | 132 +++++++ .../service/PhotoStorageService.java | 106 ++++++ src/main/resources/application.properties | 15 + .../V4__create_accommodation_photos_table.sql | 18 + .../AccommodationPhotoControllerTest.java | 304 ++++++++++++++++ .../AccommodationIntegrationTest.java | 10 + .../AccommodationPhotoIntegrationTest.java | 286 +++++++++++++++ .../AccommodationPhotoServiceTest.java | 339 ++++++++++++++++++ .../service/PhotoStorageServiceTest.java | 188 ++++++++++ 22 files changed, 1655 insertions(+) create mode 100644 src/main/java/com/devoops/accommodation/config/MinioConfig.java create mode 100644 src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java create mode 100644 src/main/java/com/devoops/accommodation/dto/response/AccommodationPhotoResponse.java create mode 100644 src/main/java/com/devoops/accommodation/entity/AccommodationPhoto.java create mode 100644 src/main/java/com/devoops/accommodation/exception/InvalidContentTypeException.java create mode 100644 src/main/java/com/devoops/accommodation/exception/PhotoLimitExceededException.java create mode 100644 src/main/java/com/devoops/accommodation/exception/PhotoNotFoundException.java create mode 100644 src/main/java/com/devoops/accommodation/exception/PhotoStorageException.java create mode 100644 src/main/java/com/devoops/accommodation/mapper/AccommodationPhotoMapper.java create mode 100644 src/main/java/com/devoops/accommodation/repository/AccommodationPhotoRepository.java create mode 100644 src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java create mode 100644 src/main/java/com/devoops/accommodation/service/PhotoStorageService.java create mode 100644 src/main/resources/db/migration/V4__create_accommodation_photos_table.sql create mode 100644 src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java create mode 100644 src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java create mode 100644 src/test/java/com/devoops/accommodation/service/AccommodationPhotoServiceTest.java create mode 100644 src/test/java/com/devoops/accommodation/service/PhotoStorageServiceTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 22aab14..34840c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,9 @@ dependencies { annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") + // MinIO S3-compatible object storage + implementation("io.minio:minio:8.5.7") + // Tracing (Zipkin) implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") implementation("org.springframework.boot:spring-boot-starter-zipkin") @@ -58,6 +61,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.testcontainers:junit-jupiter:1.20.4") testImplementation("org.testcontainers:postgresql:1.20.4") + testImplementation("org.testcontainers:minio:1.20.4") testImplementation("io.rest-assured:rest-assured:5.5.0") testCompileOnly("org.projectlombok:lombok") diff --git a/environment/.local.env b/environment/.local.env index 9b21147..09a8f4c 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -6,3 +6,9 @@ POSTGRES_HOST=devoops-postgres POSGTES_PORT=5432 DB_USERNAME=accommodation-service DB_PASSWORD=accommodation-service-pass + +# MinIO configuration +MINIO_ENDPOINT=http://devoops-minio:9000 +MINIO_ACCESS_KEY=devoops +MINIO_SECRET_KEY=devoops123 +MINIO_BUCKET=accommodation-photos diff --git a/src/main/java/com/devoops/accommodation/config/MinioConfig.java b/src/main/java/com/devoops/accommodation/config/MinioConfig.java new file mode 100644 index 0000000..b539886 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/config/MinioConfig.java @@ -0,0 +1,27 @@ +package com.devoops.accommodation.config; + +import io.minio.MinioClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MinioConfig { + + @Value("${minio.endpoint}") + private String endpoint; + + @Value("${minio.access-key}") + private String accessKey; + + @Value("${minio.secret-key}") + private String secretKey; + + @Bean + public MinioClient minioClient() { + return MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + } +} diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java new file mode 100644 index 0000000..6902123 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java @@ -0,0 +1,72 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RequireRole; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.AccommodationPhoto; +import com.devoops.accommodation.service.AccommodationPhotoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.InputStream; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/accommodation/{accommodationId}/photos") +@RequiredArgsConstructor +public class AccommodationPhotoController { + + private final AccommodationPhotoService photoService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RequireRole("HOST") + public ResponseEntity uploadPhoto( + @PathVariable UUID accommodationId, + @RequestPart("file") MultipartFile file, + @RequestParam(value = "displayOrder", required = false) Integer displayOrder, + UserContext userContext) { + AccommodationPhotoResponse response = photoService.uploadPhoto(accommodationId, file, displayOrder, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public ResponseEntity> listPhotos(@PathVariable UUID accommodationId) { + List photos = photoService.listPhotos(accommodationId); + return ResponseEntity.ok(photos); + } + + @GetMapping("/{photoId}") + public ResponseEntity getPhoto( + @PathVariable UUID accommodationId, + @PathVariable UUID photoId) { + AccommodationPhoto metadata = photoService.getPhotoMetadata(accommodationId, photoId); + InputStream inputStream = photoService.getPhotoFile(accommodationId, photoId); + + StreamingResponseBody responseBody = outputStream -> { + try (inputStream) { + inputStream.transferTo(outputStream); + } + }; + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(metadata.getContentType())) + .contentLength(metadata.getFileSize()) + .body(responseBody); + } + + @DeleteMapping("/{photoId}") + @RequireRole("HOST") + public ResponseEntity deletePhoto( + @PathVariable UUID accommodationId, + @PathVariable UUID photoId, + UserContext userContext) { + photoService.deletePhoto(accommodationId, photoId, userContext); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/devoops/accommodation/dto/response/AccommodationPhotoResponse.java b/src/main/java/com/devoops/accommodation/dto/response/AccommodationPhotoResponse.java new file mode 100644 index 0000000..736458f --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/response/AccommodationPhotoResponse.java @@ -0,0 +1,15 @@ +package com.devoops.accommodation.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record AccommodationPhotoResponse( + UUID id, + UUID accommodationId, + String originalFilename, + String contentType, + Long fileSize, + Integer displayOrder, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} diff --git a/src/main/java/com/devoops/accommodation/entity/AccommodationPhoto.java b/src/main/java/com/devoops/accommodation/entity/AccommodationPhoto.java new file mode 100644 index 0000000..5e6ebd4 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/AccommodationPhoto.java @@ -0,0 +1,38 @@ +package com.devoops.accommodation.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.SQLRestriction; + +import java.util.UUID; + +@Entity +@Table(name = "accommodation_photos") +@SQLRestriction("is_deleted = false") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public class AccommodationPhoto extends BaseEntity { + + @Column(name = "accommodation_id", nullable = false) + private UUID accommodationId; + + @Column(name = "storage_filename", nullable = false, unique = true) + private String storageFilename; + + @Column(name = "original_filename", nullable = false) + private String originalFilename; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; +} diff --git a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java index 100e06d..2f48f7e 100644 --- a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import java.util.Map; import java.util.stream.Collectors; @@ -45,4 +46,29 @@ public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); } + + @ExceptionHandler(PhotoNotFoundException.class) + public ProblemDetail handlePhotoNotFound(PhotoNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(PhotoLimitExceededException.class) + public ProblemDetail handlePhotoLimitExceeded(PhotoLimitExceededException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(PhotoStorageException.class) + public ProblemDetail handlePhotoStorage(PhotoStorageException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + } + + @ExceptionHandler(InvalidContentTypeException.class) + public ProblemDetail handleInvalidContentType(InvalidContentTypeException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ProblemDetail handleMaxUploadSize(MaxUploadSizeExceededException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "File size exceeds the maximum allowed limit"); + } } diff --git a/src/main/java/com/devoops/accommodation/exception/InvalidContentTypeException.java b/src/main/java/com/devoops/accommodation/exception/InvalidContentTypeException.java new file mode 100644 index 0000000..835fa40 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/InvalidContentTypeException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class InvalidContentTypeException extends RuntimeException { + public InvalidContentTypeException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/PhotoLimitExceededException.java b/src/main/java/com/devoops/accommodation/exception/PhotoLimitExceededException.java new file mode 100644 index 0000000..bf2bd05 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/PhotoLimitExceededException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class PhotoLimitExceededException extends RuntimeException { + public PhotoLimitExceededException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/PhotoNotFoundException.java b/src/main/java/com/devoops/accommodation/exception/PhotoNotFoundException.java new file mode 100644 index 0000000..0323102 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/PhotoNotFoundException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class PhotoNotFoundException extends RuntimeException { + public PhotoNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/PhotoStorageException.java b/src/main/java/com/devoops/accommodation/exception/PhotoStorageException.java new file mode 100644 index 0000000..b15c669 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/PhotoStorageException.java @@ -0,0 +1,11 @@ +package com.devoops.accommodation.exception; + +public class PhotoStorageException extends RuntimeException { + public PhotoStorageException(String message) { + super(message); + } + + public PhotoStorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/devoops/accommodation/mapper/AccommodationPhotoMapper.java b/src/main/java/com/devoops/accommodation/mapper/AccommodationPhotoMapper.java new file mode 100644 index 0000000..71c571e --- /dev/null +++ b/src/main/java/com/devoops/accommodation/mapper/AccommodationPhotoMapper.java @@ -0,0 +1,15 @@ +package com.devoops.accommodation.mapper; + +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.AccommodationPhoto; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface AccommodationPhotoMapper { + + AccommodationPhotoResponse toResponse(AccommodationPhoto photo); + + List toResponseList(List photos); +} diff --git a/src/main/java/com/devoops/accommodation/repository/AccommodationPhotoRepository.java b/src/main/java/com/devoops/accommodation/repository/AccommodationPhotoRepository.java new file mode 100644 index 0000000..d37315b --- /dev/null +++ b/src/main/java/com/devoops/accommodation/repository/AccommodationPhotoRepository.java @@ -0,0 +1,22 @@ +package com.devoops.accommodation.repository; + +import com.devoops.accommodation.entity.AccommodationPhoto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AccommodationPhotoRepository extends JpaRepository { + + List findByAccommodationIdOrderByDisplayOrderAsc(UUID accommodationId); + + Optional findByIdAndAccommodationId(UUID id, UUID accommodationId); + + long countByAccommodationId(UUID accommodationId); + + @Query("SELECT COALESCE(MAX(p.displayOrder), -1) FROM AccommodationPhoto p WHERE p.accommodationId = :accommodationId") + Integer findMaxDisplayOrder(@Param("accommodationId") UUID accommodationId); +} diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java b/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java new file mode 100644 index 0000000..655ae5b --- /dev/null +++ b/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java @@ -0,0 +1,132 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AccommodationPhoto; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.mapper.AccommodationPhotoMapper; +import com.devoops.accommodation.repository.AccommodationPhotoRepository; +import com.devoops.accommodation.repository.AccommodationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AccommodationPhotoService { + + private final AccommodationPhotoRepository photoRepository; + private final AccommodationRepository accommodationRepository; + private final PhotoStorageService photoStorageService; + private final AccommodationPhotoMapper photoMapper; + + @Value("${app.photo.max-photos-per-accommodation}") + private int maxPhotosPerAccommodation; + + @Value("${app.photo.allowed-content-types}") + private String allowedContentTypesConfig; + + @Transactional + public AccommodationPhotoResponse uploadPhoto(UUID accommodationId, MultipartFile file, Integer displayOrder, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + validateContentType(file.getContentType()); + validatePhotoLimit(accommodationId); + + String storageFilename = photoStorageService.store(file); + + int order = displayOrder != null ? displayOrder : photoRepository.findMaxDisplayOrder(accommodationId) + 1; + + AccommodationPhoto photo = AccommodationPhoto.builder() + .accommodationId(accommodationId) + .storageFilename(storageFilename) + .originalFilename(file.getOriginalFilename()) + .contentType(file.getContentType()) + .fileSize(file.getSize()) + .displayOrder(order) + .build(); + + photo = photoRepository.saveAndFlush(photo); + log.info("Uploaded photo {} for accommodation {}", photo.getId(), accommodationId); + + return photoMapper.toResponse(photo); + } + + @Transactional(readOnly = true) + public List listPhotos(UUID accommodationId) { + findAccommodationOrThrow(accommodationId); + List photos = photoRepository.findByAccommodationIdOrderByDisplayOrderAsc(accommodationId); + return photoMapper.toResponseList(photos); + } + + @Transactional(readOnly = true) + public InputStream getPhotoFile(UUID accommodationId, UUID photoId) { + AccommodationPhoto photo = findPhotoOrThrow(accommodationId, photoId); + return photoStorageService.loadAsStream(photo.getStorageFilename()); + } + + @Transactional(readOnly = true) + public AccommodationPhoto getPhotoMetadata(UUID accommodationId, UUID photoId) { + return findPhotoOrThrow(accommodationId, photoId); + } + + @Transactional + public void deletePhoto(UUID accommodationId, UUID photoId, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + + AccommodationPhoto photo = findPhotoOrThrow(accommodationId, photoId); + + photoStorageService.delete(photo.getStorageFilename()); + + photo.setDeleted(true); + photoRepository.save(photo); + + log.info("Deleted photo {} from accommodation {}", photoId, accommodationId); + } + + private Accommodation findAccommodationOrThrow(UUID accommodationId) { + return accommodationRepository.findById(accommodationId) + .orElseThrow(() -> new AccommodationNotFoundException("Accommodation not found with id: " + accommodationId)); + } + + private AccommodationPhoto findPhotoOrThrow(UUID accommodationId, UUID photoId) { + return photoRepository.findByIdAndAccommodationId(photoId, accommodationId) + .orElseThrow(() -> new PhotoNotFoundException("Photo not found with id: " + photoId)); + } + + private void validateOwnership(Accommodation accommodation, UserContext userContext) { + if (!accommodation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You are not the owner of this accommodation"); + } + } + + private void validateContentType(String contentType) { + Set allowedTypes = Arrays.stream(allowedContentTypesConfig.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + + if (contentType == null || !allowedTypes.contains(contentType)) { + throw new InvalidContentTypeException("Invalid content type: " + contentType + ". Allowed types: " + allowedContentTypesConfig); + } + } + + private void validatePhotoLimit(UUID accommodationId) { + long currentCount = photoRepository.countByAccommodationId(accommodationId); + if (currentCount >= maxPhotosPerAccommodation) { + throw new PhotoLimitExceededException("Maximum number of photos (" + maxPhotosPerAccommodation + ") reached for this accommodation"); + } + } +} diff --git a/src/main/java/com/devoops/accommodation/service/PhotoStorageService.java b/src/main/java/com/devoops/accommodation/service/PhotoStorageService.java new file mode 100644 index 0000000..42c226d --- /dev/null +++ b/src/main/java/com/devoops/accommodation/service/PhotoStorageService.java @@ -0,0 +1,106 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.exception.PhotoStorageException; +import io.minio.*; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PhotoStorageService { + + private final MinioClient minioClient; + + @Value("${minio.bucket}") + private String bucketName; + + @PostConstruct + public void init() { + try { + boolean bucketExists = minioClient.bucketExists( + BucketExistsArgs.builder() + .bucket(bucketName) + .build() + ); + + if (!bucketExists) { + minioClient.makeBucket( + MakeBucketArgs.builder() + .bucket(bucketName) + .build() + ); + log.info("Created MinIO bucket: {}", bucketName); + } else { + log.info("MinIO bucket already exists: {}", bucketName); + } + } catch (Exception e) { + log.error("Failed to initialize MinIO bucket: {}", bucketName, e); + throw new PhotoStorageException("Failed to initialize photo storage", e); + } + } + + public String store(MultipartFile file) { + String objectKey = generateObjectKey(file.getOriginalFilename()); + + try (InputStream inputStream = file.getInputStream()) { + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .stream(inputStream, file.getSize(), -1) + .contentType(file.getContentType()) + .build() + ); + log.debug("Stored file {} as {} in bucket {}", file.getOriginalFilename(), objectKey, bucketName); + return objectKey; + } catch (Exception e) { + log.error("Failed to store file: {}", file.getOriginalFilename(), e); + throw new PhotoStorageException("Failed to store photo", e); + } + } + + public InputStream loadAsStream(String objectKey) { + try { + return minioClient.getObject( + GetObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .build() + ); + } catch (Exception e) { + log.error("Failed to load file: {}", objectKey, e); + throw new PhotoStorageException("Failed to load photo", e); + } + } + + public void delete(String objectKey) { + try { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .build() + ); + log.debug("Deleted file {} from bucket {}", objectKey, bucketName); + } catch (Exception e) { + log.error("Failed to delete file: {}", objectKey, e); + throw new PhotoStorageException("Failed to delete photo", e); + } + } + + private String generateObjectKey(String originalFilename) { + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + return UUID.randomUUID() + extension; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5024948..f7053cc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -29,3 +29,18 @@ management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_ management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.health.show-details=always management.prometheus.metrics.export.enabled=true + +# Multipart upload configuration +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=50MB + +# MinIO configuration +minio.endpoint=${MINIO_ENDPOINT:http://devoops-minio:9000} +minio.access-key=${MINIO_ACCESS_KEY:devoops} +minio.secret-key=${MINIO_SECRET_KEY:devoops123} +minio.bucket=${MINIO_BUCKET:accommodation-photos} + +# Photo configuration +app.photo.max-photos-per-accommodation=20 +app.photo.allowed-content-types=image/jpeg,image/png,image/webp diff --git a/src/main/resources/db/migration/V4__create_accommodation_photos_table.sql b/src/main/resources/db/migration/V4__create_accommodation_photos_table.sql new file mode 100644 index 0000000..53d31c0 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_accommodation_photos_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE accommodation_photos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + accommodation_id UUID NOT NULL REFERENCES accommodations(id) ON DELETE CASCADE, + storage_filename VARCHAR(255) NOT NULL UNIQUE, + original_filename VARCHAR(255) NOT NULL, + content_type VARCHAR(50) NOT NULL, + file_size BIGINT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_accommodation_photos_accommodation_id ON accommodation_photos(accommodation_id); +CREATE INDEX idx_accommodation_photos_display_order ON accommodation_photos(accommodation_id, display_order); + +ALTER TABLE accommodation_photos ADD CONSTRAINT chk_content_type + CHECK (content_type IN ('image/jpeg', 'image/png', 'image/webp')); diff --git a/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java new file mode 100644 index 0000000..7829dd3 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java @@ -0,0 +1,304 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RoleAuthorizationInterceptor; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.config.UserContextResolver; +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.AccommodationPhoto; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.service.AccommodationPhotoService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.io.ByteArrayInputStream; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class AccommodationPhotoControllerTest { + + private MockMvc mockMvc; + + @Mock + private AccommodationPhotoService photoService; + + @InjectMocks + private AccommodationPhotoController photoController; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID PHOTO_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(photoController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private AccommodationPhotoResponse createPhotoResponse() { + return new AccommodationPhotoResponse( + PHOTO_ID, ACCOMMODATION_ID, "test.jpg", "image/jpeg", + 1024L, 0, LocalDateTime.now(), LocalDateTime.now() + ); + } + + private AccommodationPhoto createPhotoEntity() { + return AccommodationPhoto.builder() + .id(PHOTO_ID) + .accommodationId(ACCOMMODATION_ID) + .storageFilename("uuid.jpg") + .originalFilename("test.jpg") + .contentType("image/jpeg") + .fileSize(1024L) + .displayOrder(0) + .build(); + } + + @Nested + @DisplayName("POST /api/accommodation/{accommodationId}/photos") + class UploadPhotoEndpoint { + + @Test + @DisplayName("With valid request returns 201") + void upload_WithValidRequest_Returns201() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + .thenReturn(createPhotoResponse()); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(PHOTO_ID.toString())) + .andExpect(jsonPath("$.originalFilename").value("test.jpg")); + } + + @Test + @DisplayName("With display order parameter uses it") + void upload_WithDisplayOrder_UsesParameter() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), eq(5), any(UserContext.class))) + .thenReturn(createPhotoResponse()); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .param("displayOrder", "5") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isCreated()); + + verify(photoService).uploadPhoto(eq(ACCOMMODATION_ID), any(), eq(5), any(UserContext.class)); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void upload_WithMissingAuthHeaders_Returns401() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With GUEST role returns 403") + void upload_WithGuestRole_Returns403() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With accommodation not found returns 404") + void upload_WithAccommodationNotFound_Returns404() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + .thenThrow(new AccommodationNotFoundException("Not found")); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With invalid content type returns 400") + void upload_WithInvalidContentType_Returns400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.gif", "image/gif", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + .thenThrow(new InvalidContentTypeException("Invalid content type")); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With photo limit exceeded returns 400") + void upload_WithPhotoLimitExceeded_Returns400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + .thenThrow(new PhotoLimitExceededException("Limit exceeded")); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{accommodationId}/photos") + class ListPhotosEndpoint { + + @Test + @DisplayName("Returns 200 with list of photos") + void list_Returns200WithList() throws Exception { + when(photoService.listPhotos(ACCOMMODATION_ID)) + .thenReturn(List.of(createPhotoResponse())); + + mockMvc.perform(get("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(PHOTO_ID.toString())) + .andExpect(jsonPath("$[0].originalFilename").value("test.jpg")); + } + + @Test + @DisplayName("With accommodation not found returns 404") + void list_WithAccommodationNotFound_Returns404() throws Exception { + when(photoService.listPhotos(ACCOMMODATION_ID)) + .thenThrow(new AccommodationNotFoundException("Not found")); + + mockMvc.perform(get("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{accommodationId}/photos/{photoId}") + class GetPhotoEndpoint { + + @Test + @DisplayName("Returns 200 with image file") + void get_Returns200WithImageFile() throws Exception { + AccommodationPhoto photo = createPhotoEntity(); + byte[] content = "test image content".getBytes(); + + when(photoService.getPhotoMetadata(ACCOMMODATION_ID, PHOTO_ID)).thenReturn(photo); + when(photoService.getPhotoFile(ACCOMMODATION_ID, PHOTO_ID)) + .thenReturn(new ByteArrayInputStream(content)); + + mockMvc.perform(get("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.IMAGE_JPEG)) + .andExpect(header().longValue("Content-Length", photo.getFileSize())); + } + + @Test + @DisplayName("With photo not found returns 404") + void get_WithPhotoNotFound_Returns404() throws Exception { + when(photoService.getPhotoMetadata(ACCOMMODATION_ID, PHOTO_ID)) + .thenThrow(new PhotoNotFoundException("Not found")); + + mockMvc.perform(get("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("DELETE /api/accommodation/{accommodationId}/photos/{photoId}") + class DeletePhotoEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void delete_WithValidRequest_Returns204() throws Exception { + doNothing().when(photoService).deletePhoto(eq(ACCOMMODATION_ID), eq(PHOTO_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void delete_WithMissingAuthHeaders_Returns401() throws Exception { + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With GUEST role returns 403") + void delete_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With photo not found returns 404") + void delete_WithPhotoNotFound_Returns404() throws Exception { + doThrow(new PhotoNotFoundException("Not found")) + .when(photoService).deletePhoto(eq(ACCOMMODATION_ID), eq(PHOTO_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void delete_WithWrongOwner_Returns403() throws Exception { + doThrow(new ForbiddenException("Not the owner")) + .when(photoService).deletePhoto(eq(ACCOMMODATION_ID), eq(PHOTO_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java index a1e702c..14ee471 100644 --- a/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java @@ -11,6 +11,7 @@ import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MinIOContainer; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -36,6 +37,9 @@ class AccommodationIntegrationTest { .withUsername("test") .withPassword("test"); + @Container + static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2024-01-31T20-20-33Z"); + @Autowired private MockMvc mockMvc; @@ -55,6 +59,12 @@ static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.flyway.url", postgres::getJdbcUrl); registry.add("spring.flyway.user", postgres::getUsername); registry.add("spring.flyway.password", postgres::getPassword); + + // MinIO + registry.add("minio.endpoint", minio::getS3URL); + registry.add("minio.access-key", minio::getUserName); + registry.add("minio.secret-key", minio::getPassword); + registry.add("minio.bucket", () -> "test-accommodation-photos"); } private Map validCreateRequest() { diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java new file mode 100644 index 0000000..c59a736 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java @@ -0,0 +1,286 @@ +package com.devoops.accommodation.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AccommodationPhotoIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("accommodation_db_test") + .withUsername("test") + .withPassword("test"); + + @Container + static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2024-01-31T20-20-33Z"); + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static String accommodationId; + private static String photoId; + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID OTHER_HOST_ID = UUID.randomUUID(); + + private static final String ACCOMMODATION_BASE_PATH = "/api/accommodation"; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + // PostgreSQL + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + + // MinIO + registry.add("minio.endpoint", minio::getS3URL); + registry.add("minio.access-key", minio::getUserName); + registry.add("minio.secret-key", minio::getPassword); + registry.add("minio.bucket", () -> "test-accommodation-photos"); + } + + private String photosPath() { + return ACCOMMODATION_BASE_PATH + "/" + accommodationId + "/photos"; + } + + @Test + @Order(1) + @DisplayName("Create accommodation for photo tests") + void setup_CreateAccommodation() throws Exception { + var request = Map.of( + "name", "Photo Test Apartment", + "address", "123 Photo St", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + MvcResult result = mockMvc.perform(post(ACCOMMODATION_BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + accommodationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(2) + @DisplayName("Upload photo with valid request returns 201") + void uploadPhoto_WithValidRequest_Returns201() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test-image.jpg", "image/jpeg", + "fake image content".getBytes()); + + MvcResult result = mockMvc.perform(multipart(photosPath()) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.accommodationId").value(accommodationId)) + .andExpect(jsonPath("$.originalFilename").value("test-image.jpg")) + .andExpect(jsonPath("$.contentType").value("image/jpeg")) + .andExpect(jsonPath("$.displayOrder").value(0)) + .andReturn(); + + photoId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(3) + @DisplayName("Upload photo with custom display order uses provided order") + void uploadPhoto_WithDisplayOrder_UsesProvidedOrder() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "second-image.png", "image/png", + "fake image content 2".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file) + .param("displayOrder", "5") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.displayOrder").value(5)); + } + + @Test + @Order(4) + @DisplayName("Upload photo without auth headers returns 401") + void uploadPhoto_WithoutAuthHeaders_Returns401() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file)) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(5) + @DisplayName("Upload photo with GUEST role returns 403") + void uploadPhoto_WithGuestRole_Returns403() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(6) + @DisplayName("Upload photo with different host returns 403") + void uploadPhoto_WithDifferentHost_Returns403() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file) + .header("X-User-Id", OTHER_HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(7) + @DisplayName("Upload photo with invalid content type returns 400") + void uploadPhoto_WithInvalidContentType_Returns400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.gif", "image/gif", "content".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(8) + @DisplayName("List photos returns 200 with photo list") + void listPhotos_Returns200WithPhotoList() throws Exception { + mockMvc.perform(get(photosPath())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(2)))) + .andExpect(jsonPath("$[0].originalFilename").value("test-image.jpg")); + } + + @Test + @Order(9) + @DisplayName("List photos for non-existing accommodation returns 404") + void listPhotos_NonExistingAccommodation_Returns404() throws Exception { + mockMvc.perform(get(ACCOMMODATION_BASE_PATH + "/" + UUID.randomUUID() + "/photos")) + .andExpect(status().isNotFound()); + } + + @Test + @Order(10) + @DisplayName("Get photo file returns 200 with image content") + void getPhotoFile_Returns200WithImageContent() throws Exception { + mockMvc.perform(get(photosPath() + "/" + photoId)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.IMAGE_JPEG)) + .andExpect(header().exists("Content-Length")); + } + + @Test + @Order(11) + @DisplayName("Get photo file for non-existing photo returns 404") + void getPhotoFile_NonExistingPhoto_Returns404() throws Exception { + mockMvc.perform(get(photosPath() + "/" + UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + @Test + @Order(12) + @DisplayName("Delete photo without auth headers returns 401") + void deletePhoto_WithoutAuthHeaders_Returns401() throws Exception { + mockMvc.perform(delete(photosPath() + "/" + photoId)) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(13) + @DisplayName("Delete photo with GUEST role returns 403") + void deletePhoto_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(delete(photosPath() + "/" + photoId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(14) + @DisplayName("Delete photo with different host returns 403") + void deletePhoto_WithDifferentHost_Returns403() throws Exception { + mockMvc.perform(delete(photosPath() + "/" + photoId) + .header("X-User-Id", OTHER_HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(15) + @DisplayName("Delete photo with valid owner returns 204") + void deletePhoto_WithValidOwner_Returns204() throws Exception { + mockMvc.perform(delete(photosPath() + "/" + photoId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @Order(16) + @DisplayName("After delete, get photo returns 404 (soft-delete filters)") + void deletePhoto_ThenGet_Returns404() throws Exception { + mockMvc.perform(get(photosPath() + "/" + photoId)) + .andExpect(status().isNotFound()); + } + + @Test + @Order(17) + @DisplayName("After delete, list photos excludes deleted photo") + void deletePhoto_ThenList_ExcludesDeletedPhoto() throws Exception { + mockMvc.perform(get(photosPath())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + photoId + "')]").doesNotExist()); + } +} diff --git a/src/test/java/com/devoops/accommodation/service/AccommodationPhotoServiceTest.java b/src/test/java/com/devoops/accommodation/service/AccommodationPhotoServiceTest.java new file mode 100644 index 0000000..c381fc0 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/AccommodationPhotoServiceTest.java @@ -0,0 +1,339 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AccommodationPhoto; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.mapper.AccommodationPhotoMapper; +import com.devoops.accommodation.repository.AccommodationPhotoRepository; +import com.devoops.accommodation.repository.AccommodationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AccommodationPhotoServiceTest { + + @Mock + private AccommodationPhotoRepository photoRepository; + + @Mock + private AccommodationRepository accommodationRepository; + + @Mock + private PhotoStorageService photoStorageService; + + @Mock + private AccommodationPhotoMapper photoMapper; + + @InjectMocks + private AccommodationPhotoService photoService; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID OTHER_HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID PHOTO_ID = UUID.randomUUID(); + private static final UserContext HOST_CONTEXT = new UserContext(HOST_ID, "HOST"); + private static final UserContext OTHER_HOST_CONTEXT = new UserContext(OTHER_HOST_ID, "HOST"); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(photoService, "maxPhotosPerAccommodation", 20); + ReflectionTestUtils.setField(photoService, "allowedContentTypesConfig", "image/jpeg,image/png,image/webp"); + } + + private Accommodation createAccommodation() { + return Accommodation.builder() + .id(ACCOMMODATION_ID) + .hostId(HOST_ID) + .name("Test Apartment") + .address("123 Test St") + .minGuests(1) + .maxGuests(4) + .pricingMode(PricingMode.PER_GUEST) + .approvalMode(ApprovalMode.MANUAL) + .build(); + } + + private AccommodationPhoto createPhoto() { + return AccommodationPhoto.builder() + .id(PHOTO_ID) + .accommodationId(ACCOMMODATION_ID) + .storageFilename("uuid-filename.jpg") + .originalFilename("original.jpg") + .contentType("image/jpeg") + .fileSize(1024L) + .displayOrder(0) + .build(); + } + + private AccommodationPhotoResponse createPhotoResponse() { + return new AccommodationPhotoResponse( + PHOTO_ID, ACCOMMODATION_ID, "original.jpg", "image/jpeg", + 1024L, 0, LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Nested + @DisplayName("uploadPhoto") + class UploadPhotoTests { + + @Test + @DisplayName("With valid request uploads photo and returns response") + void uploadPhoto_ValidRequest_ReturnsResponse() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + var accommodation = createAccommodation(); + var photo = createPhoto(); + var response = createPhotoResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.countByAccommodationId(ACCOMMODATION_ID)).thenReturn(0L); + when(photoStorageService.store(file)).thenReturn("uuid-filename.jpg"); + when(photoRepository.findMaxDisplayOrder(ACCOMMODATION_ID)).thenReturn(-1); + when(photoRepository.saveAndFlush(any(AccommodationPhoto.class))).thenReturn(photo); + when(photoMapper.toResponse(photo)).thenReturn(response); + + AccommodationPhotoResponse result = photoService.uploadPhoto(ACCOMMODATION_ID, file, null, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + verify(photoStorageService).store(file); + verify(photoRepository).saveAndFlush(any(AccommodationPhoto.class)); + } + + @Test + @DisplayName("With custom display order uses provided order") + void uploadPhoto_CustomDisplayOrder_UsesProvidedOrder() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + var accommodation = createAccommodation(); + var photo = createPhoto(); + var response = createPhotoResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.countByAccommodationId(ACCOMMODATION_ID)).thenReturn(0L); + when(photoStorageService.store(file)).thenReturn("uuid-filename.jpg"); + when(photoRepository.saveAndFlush(any(AccommodationPhoto.class))).thenReturn(photo); + when(photoMapper.toResponse(photo)).thenReturn(response); + + photoService.uploadPhoto(ACCOMMODATION_ID, file, 5, HOST_CONTEXT); + + verify(photoRepository, never()).findMaxDisplayOrder(any()); + } + + @Test + @DisplayName("With accommodation not found throws AccommodationNotFoundException") + void uploadPhoto_AccommodationNotFound_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.uploadPhoto(ACCOMMODATION_ID, file, null, HOST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void uploadPhoto_WrongOwner_ThrowsForbiddenException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> photoService.uploadPhoto(ACCOMMODATION_ID, file, null, OTHER_HOST_CONTEXT)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("not the owner"); + } + + @Test + @DisplayName("With invalid content type throws InvalidContentTypeException") + void uploadPhoto_InvalidContentType_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.gif", "image/gif", "test content".getBytes()); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> photoService.uploadPhoto(ACCOMMODATION_ID, file, null, HOST_CONTEXT)) + .isInstanceOf(InvalidContentTypeException.class) + .hasMessageContaining("image/gif"); + } + + @Test + @DisplayName("With photo limit reached throws PhotoLimitExceededException") + void uploadPhoto_PhotoLimitReached_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.countByAccommodationId(ACCOMMODATION_ID)).thenReturn(20L); + + assertThatThrownBy(() -> photoService.uploadPhoto(ACCOMMODATION_ID, file, null, HOST_CONTEXT)) + .isInstanceOf(PhotoLimitExceededException.class) + .hasMessageContaining("Maximum number of photos"); + } + } + + @Nested + @DisplayName("listPhotos") + class ListPhotosTests { + + @Test + @DisplayName("Returns list of photo responses") + void listPhotos_ReturnsPhotoResponses() { + var accommodation = createAccommodation(); + var photos = List.of(createPhoto()); + var responses = List.of(createPhotoResponse()); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.findByAccommodationIdOrderByDisplayOrderAsc(ACCOMMODATION_ID)).thenReturn(photos); + when(photoMapper.toResponseList(photos)).thenReturn(responses); + + List result = photoService.listPhotos(ACCOMMODATION_ID); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).isEqualTo(responses.get(0)); + } + + @Test + @DisplayName("With accommodation not found throws AccommodationNotFoundException") + void listPhotos_AccommodationNotFound_ThrowsException() { + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.listPhotos(ACCOMMODATION_ID)) + .isInstanceOf(AccommodationNotFoundException.class); + } + } + + @Nested + @DisplayName("getPhotoFile") + class GetPhotoFileTests { + + @Test + @DisplayName("Returns input stream for photo file") + void getPhotoFile_ReturnsInputStream() { + var photo = createPhoto(); + InputStream mockStream = new ByteArrayInputStream("test content".getBytes()); + + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.of(photo)); + when(photoStorageService.loadAsStream(photo.getStorageFilename())).thenReturn(mockStream); + + InputStream result = photoService.getPhotoFile(ACCOMMODATION_ID, PHOTO_ID); + + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("With photo not found throws PhotoNotFoundException") + void getPhotoFile_PhotoNotFound_ThrowsException() { + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.getPhotoFile(ACCOMMODATION_ID, PHOTO_ID)) + .isInstanceOf(PhotoNotFoundException.class); + } + } + + @Nested + @DisplayName("getPhotoMetadata") + class GetPhotoMetadataTests { + + @Test + @DisplayName("Returns photo entity") + void getPhotoMetadata_ReturnsPhotoEntity() { + var photo = createPhoto(); + + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.of(photo)); + + AccommodationPhoto result = photoService.getPhotoMetadata(ACCOMMODATION_ID, PHOTO_ID); + + assertThat(result).isEqualTo(photo); + } + + @Test + @DisplayName("With photo not found throws PhotoNotFoundException") + void getPhotoMetadata_PhotoNotFound_ThrowsException() { + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.getPhotoMetadata(ACCOMMODATION_ID, PHOTO_ID)) + .isInstanceOf(PhotoNotFoundException.class); + } + } + + @Nested + @DisplayName("deletePhoto") + class DeletePhotoTests { + + @Test + @DisplayName("With valid owner soft-deletes photo and removes from storage") + void deletePhoto_ValidOwner_SoftDeletesPhoto() { + var accommodation = createAccommodation(); + var photo = createPhoto(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.of(photo)); + + photoService.deletePhoto(ACCOMMODATION_ID, PHOTO_ID, HOST_CONTEXT); + + assertThat(photo.isDeleted()).isTrue(); + verify(photoStorageService).delete(photo.getStorageFilename()); + verify(photoRepository).save(photo); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void deletePhoto_WrongOwner_ThrowsForbiddenException() { + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> photoService.deletePhoto(ACCOMMODATION_ID, PHOTO_ID, OTHER_HOST_CONTEXT)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With accommodation not found throws AccommodationNotFoundException") + void deletePhoto_AccommodationNotFound_ThrowsException() { + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.deletePhoto(ACCOMMODATION_ID, PHOTO_ID, HOST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class); + } + + @Test + @DisplayName("With photo not found throws PhotoNotFoundException") + void deletePhoto_PhotoNotFound_ThrowsException() { + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.deletePhoto(ACCOMMODATION_ID, PHOTO_ID, HOST_CONTEXT)) + .isInstanceOf(PhotoNotFoundException.class); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/service/PhotoStorageServiceTest.java b/src/test/java/com/devoops/accommodation/service/PhotoStorageServiceTest.java new file mode 100644 index 0000000..e46e444 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/PhotoStorageServiceTest.java @@ -0,0 +1,188 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.exception.PhotoStorageException; +import io.minio.*; +import io.minio.errors.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PhotoStorageServiceTest { + + @Mock + private MinioClient minioClient; + + private PhotoStorageService photoStorageService; + + private static final String BUCKET_NAME = "test-bucket"; + + @BeforeEach + void setUp() { + photoStorageService = new PhotoStorageService(minioClient); + ReflectionTestUtils.setField(photoStorageService, "bucketName", BUCKET_NAME); + } + + @Nested + @DisplayName("init") + class InitTests { + + @Test + @DisplayName("Creates bucket if it does not exist") + void init_BucketDoesNotExist_CreatesBucket() throws Exception { + when(minioClient.bucketExists(any(BucketExistsArgs.class))).thenReturn(false); + + photoStorageService.init(); + + verify(minioClient).makeBucket(any(MakeBucketArgs.class)); + } + + @Test + @DisplayName("Does not create bucket if it already exists") + void init_BucketExists_DoesNotCreateBucket() throws Exception { + when(minioClient.bucketExists(any(BucketExistsArgs.class))).thenReturn(true); + + photoStorageService.init(); + + verify(minioClient, never()).makeBucket(any(MakeBucketArgs.class)); + } + + @Test + @DisplayName("Throws PhotoStorageException on MinIO error") + void init_MinioError_ThrowsPhotoStorageException() throws Exception { + when(minioClient.bucketExists(any(BucketExistsArgs.class))) + .thenThrow(new RuntimeException("Connection failed")); + + assertThatThrownBy(() -> photoStorageService.init()) + .isInstanceOf(PhotoStorageException.class) + .hasMessageContaining("Failed to initialize photo storage"); + } + } + + @Nested + @DisplayName("store") + class StoreTests { + + @Test + @DisplayName("Stores file and returns object key with extension") + void store_ValidFile_ReturnsObjectKeyWithExtension() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test-image.jpg", "image/jpeg", "test content".getBytes()); + + String objectKey = photoStorageService.store(file); + + assertThat(objectKey).endsWith(".jpg"); + assertThat(objectKey).matches("[a-f0-9\\-]+\\.jpg"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PutObjectArgs.class); + verify(minioClient).putObject(captor.capture()); + assertThat(captor.getValue().bucket()).isEqualTo(BUCKET_NAME); + assertThat(captor.getValue().contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("Stores file without extension when original filename has no extension") + void store_FileWithoutExtension_ReturnsObjectKeyWithoutExtension() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "testimage", "image/jpeg", "test content".getBytes()); + + String objectKey = photoStorageService.store(file); + + assertThat(objectKey).doesNotContain("."); + verify(minioClient).putObject(any(PutObjectArgs.class)); + } + + @Test + @DisplayName("Throws PhotoStorageException on MinIO error") + void store_MinioError_ThrowsPhotoStorageException() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + doThrow(new RuntimeException("Upload failed")) + .when(minioClient).putObject(any(PutObjectArgs.class)); + + assertThatThrownBy(() -> photoStorageService.store(file)) + .isInstanceOf(PhotoStorageException.class) + .hasMessageContaining("Failed to store photo"); + } + } + + @Nested + @DisplayName("loadAsStream") + class LoadAsStreamTests { + + @Test + @DisplayName("Returns input stream for existing object") + void loadAsStream_ExistingObject_ReturnsInputStream() throws Exception { + String objectKey = "test-object.jpg"; + InputStream mockStream = new ByteArrayInputStream("test content".getBytes()); + GetObjectResponse mockResponse = mock(GetObjectResponse.class); + + when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockResponse); + + InputStream result = photoStorageService.loadAsStream(objectKey); + + assertThat(result).isNotNull(); + verify(minioClient).getObject(any(GetObjectArgs.class)); + } + + @Test + @DisplayName("Throws PhotoStorageException on MinIO error") + void loadAsStream_MinioError_ThrowsPhotoStorageException() throws Exception { + String objectKey = "nonexistent.jpg"; + + when(minioClient.getObject(any(GetObjectArgs.class))) + .thenThrow(new RuntimeException("Object not found")); + + assertThatThrownBy(() -> photoStorageService.loadAsStream(objectKey)) + .isInstanceOf(PhotoStorageException.class) + .hasMessageContaining("Failed to load photo"); + } + } + + @Nested + @DisplayName("delete") + class DeleteTests { + + @Test + @DisplayName("Deletes object from bucket") + void delete_ExistingObject_DeletesFromBucket() throws Exception { + String objectKey = "test-object.jpg"; + + photoStorageService.delete(objectKey); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RemoveObjectArgs.class); + verify(minioClient).removeObject(captor.capture()); + assertThat(captor.getValue().bucket()).isEqualTo(BUCKET_NAME); + assertThat(captor.getValue().object()).isEqualTo(objectKey); + } + + @Test + @DisplayName("Throws PhotoStorageException on MinIO error") + void delete_MinioError_ThrowsPhotoStorageException() throws Exception { + String objectKey = "test-object.jpg"; + + doThrow(new RuntimeException("Delete failed")) + .when(minioClient).removeObject(any(RemoveObjectArgs.class)); + + assertThatThrownBy(() -> photoStorageService.delete(objectKey)) + .isInstanceOf(PhotoStorageException.class) + .hasMessageContaining("Failed to delete photo"); + } + } +} From 8c5da456a6481430bdf5bacd33637c2fb7023283 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:34:40 +0100 Subject: [PATCH 12/24] feat: Add endpoint for fetching all accommodations. --- .../controller/AccommodationController.java | 8 ++++++++ .../accommodation/service/AccommodationService.java | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java index 805971c..9e3f900 100644 --- a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java @@ -8,6 +8,7 @@ import com.devoops.accommodation.service.AccommodationService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -31,6 +32,13 @@ public ResponseEntity create( return ResponseEntity.status(HttpStatus.CREATED).body(response); } + @GetMapping + public ResponseEntity> getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "12") int size) { + return ResponseEntity.ok(accommodationService.getAll(page, size)); + } + @GetMapping("/{id}") public ResponseEntity getById(@PathVariable UUID id) { return ResponseEntity.ok(accommodationService.getById(id)); diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationService.java b/src/main/java/com/devoops/accommodation/service/AccommodationService.java index df22199..9342117 100644 --- a/src/main/java/com/devoops/accommodation/service/AccommodationService.java +++ b/src/main/java/com/devoops/accommodation/service/AccommodationService.java @@ -10,6 +10,10 @@ import com.devoops.accommodation.mapper.AccommodationMapper; import com.devoops.accommodation.repository.AccommodationRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,6 +55,13 @@ public List getByHostId(UUID hostId) { return accommodationMapper.toResponseList(accommodations); } + @Transactional(readOnly = true) + public Page getAll(int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page accommodations = accommodationRepository.findAll(pageable); + return accommodations.map(accommodationMapper::toResponse); + } + @Transactional public AccommodationResponse update(UUID id, UpdateAccommodationRequest request, UserContext userContext) { Accommodation accommodation = findAccommodationOrThrow(id); From 1eb11654d58ae58ed51c7a3eb1db78abd66f59a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20=C4=8Cuturi=C4=87?= Date: Tue, 17 Feb 2026 13:32:44 +0100 Subject: [PATCH 13/24] feat: Add price and availability for accommodation --- build.gradle.kts | 30 ++ environment/.local.env | 4 + .../AvailabilityPeriodController.java | 67 +++ .../CreateAvailabilityPeriodRequest.java | 13 + .../UpdateAvailabilityPeriodRequest.java | 12 + .../response/AvailabilityPeriodResponse.java | 16 + .../entity/AvailabilityPeriod.java | 36 ++ .../AvailabilityPeriodNotFoundException.java | 7 + .../exception/GlobalExceptionHandler.java | 15 + ...verlappingAvailabilityPeriodException.java | 7 + .../ReservationConflictException.java | 7 + .../grpc/ReservationGrpcClient.java | 33 ++ .../mapper/AvailabilityPeriodMapper.java | 24 ++ .../AvailabilityPeriodRepository.java | 44 ++ .../service/AvailabilityPeriodService.java | 153 +++++++ src/main/proto/reservation_internal.proto | 20 + src/main/resources/application.properties | 4 + .../V5__create_availability_periods_table.sql | 19 + .../AvailabilityPeriodControllerTest.java | 298 ++++++++++++++ .../AvailabilityPeriodIntegrationTest.java | 289 +++++++++++++ .../AvailabilityPeriodServiceTest.java | 388 ++++++++++++++++++ 21 files changed, 1486 insertions(+) create mode 100644 src/main/java/com/devoops/accommodation/controller/AvailabilityPeriodController.java create mode 100644 src/main/java/com/devoops/accommodation/dto/request/CreateAvailabilityPeriodRequest.java create mode 100644 src/main/java/com/devoops/accommodation/dto/request/UpdateAvailabilityPeriodRequest.java create mode 100644 src/main/java/com/devoops/accommodation/dto/response/AvailabilityPeriodResponse.java create mode 100644 src/main/java/com/devoops/accommodation/entity/AvailabilityPeriod.java create mode 100644 src/main/java/com/devoops/accommodation/exception/AvailabilityPeriodNotFoundException.java create mode 100644 src/main/java/com/devoops/accommodation/exception/OverlappingAvailabilityPeriodException.java create mode 100644 src/main/java/com/devoops/accommodation/exception/ReservationConflictException.java create mode 100644 src/main/java/com/devoops/accommodation/grpc/ReservationGrpcClient.java create mode 100644 src/main/java/com/devoops/accommodation/mapper/AvailabilityPeriodMapper.java create mode 100644 src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java create mode 100644 src/main/java/com/devoops/accommodation/service/AvailabilityPeriodService.java create mode 100644 src/main/proto/reservation_internal.proto create mode 100644 src/main/resources/db/migration/V5__create_availability_periods_table.sql create mode 100644 src/test/java/com/devoops/accommodation/controller/AvailabilityPeriodControllerTest.java create mode 100644 src/test/java/com/devoops/accommodation/integration/AvailabilityPeriodIntegrationTest.java create mode 100644 src/test/java/com/devoops/accommodation/service/AvailabilityPeriodServiceTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 34840c0..d226bb3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,11 @@ +import com.google.protobuf.gradle.* + plugins { java jacoco id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" + id("com.google.protobuf") version "0.9.4" } group = "com.devoops" @@ -19,6 +22,8 @@ repositories { mavenCentral() } +val grpcVersion = "1.68.0" + dependencies { // Web and Core implementation("org.springframework.boot:spring-boot-starter-webmvc") @@ -47,6 +52,13 @@ dependencies { // MinIO S3-compatible object storage implementation("io.minio:minio:8.5.7") + // gRPC Client + implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE") + implementation("io.grpc:grpc-protobuf:$grpcVersion") + implementation("io.grpc:grpc-stub:$grpcVersion") + implementation("io.grpc:grpc-netty-shaded:$grpcVersion") + compileOnly("javax.annotation:javax.annotation-api:1.3.2") + // Tracing (Zipkin) implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") implementation("org.springframework.boot:spring-boot-starter-zipkin") @@ -69,6 +81,24 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.25.5" + } + plugins { + id("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all().forEach { task -> + task.plugins { + id("grpc") + } + } + } +} + tasks.withType { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) diff --git a/environment/.local.env b/environment/.local.env index 09a8f4c..375ba1e 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -12,3 +12,7 @@ MINIO_ENDPOINT=http://devoops-minio:9000 MINIO_ACCESS_KEY=devoops MINIO_SECRET_KEY=devoops123 MINIO_BUCKET=accommodation-photos + +# gRPC +RESERVATION_GRPC_HOST=devoops-reservation-service +RESERVATION_GRPC_PORT=9090 diff --git a/src/main/java/com/devoops/accommodation/controller/AvailabilityPeriodController.java b/src/main/java/com/devoops/accommodation/controller/AvailabilityPeriodController.java new file mode 100644 index 0000000..b4c2150 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/controller/AvailabilityPeriodController.java @@ -0,0 +1,67 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RequireRole; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.request.UpdateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.service.AvailabilityPeriodService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/accommodation/{accommodationId}/availability") +@RequiredArgsConstructor +public class AvailabilityPeriodController { + + private final AvailabilityPeriodService availabilityPeriodService; + + @PostMapping + @RequireRole("HOST") + public ResponseEntity create( + @PathVariable UUID accommodationId, + @Valid @RequestBody CreateAvailabilityPeriodRequest request, + UserContext userContext) { + AvailabilityPeriodResponse response = availabilityPeriodService.create(accommodationId, request, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public ResponseEntity> getByAccommodationId( + @PathVariable UUID accommodationId) { + return ResponseEntity.ok(availabilityPeriodService.getByAccommodationId(accommodationId)); + } + + @GetMapping("/{periodId}") + public ResponseEntity getById( + @PathVariable UUID accommodationId, + @PathVariable UUID periodId) { + return ResponseEntity.ok(availabilityPeriodService.getById(accommodationId, periodId)); + } + + @PutMapping("/{periodId}") + @RequireRole("HOST") + public ResponseEntity update( + @PathVariable UUID accommodationId, + @PathVariable UUID periodId, + @Valid @RequestBody UpdateAvailabilityPeriodRequest request, + UserContext userContext) { + return ResponseEntity.ok(availabilityPeriodService.update(accommodationId, periodId, request, userContext)); + } + + @DeleteMapping("/{periodId}") + @RequireRole("HOST") + public ResponseEntity delete( + @PathVariable UUID accommodationId, + @PathVariable UUID periodId, + UserContext userContext) { + availabilityPeriodService.delete(accommodationId, periodId, userContext); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/devoops/accommodation/dto/request/CreateAvailabilityPeriodRequest.java b/src/main/java/com/devoops/accommodation/dto/request/CreateAvailabilityPeriodRequest.java new file mode 100644 index 0000000..f459543 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/request/CreateAvailabilityPeriodRequest.java @@ -0,0 +1,13 @@ +package com.devoops.accommodation.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record CreateAvailabilityPeriodRequest( + @NotNull(message = "Start date is required") LocalDate startDate, + @NotNull(message = "End date is required") LocalDate endDate, + @NotNull(message = "Price per day is required") @Positive(message = "Price per day must be positive") BigDecimal pricePerDay +) {} diff --git a/src/main/java/com/devoops/accommodation/dto/request/UpdateAvailabilityPeriodRequest.java b/src/main/java/com/devoops/accommodation/dto/request/UpdateAvailabilityPeriodRequest.java new file mode 100644 index 0000000..a95d280 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/request/UpdateAvailabilityPeriodRequest.java @@ -0,0 +1,12 @@ +package com.devoops.accommodation.dto.request; + +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record UpdateAvailabilityPeriodRequest( + LocalDate startDate, + LocalDate endDate, + @Positive(message = "Price per day must be positive") BigDecimal pricePerDay +) {} diff --git a/src/main/java/com/devoops/accommodation/dto/response/AvailabilityPeriodResponse.java b/src/main/java/com/devoops/accommodation/dto/response/AvailabilityPeriodResponse.java new file mode 100644 index 0000000..7289bfa --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/response/AvailabilityPeriodResponse.java @@ -0,0 +1,16 @@ +package com.devoops.accommodation.dto.response; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +public record AvailabilityPeriodResponse( + UUID id, + UUID accommodationId, + LocalDate startDate, + LocalDate endDate, + BigDecimal pricePerDay, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} diff --git a/src/main/java/com/devoops/accommodation/entity/AvailabilityPeriod.java b/src/main/java/com/devoops/accommodation/entity/AvailabilityPeriod.java new file mode 100644 index 0000000..b9bd867 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/AvailabilityPeriod.java @@ -0,0 +1,36 @@ +package com.devoops.accommodation.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "availability_periods") +@SQLRestriction("is_deleted = false") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public class AvailabilityPeriod extends BaseEntity { + + @Column(name = "accommodation_id", nullable = false) + private UUID accommodationId; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Column(name = "price_per_day", nullable = false, precision = 12, scale = 2) + private BigDecimal pricePerDay; +} diff --git a/src/main/java/com/devoops/accommodation/exception/AvailabilityPeriodNotFoundException.java b/src/main/java/com/devoops/accommodation/exception/AvailabilityPeriodNotFoundException.java new file mode 100644 index 0000000..686b6fc --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/AvailabilityPeriodNotFoundException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class AvailabilityPeriodNotFoundException extends RuntimeException { + public AvailabilityPeriodNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java index 2f48f7e..c66ec8b 100644 --- a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java @@ -71,4 +71,19 @@ public ProblemDetail handleInvalidContentType(InvalidContentTypeException ex) { public ProblemDetail handleMaxUploadSize(MaxUploadSizeExceededException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "File size exceeds the maximum allowed limit"); } + + @ExceptionHandler(AvailabilityPeriodNotFoundException.class) + public ProblemDetail handleAvailabilityPeriodNotFound(AvailabilityPeriodNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(OverlappingAvailabilityPeriodException.class) + public ProblemDetail handleOverlappingAvailabilityPeriod(OverlappingAvailabilityPeriodException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); + } + + @ExceptionHandler(ReservationConflictException.class) + public ProblemDetail handleReservationConflict(ReservationConflictException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); + } } diff --git a/src/main/java/com/devoops/accommodation/exception/OverlappingAvailabilityPeriodException.java b/src/main/java/com/devoops/accommodation/exception/OverlappingAvailabilityPeriodException.java new file mode 100644 index 0000000..a1f9b4a --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/OverlappingAvailabilityPeriodException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class OverlappingAvailabilityPeriodException extends RuntimeException { + public OverlappingAvailabilityPeriodException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/ReservationConflictException.java b/src/main/java/com/devoops/accommodation/exception/ReservationConflictException.java new file mode 100644 index 0000000..7553aa7 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/ReservationConflictException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class ReservationConflictException extends RuntimeException { + public ReservationConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/grpc/ReservationGrpcClient.java b/src/main/java/com/devoops/accommodation/grpc/ReservationGrpcClient.java new file mode 100644 index 0000000..f743ba7 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/grpc/ReservationGrpcClient.java @@ -0,0 +1,33 @@ +package com.devoops.accommodation.grpc; + +import com.devoops.accommodation.grpc.proto.CheckReservationsExistRequest; +import com.devoops.accommodation.grpc.proto.CheckReservationsExistResponse; +import com.devoops.accommodation.grpc.proto.ReservationInternalServiceGrpc; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.UUID; + +@Component +@Slf4j +public class ReservationGrpcClient { + + @GrpcClient("reservation-service") + private ReservationInternalServiceGrpc.ReservationInternalServiceBlockingStub reservationStub; + + public boolean hasApprovedReservations(UUID accommodationId, LocalDate startDate, LocalDate endDate) { + log.debug("gRPC: Checking approved reservations for accommodation {} between {} and {}", + accommodationId, startDate, endDate); + + CheckReservationsExistRequest request = CheckReservationsExistRequest.newBuilder() + .setAccommodationId(accommodationId.toString()) + .setStartDate(startDate.toString()) + .setEndDate(endDate.toString()) + .build(); + + CheckReservationsExistResponse response = reservationStub.checkReservationsExist(request); + return response.getHasReservations(); + } +} diff --git a/src/main/java/com/devoops/accommodation/mapper/AvailabilityPeriodMapper.java b/src/main/java/com/devoops/accommodation/mapper/AvailabilityPeriodMapper.java new file mode 100644 index 0000000..2587e61 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/mapper/AvailabilityPeriodMapper.java @@ -0,0 +1,24 @@ +package com.devoops.accommodation.mapper; + +import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface AvailabilityPeriodMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "accommodationId", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "isDeleted", ignore = true) + AvailabilityPeriod toEntity(CreateAvailabilityPeriodRequest request); + + AvailabilityPeriodResponse toResponse(AvailabilityPeriod period); + + List toResponseList(List periods); +} diff --git a/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java b/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java new file mode 100644 index 0000000..39d629e --- /dev/null +++ b/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java @@ -0,0 +1,44 @@ +package com.devoops.accommodation.repository; + +import com.devoops.accommodation.entity.AvailabilityPeriod; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AvailabilityPeriodRepository extends JpaRepository { + + List findByAccommodationIdOrderByStartDateAsc(UUID accommodationId); + + Optional findByIdAndAccommodationId(UUID id, UUID accommodationId); + + @Query(""" + SELECT ap FROM AvailabilityPeriod ap + WHERE ap.accommodationId = :accommodationId + AND ap.startDate < :endDate + AND ap.endDate > :startDate + """) + List findOverlapping( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + @Query(""" + SELECT ap FROM AvailabilityPeriod ap + WHERE ap.accommodationId = :accommodationId + AND ap.id != :excludeId + AND ap.startDate < :endDate + AND ap.endDate > :startDate + """) + List findOverlappingExcluding( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("excludeId") UUID excludeId + ); +} diff --git a/src/main/java/com/devoops/accommodation/service/AvailabilityPeriodService.java b/src/main/java/com/devoops/accommodation/service/AvailabilityPeriodService.java new file mode 100644 index 0000000..6d2749a --- /dev/null +++ b/src/main/java/com/devoops/accommodation/service/AvailabilityPeriodService.java @@ -0,0 +1,153 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.request.UpdateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.grpc.ReservationGrpcClient; +import com.devoops.accommodation.mapper.AvailabilityPeriodMapper; +import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AvailabilityPeriodService { + + private final AvailabilityPeriodRepository availabilityPeriodRepository; + private final AccommodationRepository accommodationRepository; + private final AvailabilityPeriodMapper availabilityPeriodMapper; + private final ReservationGrpcClient reservationGrpcClient; + + @Transactional + public AvailabilityPeriodResponse create(UUID accommodationId, CreateAvailabilityPeriodRequest request, + UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + validateDates(request.startDate(), request.endDate()); + + List overlapping = availabilityPeriodRepository.findOverlapping( + accommodationId, request.startDate(), request.endDate()); + if (!overlapping.isEmpty()) { + throw new OverlappingAvailabilityPeriodException( + "The availability period overlaps with an existing period"); + } + + AvailabilityPeriod period = availabilityPeriodMapper.toEntity(request); + period.setAccommodationId(accommodationId); + + period = availabilityPeriodRepository.saveAndFlush(period); + log.info("Created availability period {} for accommodation {}", period.getId(), accommodationId); + return availabilityPeriodMapper.toResponse(period); + } + + @Transactional(readOnly = true) + public List getByAccommodationId(UUID accommodationId) { + findAccommodationOrThrow(accommodationId); + List periods = availabilityPeriodRepository + .findByAccommodationIdOrderByStartDateAsc(accommodationId); + return availabilityPeriodMapper.toResponseList(periods); + } + + @Transactional(readOnly = true) + public AvailabilityPeriodResponse getById(UUID accommodationId, UUID periodId) { + AvailabilityPeriod period = findPeriodOrThrow(accommodationId, periodId); + return availabilityPeriodMapper.toResponse(period); + } + + @Transactional + public AvailabilityPeriodResponse update(UUID accommodationId, UUID periodId, + UpdateAvailabilityPeriodRequest request, + UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + + AvailabilityPeriod period = findPeriodOrThrow(accommodationId, periodId); + + // Check for approved reservations in the CURRENT period dates before allowing changes + if (reservationGrpcClient.hasApprovedReservations(accommodationId, + period.getStartDate(), period.getEndDate())) { + throw new ReservationConflictException( + "Cannot modify availability period: approved reservations exist in this date range"); + } + + // Apply partial updates + if (request.startDate() != null) { + period.setStartDate(request.startDate()); + } + if (request.endDate() != null) { + period.setEndDate(request.endDate()); + } + if (request.pricePerDay() != null) { + period.setPricePerDay(request.pricePerDay()); + } + + validateDates(period.getStartDate(), period.getEndDate()); + + // Validate new dates don't overlap with other periods + List overlapping = availabilityPeriodRepository.findOverlappingExcluding( + accommodationId, period.getStartDate(), period.getEndDate(), periodId); + if (!overlapping.isEmpty()) { + throw new OverlappingAvailabilityPeriodException( + "The updated availability period overlaps with an existing period"); + } + + period = availabilityPeriodRepository.saveAndFlush(period); + log.info("Updated availability period {} for accommodation {}", periodId, accommodationId); + return availabilityPeriodMapper.toResponse(period); + } + + @Transactional + public void delete(UUID accommodationId, UUID periodId, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + + AvailabilityPeriod period = findPeriodOrThrow(accommodationId, periodId); + + // Check for approved reservations before allowing deletion + if (reservationGrpcClient.hasApprovedReservations(accommodationId, + period.getStartDate(), period.getEndDate())) { + throw new ReservationConflictException( + "Cannot delete availability period: approved reservations exist in this date range"); + } + + period.setDeleted(true); + availabilityPeriodRepository.save(period); + log.info("Deleted availability period {} for accommodation {}", periodId, accommodationId); + } + + private Accommodation findAccommodationOrThrow(UUID accommodationId) { + return accommodationRepository.findById(accommodationId) + .orElseThrow(() -> new AccommodationNotFoundException( + "Accommodation not found with id: " + accommodationId)); + } + + private AvailabilityPeriod findPeriodOrThrow(UUID accommodationId, UUID periodId) { + return availabilityPeriodRepository.findByIdAndAccommodationId(periodId, accommodationId) + .orElseThrow(() -> new AvailabilityPeriodNotFoundException( + "Availability period not found with id: " + periodId)); + } + + private void validateOwnership(Accommodation accommodation, UserContext userContext) { + if (!accommodation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You are not the owner of this accommodation"); + } + } + + private void validateDates(LocalDate startDate, LocalDate endDate) { + if (!endDate.isAfter(startDate)) { + throw new IllegalArgumentException("End date must be after start date"); + } + } +} diff --git a/src/main/proto/reservation_internal.proto b/src/main/proto/reservation_internal.proto new file mode 100644 index 0000000..6af4d68 --- /dev/null +++ b/src/main/proto/reservation_internal.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package reservation; + +option java_multiple_files = true; +option java_package = "com.devoops.accommodation.grpc.proto"; + +service ReservationInternalService { + rpc CheckReservationsExist(CheckReservationsExistRequest) returns (CheckReservationsExistResponse); +} + +message CheckReservationsExistRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; +} + +message CheckReservationsExistResponse { + bool has_reservations = 1; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f7053cc..e63e04c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -44,3 +44,7 @@ minio.bucket=${MINIO_BUCKET:accommodation-photos} # Photo configuration app.photo.max-photos-per-accommodation=20 app.photo.allowed-content-types=image/jpeg,image/png,image/webp + +# gRPC Client +grpc.client.reservation-service.address=static://${RESERVATION_GRPC_HOST:devoops-reservation-service}:${RESERVATION_GRPC_PORT:9090} +grpc.client.reservation-service.negotiationType=plaintext diff --git a/src/main/resources/db/migration/V5__create_availability_periods_table.sql b/src/main/resources/db/migration/V5__create_availability_periods_table.sql new file mode 100644 index 0000000..2a6b295 --- /dev/null +++ b/src/main/resources/db/migration/V5__create_availability_periods_table.sql @@ -0,0 +1,19 @@ +CREATE TABLE availability_periods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + accommodation_id UUID NOT NULL REFERENCES accommodations(id) ON DELETE CASCADE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + price_per_day NUMERIC(12, 2) NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT chk_availability_dates CHECK (end_date > start_date), + CONSTRAINT chk_price_positive CHECK (price_per_day > 0) +); + +CREATE INDEX idx_availability_periods_accommodation_id ON availability_periods(accommodation_id); +CREATE INDEX idx_availability_periods_dates ON availability_periods(start_date, end_date); +CREATE INDEX idx_availability_periods_accommodation_dates + ON availability_periods(accommodation_id, start_date, end_date) + WHERE is_deleted = false; diff --git a/src/test/java/com/devoops/accommodation/controller/AvailabilityPeriodControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AvailabilityPeriodControllerTest.java new file mode 100644 index 0000000..a24694c --- /dev/null +++ b/src/test/java/com/devoops/accommodation/controller/AvailabilityPeriodControllerTest.java @@ -0,0 +1,298 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RoleAuthorizationInterceptor; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.config.UserContextResolver; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.service.AvailabilityPeriodService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class AvailabilityPeriodControllerTest { + + private MockMvc mockMvc; + + @Mock + private AvailabilityPeriodService availabilityPeriodService; + + @InjectMocks + private AvailabilityPeriodController availabilityPeriodController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID PERIOD_ID = UUID.randomUUID(); + private static final String BASE_PATH = "/api/accommodation/" + ACCOMMODATION_ID + "/availability"; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(availabilityPeriodController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private AvailabilityPeriodResponse createResponse() { + return new AvailabilityPeriodResponse( + PERIOD_ID, ACCOMMODATION_ID, + LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 30), + new BigDecimal("100.00"), + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private Map validCreateRequest() { + return Map.of( + "startDate", "2026-06-01", + "endDate", "2026-06-30", + "pricePerDay", 100.00 + ); + } + + @Nested + @DisplayName("POST /api/accommodation/{accommodationId}/availability") + class CreateEndpoint { + + @Test + @DisplayName("With valid request returns 201") + void create_WithValidRequest_Returns201() throws Exception { + when(availabilityPeriodService.create(eq(ACCOMMODATION_ID), any(), any(UserContext.class))) + .thenReturn(createResponse()); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(PERIOD_ID.toString())) + .andExpect(jsonPath("$.accommodationId").value(ACCOMMODATION_ID.toString())); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void create_WithMissingAuth_Returns401() throws Exception { + mockMvc.perform(post(BASE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With GUEST role returns 403") + void create_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With missing required field returns 400") + void create_WithMissingField_Returns400() throws Exception { + var request = Map.of( + "startDate", "2026-06-01", + "endDate", "2026-06-30" + // missing pricePerDay + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{accommodationId}/availability") + class GetAllEndpoint { + + @Test + @DisplayName("Returns 200 with list") + void getAll_Returns200WithList() throws Exception { + when(availabilityPeriodService.getByAccommodationId(ACCOMMODATION_ID)) + .thenReturn(List.of(createResponse())); + + mockMvc.perform(get(BASE_PATH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(PERIOD_ID.toString())); + } + + @Test + @DisplayName("With non-existing accommodation returns 404") + void getAll_WithNonExisting_Returns404() throws Exception { + when(availabilityPeriodService.getByAccommodationId(ACCOMMODATION_ID)) + .thenThrow(new AccommodationNotFoundException("Not found")); + + mockMvc.perform(get(BASE_PATH)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{accommodationId}/availability/{periodId}") + class GetByIdEndpoint { + + @Test + @DisplayName("With existing ID returns 200") + void getById_Returns200() throws Exception { + when(availabilityPeriodService.getById(ACCOMMODATION_ID, PERIOD_ID)) + .thenReturn(createResponse()); + + mockMvc.perform(get(BASE_PATH + "/" + PERIOD_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(PERIOD_ID.toString())); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void getById_WithNonExisting_Returns404() throws Exception { + UUID missingId = UUID.randomUUID(); + when(availabilityPeriodService.getById(ACCOMMODATION_ID, missingId)) + .thenThrow(new AvailabilityPeriodNotFoundException("Not found")); + + mockMvc.perform(get(BASE_PATH + "/" + missingId)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("PUT /api/accommodation/{accommodationId}/availability/{periodId}") + class UpdateEndpoint { + + @Test + @DisplayName("With valid request returns 200") + void update_WithValidRequest_Returns200() throws Exception { + when(availabilityPeriodService.update(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(), any(UserContext.class))) + .thenReturn(createResponse()); + + var request = Map.of("pricePerDay", 150.00); + + mockMvc.perform(put(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void update_WithWrongOwner_Returns403() throws Exception { + when(availabilityPeriodService.update(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(), any(UserContext.class))) + .thenThrow(new ForbiddenException("Not the owner")); + + var request = Map.of("pricePerDay", 150.00); + + mockMvc.perform(put(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With reservation conflict returns 409") + void update_WithReservationConflict_Returns409() throws Exception { + when(availabilityPeriodService.update(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(), any(UserContext.class))) + .thenThrow(new ReservationConflictException("Reservations exist")); + + var request = Map.of("pricePerDay", 150.00); + + mockMvc.perform(put(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + } + + @Nested + @DisplayName("DELETE /api/accommodation/{accommodationId}/availability/{periodId}") + class DeleteEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void delete_WithValidRequest_Returns204() throws Exception { + doNothing().when(availabilityPeriodService) + .delete(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(UserContext.class)); + + mockMvc.perform(delete(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void delete_WithWrongOwner_Returns403() throws Exception { + doThrow(new ForbiddenException("Not the owner")) + .when(availabilityPeriodService).delete(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(UserContext.class)); + + mockMvc.perform(delete(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-existing period returns 404") + void delete_WithNonExisting_Returns404() throws Exception { + UUID missingId = UUID.randomUUID(); + doThrow(new AvailabilityPeriodNotFoundException("Not found")) + .when(availabilityPeriodService).delete(eq(ACCOMMODATION_ID), eq(missingId), any(UserContext.class)); + + mockMvc.perform(delete(BASE_PATH + "/" + missingId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With reservation conflict returns 409") + void delete_WithReservationConflict_Returns409() throws Exception { + doThrow(new ReservationConflictException("Reservations exist")) + .when(availabilityPeriodService).delete(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(UserContext.class)); + + mockMvc.perform(delete(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isConflict()); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/integration/AvailabilityPeriodIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AvailabilityPeriodIntegrationTest.java new file mode 100644 index 0000000..0e3c3bc --- /dev/null +++ b/src/test/java/com/devoops/accommodation/integration/AvailabilityPeriodIntegrationTest.java @@ -0,0 +1,289 @@ +package com.devoops.accommodation.integration; + +import com.devoops.accommodation.grpc.ReservationGrpcClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AvailabilityPeriodIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("accommodation_db_test") + .withUsername("test") + .withPassword("test"); + + @Container + static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2024-01-31T20-20-33Z"); + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ReservationGrpcClient reservationGrpcClient; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static String accommodationId; + private static String periodId; + private static String secondPeriodId; + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID OTHER_HOST_ID = UUID.randomUUID(); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + + registry.add("minio.endpoint", minio::getS3URL); + registry.add("minio.access-key", minio::getUserName); + registry.add("minio.secret-key", minio::getPassword); + registry.add("minio.bucket", () -> "test-accommodation-photos"); + } + + @Test + @Order(1) + @DisplayName("Setup: Create accommodation for availability tests") + void setup_CreateAccommodation() throws Exception { + var request = Map.of( + "name", "Availability Test Apartment", + "address", "123 Availability St", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + MvcResult result = mockMvc.perform(post("/api/accommodation") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + accommodationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + private String basePath() { + return "/api/accommodation/" + accommodationId + "/availability"; + } + + @Test + @Order(2) + @DisplayName("Create availability period returns 201") + void create_WithValidRequest_Returns201() throws Exception { + var request = Map.of( + "startDate", "2026-06-01", + "endDate", "2026-06-30", + "pricePerDay", 100.00 + ); + + MvcResult result = mockMvc.perform(post(basePath()) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accommodationId").value(accommodationId)) + .andExpect(jsonPath("$.startDate").value("2026-06-01")) + .andExpect(jsonPath("$.endDate").value("2026-06-30")) + .andExpect(jsonPath("$.pricePerDay").value(100.00)) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andReturn(); + + periodId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(3) + @DisplayName("Create non-overlapping period returns 201") + void create_NonOverlappingPeriod_Returns201() throws Exception { + var request = Map.of( + "startDate", "2026-07-01", + "endDate", "2026-07-31", + "pricePerDay", 120.00 + ); + + MvcResult result = mockMvc.perform(post(basePath()) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + secondPeriodId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(4) + @DisplayName("Create overlapping period returns 409") + void create_OverlappingPeriod_Returns409() throws Exception { + var request = Map.of( + "startDate", "2026-06-15", + "endDate", "2026-07-15", + "pricePerDay", 110.00 + ); + + mockMvc.perform(post(basePath()) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + @Order(5) + @DisplayName("Get all periods returns sorted list") + void getAll_ReturnsSortedList() throws Exception { + mockMvc.perform(get(basePath())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].startDate").value("2026-06-01")) + .andExpect(jsonPath("$[1].startDate").value("2026-07-01")); + } + + @Test + @Order(6) + @DisplayName("Get period by ID returns 200") + void getById_Returns200() throws Exception { + mockMvc.perform(get(basePath() + "/" + periodId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(periodId)) + .andExpect(jsonPath("$.pricePerDay").value(100.00)); + } + + @Test + @Order(7) + @DisplayName("Get non-existing period returns 404") + void getById_NonExisting_Returns404() throws Exception { + mockMvc.perform(get(basePath() + "/" + UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + @Test + @Order(8) + @DisplayName("Update period with no reservations returns 200") + void update_WithNoReservations_Returns200() throws Exception { + when(reservationGrpcClient.hasApprovedReservations(any(), any(), any())).thenReturn(false); + + var request = Map.of("pricePerDay", 150.00); + + mockMvc.perform(put(basePath() + "/" + periodId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pricePerDay").value(150.00)) + .andExpect(jsonPath("$.startDate").value("2026-06-01")); + } + + @Test + @Order(9) + @DisplayName("Update period by non-owner returns 403") + void update_ByNonOwner_Returns403() throws Exception { + var request = Map.of("pricePerDay", 200.00); + + mockMvc.perform(put(basePath() + "/" + periodId) + .header("X-User-Id", OTHER_HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(10) + @DisplayName("Update period with approved reservations returns 409") + void update_WithReservations_Returns409() throws Exception { + when(reservationGrpcClient.hasApprovedReservations(any(), any(), any())).thenReturn(true); + + var request = Map.of("pricePerDay", 200.00); + + mockMvc.perform(put(basePath() + "/" + periodId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + @Order(11) + @DisplayName("Delete period with no reservations returns 204") + void delete_WithNoReservations_Returns204() throws Exception { + when(reservationGrpcClient.hasApprovedReservations(any(), any(), any())).thenReturn(false); + + mockMvc.perform(delete(basePath() + "/" + secondPeriodId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @Order(12) + @DisplayName("After delete, get by ID returns 404 (soft-delete)") + void afterDelete_GetById_Returns404() throws Exception { + mockMvc.perform(get(basePath() + "/" + secondPeriodId)) + .andExpect(status().isNotFound()); + } + + @Test + @Order(13) + @DisplayName("After delete, get all returns only non-deleted periods") + void afterDelete_GetAll_ReturnsOnlyNonDeleted() throws Exception { + mockMvc.perform(get(basePath())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id").value(periodId)); + } + + @Test + @Order(14) + @DisplayName("Delete period with approved reservations returns 409") + void delete_WithReservations_Returns409() throws Exception { + when(reservationGrpcClient.hasApprovedReservations(any(), any(), any())).thenReturn(true); + + mockMvc.perform(delete(basePath() + "/" + periodId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isConflict()); + } +} diff --git a/src/test/java/com/devoops/accommodation/service/AvailabilityPeriodServiceTest.java b/src/test/java/com/devoops/accommodation/service/AvailabilityPeriodServiceTest.java new file mode 100644 index 0000000..2b1fb18 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/AvailabilityPeriodServiceTest.java @@ -0,0 +1,388 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.request.UpdateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.grpc.ReservationGrpcClient; +import com.devoops.accommodation.mapper.AvailabilityPeriodMapper; +import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AvailabilityPeriodServiceTest { + + @Mock + private AvailabilityPeriodRepository availabilityPeriodRepository; + + @Mock + private AccommodationRepository accommodationRepository; + + @Mock + private AvailabilityPeriodMapper availabilityPeriodMapper; + + @Mock + private ReservationGrpcClient reservationGrpcClient; + + @InjectMocks + private AvailabilityPeriodService availabilityPeriodService; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID PERIOD_ID = UUID.randomUUID(); + private static final UserContext HOST_CONTEXT = new UserContext(HOST_ID, "HOST"); + private static final LocalDate START_DATE = LocalDate.of(2026, 6, 1); + private static final LocalDate END_DATE = LocalDate.of(2026, 6, 30); + + private Accommodation createAccommodation() { + return Accommodation.builder() + .id(ACCOMMODATION_ID) + .hostId(HOST_ID) + .name("Test Apartment") + .address("123 Test St") + .minGuests(1) + .maxGuests(4) + .pricingMode(PricingMode.PER_GUEST) + .approvalMode(ApprovalMode.MANUAL) + .build(); + } + + private AvailabilityPeriod createPeriod() { + return AvailabilityPeriod.builder() + .id(PERIOD_ID) + .accommodationId(ACCOMMODATION_ID) + .startDate(START_DATE) + .endDate(END_DATE) + .pricePerDay(new BigDecimal("100.00")) + .build(); + } + + private AvailabilityPeriodResponse createResponse() { + return new AvailabilityPeriodResponse( + PERIOD_ID, ACCOMMODATION_ID, START_DATE, END_DATE, + new BigDecimal("100.00"), LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Nested + @DisplayName("Create") + class CreateTests { + + @Test + @DisplayName("With valid request returns availability period response") + void create_WithValidRequest_ReturnsResponse() { + var request = new CreateAvailabilityPeriodRequest(START_DATE, END_DATE, new BigDecimal("100.00")); + var accommodation = createAccommodation(); + var period = createPeriod(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findOverlapping(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(List.of()); + when(availabilityPeriodMapper.toEntity(request)).thenReturn(period); + when(availabilityPeriodRepository.saveAndFlush(period)).thenReturn(period); + when(availabilityPeriodMapper.toResponse(period)).thenReturn(response); + + AvailabilityPeriodResponse result = availabilityPeriodService.create(ACCOMMODATION_ID, request, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + verify(availabilityPeriodRepository).saveAndFlush(period); + } + + @Test + @DisplayName("With non-existing accommodation throws AccommodationNotFoundException") + void create_WithNonExistingAccommodation_ThrowsNotFound() { + var request = new CreateAvailabilityPeriodRequest(START_DATE, END_DATE, new BigDecimal("100.00")); + UUID id = UUID.randomUUID(); + + when(accommodationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> availabilityPeriodService.create(id, request, HOST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void create_WithWrongOwner_ThrowsForbidden() { + var request = new CreateAvailabilityPeriodRequest(START_DATE, END_DATE, new BigDecimal("100.00")); + var accommodation = createAccommodation(); + var otherUser = new UserContext(UUID.randomUUID(), "HOST"); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> availabilityPeriodService.create(ACCOMMODATION_ID, request, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With invalid dates throws IllegalArgumentException") + void create_WithInvalidDates_ThrowsIllegalArgument() { + var request = new CreateAvailabilityPeriodRequest(END_DATE, START_DATE, new BigDecimal("100.00")); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> availabilityPeriodService.create(ACCOMMODATION_ID, request, HOST_CONTEXT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End date must be after start date"); + } + + @Test + @DisplayName("With overlapping period throws OverlappingAvailabilityPeriodException") + void create_WithOverlappingPeriod_ThrowsOverlapping() { + var request = new CreateAvailabilityPeriodRequest(START_DATE, END_DATE, new BigDecimal("100.00")); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findOverlapping(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(List.of(createPeriod())); + + assertThatThrownBy(() -> availabilityPeriodService.create(ACCOMMODATION_ID, request, HOST_CONTEXT)) + .isInstanceOf(OverlappingAvailabilityPeriodException.class); + } + } + + @Nested + @DisplayName("GetByAccommodationId") + class GetByAccommodationIdTests { + + @Test + @DisplayName("Returns sorted list of periods") + void getByAccommodationId_ReturnsSortedList() { + var accommodation = createAccommodation(); + var periods = List.of(createPeriod()); + var responses = List.of(createResponse()); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByAccommodationIdOrderByStartDateAsc(ACCOMMODATION_ID)) + .thenReturn(periods); + when(availabilityPeriodMapper.toResponseList(periods)).thenReturn(responses); + + List result = availabilityPeriodService.getByAccommodationId(ACCOMMODATION_ID); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("With non-existing accommodation throws AccommodationNotFoundException") + void getByAccommodationId_WithNonExisting_ThrowsNotFound() { + UUID id = UUID.randomUUID(); + when(accommodationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> availabilityPeriodService.getByAccommodationId(id)) + .isInstanceOf(AccommodationNotFoundException.class); + } + } + + @Nested + @DisplayName("Update") + class UpdateTests { + + @Test + @DisplayName("With valid request returns updated response") + void update_WithValidRequest_ReturnsUpdatedResponse() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + var period = createPeriod(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(false); + when(availabilityPeriodRepository.findOverlappingExcluding(ACCOMMODATION_ID, START_DATE, END_DATE, PERIOD_ID)) + .thenReturn(List.of()); + when(availabilityPeriodRepository.saveAndFlush(period)).thenReturn(period); + when(availabilityPeriodMapper.toResponse(period)).thenReturn(response); + + AvailabilityPeriodResponse result = availabilityPeriodService.update( + ACCOMMODATION_ID, PERIOD_ID, request, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + assertThat(period.getPricePerDay()).isEqualByComparingTo(new BigDecimal("150.00")); + } + + @Test + @DisplayName("With partial date update applies only provided fields") + void update_WithPartialDates_AppliesOnlyProvided() { + LocalDate newEndDate = LocalDate.of(2026, 7, 15); + var request = new UpdateAvailabilityPeriodRequest(null, newEndDate, null); + var accommodation = createAccommodation(); + var period = createPeriod(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(false); + when(availabilityPeriodRepository.findOverlappingExcluding(ACCOMMODATION_ID, START_DATE, newEndDate, PERIOD_ID)) + .thenReturn(List.of()); + when(availabilityPeriodRepository.saveAndFlush(period)).thenReturn(period); + when(availabilityPeriodMapper.toResponse(period)).thenReturn(response); + + availabilityPeriodService.update(ACCOMMODATION_ID, PERIOD_ID, request, HOST_CONTEXT); + + assertThat(period.getStartDate()).isEqualTo(START_DATE); + assertThat(period.getEndDate()).isEqualTo(newEndDate); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void update_WithWrongOwner_ThrowsForbidden() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + var otherUser = new UserContext(UUID.randomUUID(), "HOST"); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> availabilityPeriodService.update( + ACCOMMODATION_ID, PERIOD_ID, request, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With non-existing period throws AvailabilityPeriodNotFoundException") + void update_WithNonExistingPeriod_ThrowsNotFound() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + UUID missingPeriodId = UUID.randomUUID(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(missingPeriodId, ACCOMMODATION_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> availabilityPeriodService.update( + ACCOMMODATION_ID, missingPeriodId, request, HOST_CONTEXT)) + .isInstanceOf(AvailabilityPeriodNotFoundException.class); + } + + @Test + @DisplayName("With existing reservations throws ReservationConflictException") + void update_WithExistingReservations_ThrowsConflict() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + var period = createPeriod(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(true); + + assertThatThrownBy(() -> availabilityPeriodService.update( + ACCOMMODATION_ID, PERIOD_ID, request, HOST_CONTEXT)) + .isInstanceOf(ReservationConflictException.class); + } + + @Test + @DisplayName("With overlapping period after update throws OverlappingAvailabilityPeriodException") + void update_WithOverlappingPeriod_ThrowsOverlapping() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + var period = createPeriod(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(false); + when(availabilityPeriodRepository.findOverlappingExcluding(ACCOMMODATION_ID, START_DATE, END_DATE, PERIOD_ID)) + .thenReturn(List.of(AvailabilityPeriod.builder().id(UUID.randomUUID()).build())); + + assertThatThrownBy(() -> availabilityPeriodService.update( + ACCOMMODATION_ID, PERIOD_ID, request, HOST_CONTEXT)) + .isInstanceOf(OverlappingAvailabilityPeriodException.class); + } + } + + @Nested + @DisplayName("Delete") + class DeleteTests { + + @Test + @DisplayName("With valid owner soft-deletes period") + void delete_WithValidOwner_SoftDeletesPeriod() { + var accommodation = createAccommodation(); + var period = createPeriod(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(false); + + availabilityPeriodService.delete(ACCOMMODATION_ID, PERIOD_ID, HOST_CONTEXT); + + assertThat(period.isDeleted()).isTrue(); + verify(availabilityPeriodRepository).save(period); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void delete_WithWrongOwner_ThrowsForbidden() { + var accommodation = createAccommodation(); + var otherUser = new UserContext(UUID.randomUUID(), "HOST"); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> availabilityPeriodService.delete(ACCOMMODATION_ID, PERIOD_ID, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With non-existing period throws AvailabilityPeriodNotFoundException") + void delete_WithNonExistingPeriod_ThrowsNotFound() { + var accommodation = createAccommodation(); + UUID missingPeriodId = UUID.randomUUID(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(missingPeriodId, ACCOMMODATION_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> availabilityPeriodService.delete(ACCOMMODATION_ID, missingPeriodId, HOST_CONTEXT)) + .isInstanceOf(AvailabilityPeriodNotFoundException.class); + } + + @Test + @DisplayName("With existing reservations throws ReservationConflictException") + void delete_WithExistingReservations_ThrowsConflict() { + var accommodation = createAccommodation(); + var period = createPeriod(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(true); + + assertThatThrownBy(() -> availabilityPeriodService.delete(ACCOMMODATION_ID, PERIOD_ID, HOST_CONTEXT)) + .isInstanceOf(ReservationConflictException.class); + } + } +} From ff8ffe5a2b54a52fbbf19f885e299f961603f333 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:59:37 +0100 Subject: [PATCH 14/24] feat: Add gRPC server for reservation request validation. --- build.gradle.kts | 3 +- environment/.local.env | 5 +- .../grpc/AccommodationGrpcService.java | 128 ++++++++++++++++++ .../AvailabilityPeriodRepository.java | 12 ++ src/main/proto/accommodation_internal.proto | 26 ++++ src/main/resources/application.properties | 3 + 6 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java create mode 100644 src/main/proto/accommodation_internal.proto diff --git a/build.gradle.kts b/build.gradle.kts index d226bb3..e01db7b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,8 +52,9 @@ dependencies { // MinIO S3-compatible object storage implementation("io.minio:minio:8.5.7") - // gRPC Client + // gRPC Client and Server implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE") + implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE") implementation("io.grpc:grpc-protobuf:$grpcVersion") implementation("io.grpc:grpc-stub:$grpcVersion") implementation("io.grpc:grpc-netty-shaded:$grpcVersion") diff --git a/environment/.local.env b/environment/.local.env index 375ba1e..d6156a8 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -13,6 +13,9 @@ MINIO_ACCESS_KEY=devoops MINIO_SECRET_KEY=devoops123 MINIO_BUCKET=accommodation-photos -# gRPC +# gRPC Server +GRPC_PORT=9090 + +# gRPC Client RESERVATION_GRPC_HOST=devoops-reservation-service RESERVATION_GRPC_PORT=9090 diff --git a/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java b/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java new file mode 100644 index 0000000..9cddf52 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java @@ -0,0 +1,128 @@ +package com.devoops.accommodation.grpc; + +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.grpc.proto.AccommodationInternalServiceGrpc; +import com.devoops.accommodation.grpc.proto.ReservationValidationRequest; +import com.devoops.accommodation.grpc.proto.ReservationValidationResponse; +import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; +import io.grpc.stub.StreamObserver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.service.GrpcService; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.UUID; + +@GrpcService +@RequiredArgsConstructor +@Slf4j +public class AccommodationGrpcService extends AccommodationInternalServiceGrpc.AccommodationInternalServiceImplBase { + + private final AccommodationRepository accommodationRepository; + private final AvailabilityPeriodRepository availabilityPeriodRepository; + + @Override + public void validateAndCalculatePrice( + ReservationValidationRequest request, + StreamObserver responseObserver) { + + log.debug("Received validation request for accommodation: {}", request.getAccommodationId()); + + ReservationValidationResponse response = processValidation(request); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } + + private ReservationValidationResponse processValidation(ReservationValidationRequest request) { + UUID accommodationId; + LocalDate startDate; + LocalDate endDate; + + try { + accommodationId = UUID.fromString(request.getAccommodationId()); + } catch (IllegalArgumentException e) { + return buildErrorResponse("ACCOMMODATION_NOT_FOUND", "Invalid accommodation ID format"); + } + + try { + startDate = LocalDate.parse(request.getStartDate()); + endDate = LocalDate.parse(request.getEndDate()); + } catch (DateTimeParseException e) { + return buildErrorResponse("INVALID_DATES", "Invalid date format. Use ISO format (yyyy-MM-dd)"); + } + + Optional accommodationOpt = accommodationRepository.findById(accommodationId); + if (accommodationOpt.isEmpty()) { + log.debug("Accommodation not found: {}", accommodationId); + return buildErrorResponse("ACCOMMODATION_NOT_FOUND", "Accommodation not found"); + } + + Accommodation accommodation = accommodationOpt.get(); + int guestCount = request.getGuestCount(); + + if (guestCount < accommodation.getMinGuests() || guestCount > accommodation.getMaxGuests()) { + log.debug("Invalid guest count {} for accommodation {} (min: {}, max: {})", + guestCount, accommodationId, accommodation.getMinGuests(), accommodation.getMaxGuests()); + return buildErrorResponse("GUEST_COUNT_INVALID", + String.format("Guest count must be between %d and %d", + accommodation.getMinGuests(), accommodation.getMaxGuests())); + } + + Optional periodOpt = availabilityPeriodRepository + .findCoveringPeriod(accommodationId, startDate, endDate); + + if (periodOpt.isEmpty()) { + log.debug("No availability period covers dates {} to {} for accommodation {}", + startDate, endDate, accommodationId); + return buildErrorResponse("DATES_NOT_AVAILABLE", + "The selected dates are not within an available period"); + } + + AvailabilityPeriod period = periodOpt.get(); + BigDecimal totalPrice = calculateTotalPrice(period, accommodation.getPricingMode(), startDate, endDate, guestCount); + + log.info("Validation successful for accommodation {}: totalPrice={}, approvalMode={}", + accommodationId, totalPrice, accommodation.getApprovalMode()); + + return ReservationValidationResponse.newBuilder() + .setValid(true) + .setHostId(accommodation.getHostId().toString()) + .setTotalPrice(totalPrice.toPlainString()) + .setPricingMode(accommodation.getPricingMode().name()) + .setApprovalMode(accommodation.getApprovalMode().name()) + .build(); + } + + private BigDecimal calculateTotalPrice( + AvailabilityPeriod period, + PricingMode pricingMode, + LocalDate startDate, + LocalDate endDate, + int guestCount) { + + long nights = ChronoUnit.DAYS.between(startDate, endDate); + BigDecimal basePrice = period.getPricePerDay().multiply(BigDecimal.valueOf(nights)); + + if (pricingMode == PricingMode.PER_GUEST) { + return basePrice.multiply(BigDecimal.valueOf(guestCount)); + } + + return basePrice; + } + + private ReservationValidationResponse buildErrorResponse(String errorCode, String errorMessage) { + return ReservationValidationResponse.newBuilder() + .setValid(false) + .setErrorCode(errorCode) + .setErrorMessage(errorMessage) + .build(); + } +} diff --git a/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java b/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java index 39d629e..9a3b8b2 100644 --- a/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java +++ b/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java @@ -41,4 +41,16 @@ List findOverlappingExcluding( @Param("endDate") LocalDate endDate, @Param("excludeId") UUID excludeId ); + + @Query(""" + SELECT ap FROM AvailabilityPeriod ap + WHERE ap.accommodationId = :accommodationId + AND ap.startDate <= :startDate + AND ap.endDate >= :endDate + """) + Optional findCoveringPeriod( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); } diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto new file mode 100644 index 0000000..7a345e3 --- /dev/null +++ b/src/main/proto/accommodation_internal.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; +package accommodation; + +option java_multiple_files = true; +option java_package = "com.devoops.accommodation.grpc.proto"; + +service AccommodationInternalService { + rpc ValidateAndCalculatePrice(ReservationValidationRequest) returns (ReservationValidationResponse); +} + +message ReservationValidationRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; + int32 guest_count = 4; +} + +message ReservationValidationResponse { + bool valid = 1; + string error_code = 2; + string error_message = 3; + string host_id = 4; + string total_price = 5; + string pricing_mode = 6; + string approval_mode = 7; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e63e04c..b30cf27 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -45,6 +45,9 @@ minio.bucket=${MINIO_BUCKET:accommodation-photos} app.photo.max-photos-per-accommodation=20 app.photo.allowed-content-types=image/jpeg,image/png,image/webp +# gRPC Server +grpc.server.port=${GRPC_PORT:9090} + # gRPC Client grpc.client.reservation-service.address=static://${RESERVATION_GRPC_HOST:devoops-reservation-service}:${RESERVATION_GRPC_PORT:9090} grpc.client.reservation-service.negotiationType=plaintext From 7a7b827a0eed9005d50e1124064148a25b154227 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:36:07 +0100 Subject: [PATCH 15/24] fix: Add accommodation name to proto contract. --- .../com/devoops/accommodation/grpc/AccommodationGrpcService.java | 1 + src/main/proto/accommodation_internal.proto | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java b/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java index 9cddf52..2f13bc0 100644 --- a/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java +++ b/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java @@ -98,6 +98,7 @@ private ReservationValidationResponse processValidation(ReservationValidationReq .setTotalPrice(totalPrice.toPlainString()) .setPricingMode(accommodation.getPricingMode().name()) .setApprovalMode(accommodation.getApprovalMode().name()) + .setAccommodationName(accommodation.getName()) .build(); } diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto index 7a345e3..86bc71f 100644 --- a/src/main/proto/accommodation_internal.proto +++ b/src/main/proto/accommodation_internal.proto @@ -23,4 +23,5 @@ message ReservationValidationResponse { string total_price = 5; string pricing_mode = 6; string approval_mode = 7; + string accommodation_name = 8; } From d76c8206702715422814c94ea6aa380fd440c4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Tue, 17 Feb 2026 23:20:05 +0100 Subject: [PATCH 16/24] fix: Changed Accommodation endpoint to accept multiple photos instead of one --- .../AccommodationPhotoController.java | 9 +++-- .../service/AccommodationPhotoService.java | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java index 6902123..41580fd 100644 --- a/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java @@ -26,13 +26,12 @@ public class AccommodationPhotoController { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @RequireRole("HOST") - public ResponseEntity uploadPhoto( + public ResponseEntity> uploadPhotos( @PathVariable UUID accommodationId, - @RequestPart("file") MultipartFile file, - @RequestParam(value = "displayOrder", required = false) Integer displayOrder, + @RequestPart("files") List files, UserContext userContext) { - AccommodationPhotoResponse response = photoService.uploadPhoto(accommodationId, file, displayOrder, userContext); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + List responses = photoService.uploadPhotos(accommodationId, files, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(responses); } @GetMapping diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java b/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java index 655ae5b..d03aff2 100644 --- a/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java +++ b/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java @@ -16,6 +16,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Set; @@ -38,6 +39,38 @@ public class AccommodationPhotoService { @Value("${app.photo.allowed-content-types}") private String allowedContentTypesConfig; + @Transactional + public List uploadPhotos(UUID accommodationId, List files, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + + long currentCount = photoRepository.countByAccommodationId(accommodationId); + if (currentCount + files.size() > maxPhotosPerAccommodation) { + throw new PhotoLimitExceededException("Adding " + files.size() + " photos would exceed the limit of " + maxPhotosPerAccommodation + " photos per accommodation"); + } + + int nextOrder = photoRepository.findMaxDisplayOrder(accommodationId) + 1; + List photos = new ArrayList<>(); + + for (MultipartFile file : files) { + validateContentType(file.getContentType()); + String storageFilename = photoStorageService.store(file); + + AccommodationPhoto photo = AccommodationPhoto.builder() + .accommodationId(accommodationId) + .storageFilename(storageFilename) + .originalFilename(file.getOriginalFilename()) + .contentType(file.getContentType()) + .fileSize(file.getSize()) + .displayOrder(nextOrder++) + .build(); + photos.add(photoRepository.saveAndFlush(photo)); + } + + log.info("Uploaded {} photos for accommodation {}", photos.size(), accommodationId); + return photoMapper.toResponseList(photos); + } + @Transactional public AccommodationPhotoResponse uploadPhoto(UUID accommodationId, MultipartFile file, Integer displayOrder, UserContext userContext) { Accommodation accommodation = findAccommodationOrThrow(accommodationId); From 6383405724c72f4add2263558d5895b129d25ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Tue, 17 Feb 2026 23:38:41 +0100 Subject: [PATCH 17/24] fix: Fixed related tests --- .../AccommodationPhotoControllerTest.java | 45 ++++++------------- .../AccommodationPhotoIntegrationTest.java | 30 ++++++------- .../resources/application-test.properties | 7 +++ 3 files changed, 34 insertions(+), 48 deletions(-) diff --git a/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java index 7829dd3..aeaf7fb 100644 --- a/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java +++ b/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java @@ -82,44 +82,25 @@ class UploadPhotoEndpoint { @DisplayName("With valid request returns 201") void upload_WithValidRequest_Returns201() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "test content".getBytes()); + "files", "test.jpg", "image/jpeg", "test content".getBytes()); - when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) - .thenReturn(createPhotoResponse()); + when(photoService.uploadPhotos(eq(ACCOMMODATION_ID), any(), any(UserContext.class))) + .thenReturn(List.of(createPhotoResponse())); mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) .file(file) .header("X-User-Id", HOST_ID.toString()) .header("X-User-Role", "HOST")) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").value(PHOTO_ID.toString())) - .andExpect(jsonPath("$.originalFilename").value("test.jpg")); - } - - @Test - @DisplayName("With display order parameter uses it") - void upload_WithDisplayOrder_UsesParameter() throws Exception { - MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "test content".getBytes()); - - when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), eq(5), any(UserContext.class))) - .thenReturn(createPhotoResponse()); - - mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) - .file(file) - .param("displayOrder", "5") - .header("X-User-Id", HOST_ID.toString()) - .header("X-User-Role", "HOST")) - .andExpect(status().isCreated()); - - verify(photoService).uploadPhoto(eq(ACCOMMODATION_ID), any(), eq(5), any(UserContext.class)); + .andExpect(jsonPath("$[0].id").value(PHOTO_ID.toString())) + .andExpect(jsonPath("$[0].originalFilename").value("test.jpg")); } @Test @DisplayName("With missing auth headers returns 401") void upload_WithMissingAuthHeaders_Returns401() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "test content".getBytes()); + "files", "test.jpg", "image/jpeg", "test content".getBytes()); mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) .file(file)) @@ -130,7 +111,7 @@ void upload_WithMissingAuthHeaders_Returns401() throws Exception { @DisplayName("With GUEST role returns 403") void upload_WithGuestRole_Returns403() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "test content".getBytes()); + "files", "test.jpg", "image/jpeg", "test content".getBytes()); mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) .file(file) @@ -143,9 +124,9 @@ void upload_WithGuestRole_Returns403() throws Exception { @DisplayName("With accommodation not found returns 404") void upload_WithAccommodationNotFound_Returns404() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "test content".getBytes()); + "files", "test.jpg", "image/jpeg", "test content".getBytes()); - when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + when(photoService.uploadPhotos(eq(ACCOMMODATION_ID), any(), any(UserContext.class))) .thenThrow(new AccommodationNotFoundException("Not found")); mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) @@ -159,9 +140,9 @@ void upload_WithAccommodationNotFound_Returns404() throws Exception { @DisplayName("With invalid content type returns 400") void upload_WithInvalidContentType_Returns400() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.gif", "image/gif", "test content".getBytes()); + "files", "test.gif", "image/gif", "test content".getBytes()); - when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + when(photoService.uploadPhotos(eq(ACCOMMODATION_ID), any(), any(UserContext.class))) .thenThrow(new InvalidContentTypeException("Invalid content type")); mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) @@ -175,9 +156,9 @@ void upload_WithInvalidContentType_Returns400() throws Exception { @DisplayName("With photo limit exceeded returns 400") void upload_WithPhotoLimitExceeded_Returns400() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "test content".getBytes()); + "files", "test.jpg", "image/jpeg", "test content".getBytes()); - when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + when(photoService.uploadPhotos(eq(ACCOMMODATION_ID), any(), any(UserContext.class))) .thenThrow(new PhotoLimitExceededException("Limit exceeded")); mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java index c59a736..5da7175 100644 --- a/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java @@ -103,7 +103,7 @@ void setup_CreateAccommodation() throws Exception { @DisplayName("Upload photo with valid request returns 201") void uploadPhoto_WithValidRequest_Returns201() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test-image.jpg", "image/jpeg", + "files", "test-image.jpg", "image/jpeg", "fake image content".getBytes()); MvcResult result = mockMvc.perform(multipart(photosPath()) @@ -111,32 +111,30 @@ void uploadPhoto_WithValidRequest_Returns201() throws Exception { .header("X-User-Id", HOST_ID.toString()) .header("X-User-Role", "HOST")) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").isNotEmpty()) - .andExpect(jsonPath("$.accommodationId").value(accommodationId)) - .andExpect(jsonPath("$.originalFilename").value("test-image.jpg")) - .andExpect(jsonPath("$.contentType").value("image/jpeg")) - .andExpect(jsonPath("$.displayOrder").value(0)) + .andExpect(jsonPath("$[0].id").isNotEmpty()) + .andExpect(jsonPath("$[0].accommodationId").value(accommodationId)) + .andExpect(jsonPath("$[0].originalFilename").value("test-image.jpg")) + .andExpect(jsonPath("$[0].contentType").value("image/jpeg")) .andReturn(); photoId = objectMapper.readTree(result.getResponse().getContentAsString()) - .get("id").asText(); + .get(0).get("id").asText(); } @Test @Order(3) - @DisplayName("Upload photo with custom display order uses provided order") - void uploadPhoto_WithDisplayOrder_UsesProvidedOrder() throws Exception { + @DisplayName("Upload second photo returns 201 with auto-assigned display order") + void uploadPhoto_SecondPhoto_Returns201() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "second-image.png", "image/png", + "files", "second-image.png", "image/png", "fake image content 2".getBytes()); mockMvc.perform(multipart(photosPath()) .file(file) - .param("displayOrder", "5") .header("X-User-Id", HOST_ID.toString()) .header("X-User-Role", "HOST")) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.displayOrder").value(5)); + .andExpect(jsonPath("$[0].originalFilename").value("second-image.png")); } @Test @@ -144,7 +142,7 @@ void uploadPhoto_WithDisplayOrder_UsesProvidedOrder() throws Exception { @DisplayName("Upload photo without auth headers returns 401") void uploadPhoto_WithoutAuthHeaders_Returns401() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "content".getBytes()); + "files", "test.jpg", "image/jpeg", "content".getBytes()); mockMvc.perform(multipart(photosPath()) .file(file)) @@ -156,7 +154,7 @@ void uploadPhoto_WithoutAuthHeaders_Returns401() throws Exception { @DisplayName("Upload photo with GUEST role returns 403") void uploadPhoto_WithGuestRole_Returns403() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "content".getBytes()); + "files", "test.jpg", "image/jpeg", "content".getBytes()); mockMvc.perform(multipart(photosPath()) .file(file) @@ -170,7 +168,7 @@ void uploadPhoto_WithGuestRole_Returns403() throws Exception { @DisplayName("Upload photo with different host returns 403") void uploadPhoto_WithDifferentHost_Returns403() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.jpg", "image/jpeg", "content".getBytes()); + "files", "test.jpg", "image/jpeg", "content".getBytes()); mockMvc.perform(multipart(photosPath()) .file(file) @@ -184,7 +182,7 @@ void uploadPhoto_WithDifferentHost_Returns403() throws Exception { @DisplayName("Upload photo with invalid content type returns 400") void uploadPhoto_WithInvalidContentType_Returns400() throws Exception { MockMultipartFile file = new MockMultipartFile( - "file", "test.gif", "image/gif", "content".getBytes()); + "files", "test.gif", "image/gif", "content".getBytes()); mockMvc.perform(multipart(photosPath()) .file(file) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 9e787a6..069c3b7 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -3,5 +3,12 @@ spring.application.name=accommodation-test # Disable tracing in tests management.tracing.enabled=false +# Use random port for gRPC server to avoid conflicts between test contexts +grpc.server.port=0 + +# gRPC client config for tests (won't actually connect unless used) +grpc.client.reservation-service.address=static://localhost:9090 +grpc.client.reservation-service.negotiationType=plaintext + # Logging logging.level.com.devoops=DEBUG From 8f030fe04b8b7f8da5ca58cf2e5e572c26eefb62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20=C4=8Cuturi=C4=87?= Date: Thu, 19 Feb 2026 21:21:57 +0100 Subject: [PATCH 18/24] feat: Add search --- .../controller/AccommodationController.java | 12 + .../response/AccommodationSearchResponse.java | 28 ++ .../repository/AccommodationRepository.java | 13 + .../service/AccommodationService.java | 56 ++++ .../AccommodationSearchControllerTest.java | 171 ++++++++++ .../AccommodationSearchIntegrationTest.java | 299 ++++++++++++++++++ .../AccommodationSearchServiceTest.java | 240 ++++++++++++++ 7 files changed, 819 insertions(+) create mode 100644 src/main/java/com/devoops/accommodation/dto/response/AccommodationSearchResponse.java create mode 100644 src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java create mode 100644 src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java create mode 100644 src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java index 9e3f900..d4463f3 100644 --- a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java @@ -5,14 +5,17 @@ import com.devoops.accommodation.dto.request.CreateAccommodationRequest; import com.devoops.accommodation.dto.request.UpdateAccommodationRequest; import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.dto.response.AccommodationSearchResponse; import com.devoops.accommodation.service.AccommodationService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @@ -39,6 +42,15 @@ public ResponseEntity> getAll( return ResponseEntity.ok(accommodationService.getAll(page, size)); } + @GetMapping("/search") + public ResponseEntity> search( + @RequestParam String location, + @RequestParam int guests, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + return ResponseEntity.ok(accommodationService.search(location, guests, startDate, endDate)); + } + @GetMapping("/{id}") public ResponseEntity getById(@PathVariable UUID id) { return ResponseEntity.ok(accommodationService.getById(id)); diff --git a/src/main/java/com/devoops/accommodation/dto/response/AccommodationSearchResponse.java b/src/main/java/com/devoops/accommodation/dto/response/AccommodationSearchResponse.java new file mode 100644 index 0000000..8ffc2b3 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/response/AccommodationSearchResponse.java @@ -0,0 +1,28 @@ +package com.devoops.accommodation.dto.response; + +import com.devoops.accommodation.entity.AmenityType; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record AccommodationSearchResponse( + UUID id, + UUID hostId, + String name, + String address, + int minGuests, + int maxGuests, + PricingMode pricingMode, + ApprovalMode approvalMode, + List amenities, + LocalDateTime createdAt, + LocalDateTime updatedAt, + BigDecimal totalPrice, + BigDecimal unitPrice, + int numberOfNights +) { +} diff --git a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java index 6b7b807..8f5f0fe 100644 --- a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java +++ b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java @@ -2,6 +2,8 @@ import com.devoops.accommodation.entity.Accommodation; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; @@ -9,4 +11,15 @@ public interface AccommodationRepository extends JpaRepository { List findByHostId(UUID hostId); + + @Query(""" + SELECT a FROM Accommodation a + WHERE LOWER(a.address) LIKE LOWER(CONCAT('%', :location, '%')) + AND a.minGuests <= :guests + AND a.maxGuests >= :guests + ORDER BY a.createdAt DESC + """) + List searchByLocationAndGuests( + @Param("location") String location, + @Param("guests") int guests); } diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationService.java b/src/main/java/com/devoops/accommodation/service/AccommodationService.java index 9342117..5f37f92 100644 --- a/src/main/java/com/devoops/accommodation/service/AccommodationService.java +++ b/src/main/java/com/devoops/accommodation/service/AccommodationService.java @@ -4,11 +4,15 @@ import com.devoops.accommodation.dto.request.CreateAccommodationRequest; import com.devoops.accommodation.dto.request.UpdateAccommodationRequest; import com.devoops.accommodation.dto.response.AccommodationResponse; +import com.devoops.accommodation.dto.response.AccommodationSearchResponse; import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.entity.PricingMode; import com.devoops.accommodation.exception.AccommodationNotFoundException; import com.devoops.accommodation.exception.ForbiddenException; import com.devoops.accommodation.mapper.AccommodationMapper; import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -17,8 +21,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; @Service @@ -27,6 +35,7 @@ public class AccommodationService { private final AccommodationRepository accommodationRepository; private final AccommodationMapper accommodationMapper; + private final AvailabilityPeriodRepository availabilityPeriodRepository; @Transactional public AccommodationResponse create(CreateAccommodationRequest request, UserContext userContext) { @@ -105,6 +114,53 @@ public void delete(UUID id, UserContext userContext) { accommodationRepository.save(accommodation); } + @Transactional(readOnly = true) + public List search(String location, int guests, LocalDate startDate, LocalDate endDate) { + if (!endDate.isAfter(startDate)) { + throw new IllegalArgumentException("End date must be after start date"); + } + + long nights = ChronoUnit.DAYS.between(startDate, endDate); + List candidates = accommodationRepository.searchByLocationAndGuests(location, guests); + List results = new ArrayList<>(); + + for (Accommodation accommodation : candidates) { + Optional coveringPeriod = availabilityPeriodRepository + .findCoveringPeriod(accommodation.getId(), startDate, endDate); + + if (coveringPeriod.isPresent()) { + AvailabilityPeriod period = coveringPeriod.get(); + BigDecimal unitPrice = period.getPricePerDay(); + BigDecimal totalPrice; + + if (accommodation.getPricingMode() == PricingMode.PER_GUEST) { + totalPrice = unitPrice.multiply(BigDecimal.valueOf(nights)).multiply(BigDecimal.valueOf(guests)); + } else { + totalPrice = unitPrice.multiply(BigDecimal.valueOf(nights)); + } + + results.add(new AccommodationSearchResponse( + accommodation.getId(), + accommodation.getHostId(), + accommodation.getName(), + accommodation.getAddress(), + accommodation.getMinGuests(), + accommodation.getMaxGuests(), + accommodation.getPricingMode(), + accommodation.getApprovalMode(), + accommodation.getAmenities(), + accommodation.getCreatedAt(), + accommodation.getUpdatedAt(), + totalPrice, + unitPrice, + (int) nights + )); + } + } + + return results; + } + private Accommodation findAccommodationOrThrow(UUID id) { return accommodationRepository.findById(id) .orElseThrow(() -> new AccommodationNotFoundException("Accommodation not found with id: " + id)); diff --git a/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java new file mode 100644 index 0000000..431ad65 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java @@ -0,0 +1,171 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RoleAuthorizationInterceptor; +import com.devoops.accommodation.config.UserContextResolver; +import com.devoops.accommodation.dto.response.AccommodationSearchResponse; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.exception.GlobalExceptionHandler; +import com.devoops.accommodation.service.AccommodationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class AccommodationSearchControllerTest { + + private MockMvc mockMvc; + + @Mock + private AccommodationService accommodationService; + + @InjectMocks + private AccommodationController accommodationController; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(accommodationController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private AccommodationSearchResponse createSearchResponse() { + return new AccommodationSearchResponse( + ACCOMMODATION_ID, HOST_ID, "Test Apartment", "123 Belgrade St", + 1, 4, PricingMode.PER_GUEST, ApprovalMode.MANUAL, + List.of(), LocalDateTime.now(), LocalDateTime.now(), + new BigDecimal("500.00"), new BigDecimal("50.00"), 5 + ); + } + + @Nested + @DisplayName("GET /api/accommodation/search") + class SearchEndpoint { + + @Test + @DisplayName("With valid parameters returns 200 with results") + void search_WithValidParams_Returns200WithResults() throws Exception { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationService.search("Belgrade", 2, startDate, endDate)) + .thenReturn(List.of(createSearchResponse())); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(ACCOMMODATION_ID.toString())) + .andExpect(jsonPath("$[0].name").value("Test Apartment")) + .andExpect(jsonPath("$[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$[0].unitPrice").value(50.00)) + .andExpect(jsonPath("$[0].numberOfNights").value(5)); + } + + @Test + @DisplayName("With no results returns 200 with empty list") + void search_WithNoResults_Returns200WithEmptyList() throws Exception { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationService.search("Nowhere", 2, startDate, endDate)) + .thenReturn(List.of()); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Nowhere") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + @DisplayName("Without auth headers still returns 200 (public endpoint)") + void search_WithoutAuthHeaders_Returns200() throws Exception { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationService.search("Belgrade", 2, startDate, endDate)) + .thenReturn(List.of()); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("With missing location parameter returns 400") + void search_WithMissingLocation_Returns400() throws Exception { + mockMvc.perform(get("/api/accommodation/search") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With missing guests parameter returns 400") + void search_WithMissingGuests_Returns400() throws Exception { + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With missing date parameters returns 400") + void search_WithMissingDates_Returns400() throws Exception { + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With invalid date returns IllegalArgumentException") + void search_WithInvalidDates_Returns400() throws Exception { + var startDate = LocalDate.of(2026, 3, 10); + var endDate = LocalDate.of(2026, 3, 5); + + when(accommodationService.search("Belgrade", 2, startDate, endDate)) + .thenThrow(new IllegalArgumentException("End date must be after start date")); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-10") + .param("endDate", "2026-03-05")) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java new file mode 100644 index 0000000..01491dc --- /dev/null +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java @@ -0,0 +1,299 @@ +package com.devoops.accommodation.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AccommodationSearchIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("accommodation_db_test") + .withUsername("test") + .withPassword("test"); + + @Container + static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2024-01-31T20-20-33Z"); + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static String accommodationId; + private static String accommodationIdPerUnit; + private static final UUID HOST_ID = UUID.randomUUID(); + + private static final String BASE_PATH = "/api/accommodation"; + private static final String SEARCH_PATH = BASE_PATH + "/search"; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + + registry.add("minio.endpoint", minio::getS3URL); + registry.add("minio.access-key", minio::getUserName); + registry.add("minio.secret-key", minio::getPassword); + registry.add("minio.bucket", () -> "test-accommodation-photos"); + } + + @Test + @Order(1) + @DisplayName("Setup: Create PER_GUEST accommodation in Belgrade") + void setup_CreatePerGuestAccommodation() throws Exception { + var request = Map.of( + "name", "Belgrade Apartment", + "address", "123 Belgrade Center, Serbia", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + MvcResult result = mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + accommodationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(2) + @DisplayName("Setup: Create availability period for PER_GUEST accommodation") + void setup_CreateAvailabilityPeriod() throws Exception { + var request = Map.of( + "startDate", "2026-03-01", + "endDate", "2026-03-31", + "pricePerDay", 50.00 + ); + + mockMvc.perform(post(BASE_PATH + "/" + accommodationId + "/availability") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @Order(3) + @DisplayName("Setup: Create PER_UNIT accommodation in Belgrade") + void setup_CreatePerUnitAccommodation() throws Exception { + var request = Map.of( + "name", "Belgrade Studio", + "address", "456 Belgrade New, Serbia", + "minGuests", 1, + "maxGuests", 2, + "pricingMode", "PER_UNIT", + "approvalMode", "AUTOMATIC" + ); + + MvcResult result = mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + accommodationIdPerUnit = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(4) + @DisplayName("Setup: Create availability period for PER_UNIT accommodation") + void setup_CreatePerUnitAvailabilityPeriod() throws Exception { + var request = Map.of( + "startDate", "2026-03-01", + "endDate", "2026-03-31", + "pricePerDay", 100.00 + ); + + mockMvc.perform(post(BASE_PATH + "/" + accommodationIdPerUnit + "/availability") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @Order(5) + @DisplayName("Search with matching criteria returns results with prices") + void search_WithMatchingCriteria_ReturnsResultsWithPrices() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[*].totalPrice").exists()) + .andExpect(jsonPath("$[*].unitPrice").exists()) + .andExpect(jsonPath("$[*].numberOfNights").exists()); + } + + @Test + @Order(6) + @DisplayName("Search PER_GUEST accommodation calculates price with guest multiplier") + void search_PerGuestAccommodation_CalculatesWithGuestMultiplier() throws Exception { + // 50/day * 5 nights * 2 guests = 500 + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade Center") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Belgrade Apartment")) + .andExpect(jsonPath("$[0].unitPrice").value(50.00)) + .andExpect(jsonPath("$[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$[0].numberOfNights").value(5)); + } + + @Test + @Order(7) + @DisplayName("Search PER_UNIT accommodation calculates price without guest multiplier") + void search_PerUnitAccommodation_CalculatesWithoutGuestMultiplier() throws Exception { + // 100/day * 5 nights = 500 (no guest multiplier) + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade New") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Belgrade Studio")) + .andExpect(jsonPath("$[0].unitPrice").value(100.00)) + .andExpect(jsonPath("$[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$[0].numberOfNights").value(5)); + } + + @Test + @Order(8) + @DisplayName("Search with non-matching location returns empty list") + void search_WithNonMatchingLocation_ReturnsEmptyList() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Paris") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @Order(9) + @DisplayName("Search with too many guests returns empty list") + void search_WithTooManyGuests_ReturnsEmptyList() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "10") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @Order(10) + @DisplayName("Search with dates outside availability returns empty list") + void search_WithDatesOutsideAvailability_ReturnsEmptyList() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-05-01") + .param("endDate", "2026-05-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + @Order(11) + @DisplayName("Search without auth headers returns 200 (public endpoint)") + void search_WithoutAuth_Returns200() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()); + } + + @Test + @Order(12) + @DisplayName("Search with missing parameters returns 400") + void search_WithMissingParams_Returns400() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade")) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(13) + @DisplayName("Search with end date before start date returns 400") + void search_WithInvalidDates_Returns400() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-10") + .param("endDate", "2026-03-05")) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(14) + @DisplayName("Search returns all accommodation fields") + void search_ReturnsAllAccommodationFields() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade Center") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(accommodationId)) + .andExpect(jsonPath("$[0].hostId").value(HOST_ID.toString())) + .andExpect(jsonPath("$[0].name").value("Belgrade Apartment")) + .andExpect(jsonPath("$[0].address").value("123 Belgrade Center, Serbia")) + .andExpect(jsonPath("$[0].minGuests").value(1)) + .andExpect(jsonPath("$[0].maxGuests").value(4)) + .andExpect(jsonPath("$[0].pricingMode").value("PER_GUEST")) + .andExpect(jsonPath("$[0].approvalMode").value("MANUAL")); + } +} diff --git a/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java b/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java new file mode 100644 index 0000000..0a83a09 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java @@ -0,0 +1,240 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.dto.response.AccommodationSearchResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.mapper.AccommodationMapper; +import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AccommodationSearchServiceTest { + + @Mock + private AccommodationRepository accommodationRepository; + + @Mock + private AccommodationMapper accommodationMapper; + + @Mock + private AvailabilityPeriodRepository availabilityPeriodRepository; + + @InjectMocks + private AccommodationService accommodationService; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + + private Accommodation createAccommodation(PricingMode pricingMode) { + return Accommodation.builder() + .id(ACCOMMODATION_ID) + .hostId(HOST_ID) + .name("Test Apartment") + .address("123 Belgrade St") + .minGuests(1) + .maxGuests(4) + .pricingMode(pricingMode) + .approvalMode(ApprovalMode.MANUAL) + .build(); + } + + private AvailabilityPeriod createPeriod(BigDecimal pricePerDay) { + return AvailabilityPeriod.builder() + .id(UUID.randomUUID()) + .accommodationId(ACCOMMODATION_ID) + .startDate(LocalDate.of(2026, 3, 1)) + .endDate(LocalDate.of(2026, 3, 31)) + .pricePerDay(pricePerDay) + .build(); + } + + @Nested + @DisplayName("Search") + class SearchTests { + + @Test + @DisplayName("With matching location and guests and covering period returns results") + void search_WithMatchingCriteria_ReturnsResults() { + var accommodation = createAccommodation(PricingMode.PER_GUEST); + var period = createPeriod(new BigDecimal("50.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + + List results = accommodationService.search("Belgrade", 2, startDate, endDate); + + assertThat(results).hasSize(1); + assertThat(results.get(0).id()).isEqualTo(ACCOMMODATION_ID); + assertThat(results.get(0).name()).isEqualTo("Test Apartment"); + } + + @Test + @DisplayName("With PER_GUEST pricing calculates total correctly") + void search_WithPerGuestPricing_CalculatesTotalCorrectly() { + var accommodation = createAccommodation(PricingMode.PER_GUEST); + var period = createPeriod(new BigDecimal("50.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); // 5 nights + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + + List results = accommodationService.search("Belgrade", 2, startDate, endDate); + + assertThat(results.get(0).unitPrice()).isEqualByComparingTo(new BigDecimal("50.00")); + // 50 * 5 nights * 2 guests = 500 + assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("500.00")); + assertThat(results.get(0).numberOfNights()).isEqualTo(5); + } + + @Test + @DisplayName("With PER_UNIT pricing calculates total without guest multiplier") + void search_WithPerUnitPricing_CalculatesTotalWithoutGuestMultiplier() { + var accommodation = createAccommodation(PricingMode.PER_UNIT); + var period = createPeriod(new BigDecimal("100.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); // 5 nights + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 3)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + + List results = accommodationService.search("Belgrade", 3, startDate, endDate); + + assertThat(results.get(0).unitPrice()).isEqualByComparingTo(new BigDecimal("100.00")); + // 100 * 5 nights = 500 (no guest multiplier) + assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("500.00")); + assertThat(results.get(0).numberOfNights()).isEqualTo(5); + } + + @Test + @DisplayName("With no covering period excludes accommodation from results") + void search_WithNoCoveringPeriod_ExcludesAccommodation() { + var accommodation = createAccommodation(PricingMode.PER_GUEST); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.empty()); + + List results = accommodationService.search("Belgrade", 2, startDate, endDate); + + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("With no matching location returns empty list") + void search_WithNoMatchingLocation_ReturnsEmptyList() { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Nowhere", 2)) + .thenReturn(List.of()); + + List results = accommodationService.search("Nowhere", 2, startDate, endDate); + + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("With end date before start date throws IllegalArgumentException") + void search_WithEndDateBeforeStartDate_ThrowsIllegalArgumentException() { + var startDate = LocalDate.of(2026, 3, 10); + var endDate = LocalDate.of(2026, 3, 5); + + assertThatThrownBy(() -> accommodationService.search("Belgrade", 2, startDate, endDate)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End date must be after start date"); + } + + @Test + @DisplayName("With equal start and end date throws IllegalArgumentException") + void search_WithEqualDates_ThrowsIllegalArgumentException() { + var date = LocalDate.of(2026, 3, 10); + + assertThatThrownBy(() -> accommodationService.search("Belgrade", 2, date, date)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End date must be after start date"); + } + + @Test + @DisplayName("With multiple candidates returns only those with availability") + void search_WithMultipleCandidates_ReturnsOnlyAvailable() { + var accommodation1 = createAccommodation(PricingMode.PER_GUEST); + var id2 = UUID.randomUUID(); + var accommodation2 = Accommodation.builder() + .id(id2) + .hostId(HOST_ID) + .name("Second Apartment") + .address("456 Belgrade Ave") + .minGuests(1) + .maxGuests(4) + .pricingMode(PricingMode.PER_UNIT) + .approvalMode(ApprovalMode.AUTOMATIC) + .build(); + var period = createPeriod(new BigDecimal("75.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation1, accommodation2)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + when(availabilityPeriodRepository.findCoveringPeriod(id2, startDate, endDate)) + .thenReturn(Optional.empty()); + + List results = accommodationService.search("Belgrade", 2, startDate, endDate); + + assertThat(results).hasSize(1); + assertThat(results.get(0).id()).isEqualTo(ACCOMMODATION_ID); + } + + @Test + @DisplayName("With single night stay calculates price correctly") + void search_WithSingleNight_CalculatesPriceCorrectly() { + var accommodation = createAccommodation(PricingMode.PER_GUEST); + var period = createPeriod(new BigDecimal("80.00")); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 6); // 1 night + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 1)) + .thenReturn(List.of(accommodation)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period)); + + List results = accommodationService.search("Belgrade", 1, startDate, endDate); + + assertThat(results.get(0).numberOfNights()).isEqualTo(1); + // 80 * 1 night * 1 guest = 80 + assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("80.00")); + } + } +} From 497b074fe1611d023399ae00833c4c3cdb678eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20=C4=8Cuturi=C4=87?= Date: Fri, 20 Feb 2026 17:43:57 +0100 Subject: [PATCH 19/24] fix: Add search pagination --- .../controller/AccommodationController.java | 8 +- .../repository/AccommodationRepository.java | 2 + .../service/AccommodationService.java | 15 ++- .../AccommodationSearchControllerTest.java | 64 +++++++++---- .../AccommodationSearchIntegrationTest.java | 92 +++++++++++------- .../AccommodationSearchServiceTest.java | 95 ++++++++++++++----- 6 files changed, 191 insertions(+), 85 deletions(-) diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java index d4463f3..feb60e2 100644 --- a/src/main/java/com/devoops/accommodation/controller/AccommodationController.java +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationController.java @@ -43,12 +43,14 @@ public ResponseEntity> getAll( } @GetMapping("/search") - public ResponseEntity> search( + public ResponseEntity> search( @RequestParam String location, @RequestParam int guests, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { - return ResponseEntity.ok(accommodationService.search(location, guests, startDate, endDate)); + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "12") int size) { + return ResponseEntity.ok(accommodationService.search(location, guests, startDate, endDate, page, size)); } @GetMapping("/{id}") diff --git a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java index 8f5f0fe..8b78295 100644 --- a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java +++ b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java @@ -1,6 +1,8 @@ package com.devoops.accommodation.repository; import com.devoops.accommodation.entity.Accommodation; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationService.java b/src/main/java/com/devoops/accommodation/service/AccommodationService.java index 5f37f92..9233b60 100644 --- a/src/main/java/com/devoops/accommodation/service/AccommodationService.java +++ b/src/main/java/com/devoops/accommodation/service/AccommodationService.java @@ -15,6 +15,7 @@ import com.devoops.accommodation.repository.AvailabilityPeriodRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -115,14 +116,14 @@ public void delete(UUID id, UserContext userContext) { } @Transactional(readOnly = true) - public List search(String location, int guests, LocalDate startDate, LocalDate endDate) { + public Page search(String location, int guests, LocalDate startDate, LocalDate endDate, int page, int size) { if (!endDate.isAfter(startDate)) { throw new IllegalArgumentException("End date must be after start date"); } long nights = ChronoUnit.DAYS.between(startDate, endDate); List candidates = accommodationRepository.searchByLocationAndGuests(location, guests); - List results = new ArrayList<>(); + List allResults = new ArrayList<>(); for (Accommodation accommodation : candidates) { Optional coveringPeriod = availabilityPeriodRepository @@ -139,7 +140,7 @@ public List search(String location, int guests, Loc totalPrice = unitPrice.multiply(BigDecimal.valueOf(nights)); } - results.add(new AccommodationSearchResponse( + allResults.add(new AccommodationSearchResponse( accommodation.getId(), accommodation.getHostId(), accommodation.getName(), @@ -158,7 +159,13 @@ public List search(String location, int guests, Loc } } - return results; + int start = page * size; + int end = Math.min(start + size, allResults.size()); + List pageContent = start >= allResults.size() + ? List.of() + : allResults.subList(start, end); + + return new PageImpl<>(pageContent, PageRequest.of(page, size), allResults.size()); } private Accommodation findAccommodationOrThrow(UUID id) { diff --git a/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java index 431ad65..f2f985c 100644 --- a/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java +++ b/src/test/java/com/devoops/accommodation/controller/AccommodationSearchControllerTest.java @@ -15,6 +15,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -65,13 +67,14 @@ private AccommodationSearchResponse createSearchResponse() { class SearchEndpoint { @Test - @DisplayName("With valid parameters returns 200 with results") + @DisplayName("With valid parameters returns 200 with paginated results") void search_WithValidParams_Returns200WithResults() throws Exception { var startDate = LocalDate.of(2026, 3, 5); var endDate = LocalDate.of(2026, 3, 10); + var searchResponse = createSearchResponse(); - when(accommodationService.search("Belgrade", 2, startDate, endDate)) - .thenReturn(List.of(createSearchResponse())); + when(accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12)) + .thenReturn(new PageImpl<>(List.of(searchResponse), PageRequest.of(0, 12), 1)); mockMvc.perform(get("/api/accommodation/search") .param("location", "Belgrade") @@ -79,21 +82,24 @@ void search_WithValidParams_Returns200WithResults() throws Exception { .param("startDate", "2026-03-05") .param("endDate", "2026-03-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(ACCOMMODATION_ID.toString())) - .andExpect(jsonPath("$[0].name").value("Test Apartment")) - .andExpect(jsonPath("$[0].totalPrice").value(500.00)) - .andExpect(jsonPath("$[0].unitPrice").value(50.00)) - .andExpect(jsonPath("$[0].numberOfNights").value(5)); + .andExpect(jsonPath("$.content[0].id").value(ACCOMMODATION_ID.toString())) + .andExpect(jsonPath("$.content[0].name").value("Test Apartment")) + .andExpect(jsonPath("$.content[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$.content[0].unitPrice").value(50.00)) + .andExpect(jsonPath("$.content[0].numberOfNights").value(5)) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.last").value(true)); } @Test - @DisplayName("With no results returns 200 with empty list") - void search_WithNoResults_Returns200WithEmptyList() throws Exception { + @DisplayName("With no results returns 200 with empty page") + void search_WithNoResults_Returns200WithEmptyPage() throws Exception { var startDate = LocalDate.of(2026, 3, 5); var endDate = LocalDate.of(2026, 3, 10); - when(accommodationService.search("Nowhere", 2, startDate, endDate)) - .thenReturn(List.of()); + when(accommodationService.search("Nowhere", 2, startDate, endDate, 0, 12)) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 12), 0)); mockMvc.perform(get("/api/accommodation/search") .param("location", "Nowhere") @@ -101,8 +107,9 @@ void search_WithNoResults_Returns200WithEmptyList() throws Exception { .param("startDate", "2026-03-05") .param("endDate", "2026-03-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content").isEmpty()) + .andExpect(jsonPath("$.totalElements").value(0)); } @Test @@ -111,8 +118,8 @@ void search_WithoutAuthHeaders_Returns200() throws Exception { var startDate = LocalDate.of(2026, 3, 5); var endDate = LocalDate.of(2026, 3, 10); - when(accommodationService.search("Belgrade", 2, startDate, endDate)) - .thenReturn(List.of()); + when(accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12)) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 12), 0)); mockMvc.perform(get("/api/accommodation/search") .param("location", "Belgrade") @@ -152,12 +159,12 @@ void search_WithMissingDates_Returns400() throws Exception { } @Test - @DisplayName("With invalid date returns IllegalArgumentException") + @DisplayName("With invalid date returns 400") void search_WithInvalidDates_Returns400() throws Exception { var startDate = LocalDate.of(2026, 3, 10); var endDate = LocalDate.of(2026, 3, 5); - when(accommodationService.search("Belgrade", 2, startDate, endDate)) + when(accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12)) .thenThrow(new IllegalArgumentException("End date must be after start date")); mockMvc.perform(get("/api/accommodation/search") @@ -167,5 +174,26 @@ void search_WithInvalidDates_Returns400() throws Exception { .param("endDate", "2026-03-05")) .andExpect(status().isBadRequest()); } + + @Test + @DisplayName("With custom page and size parameters uses them") + void search_WithCustomPageAndSize_UsesThem() throws Exception { + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationService.search("Belgrade", 2, startDate, endDate, 1, 6)) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(1, 6), 0)); + + mockMvc.perform(get("/api/accommodation/search") + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10") + .param("page", "1") + .param("size", "6")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.number").value(1)) + .andExpect(jsonPath("$.size").value(6)); + } } } diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java index 01491dc..3eab1ab 100644 --- a/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationSearchIntegrationTest.java @@ -155,18 +155,20 @@ void setup_CreatePerUnitAvailabilityPeriod() throws Exception { @Test @Order(5) - @DisplayName("Search with matching criteria returns results with prices") - void search_WithMatchingCriteria_ReturnsResultsWithPrices() throws Exception { + @DisplayName("Search with matching criteria returns paginated results with prices") + void search_WithMatchingCriteria_ReturnsPaginatedResultsWithPrices() throws Exception { mockMvc.perform(get(SEARCH_PATH) .param("location", "Belgrade") .param("guests", "2") .param("startDate", "2026-03-05") .param("endDate", "2026-03-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(2))) - .andExpect(jsonPath("$[*].totalPrice").exists()) - .andExpect(jsonPath("$[*].unitPrice").exists()) - .andExpect(jsonPath("$[*].numberOfNights").exists()); + .andExpect(jsonPath("$.content", hasSize(2))) + .andExpect(jsonPath("$.content[*].totalPrice").exists()) + .andExpect(jsonPath("$.content[*].unitPrice").exists()) + .andExpect(jsonPath("$.content[*].numberOfNights").exists()) + .andExpect(jsonPath("$.totalElements").value(2)) + .andExpect(jsonPath("$.last").value(true)); } @Test @@ -180,11 +182,11 @@ void search_PerGuestAccommodation_CalculatesWithGuestMultiplier() throws Excepti .param("startDate", "2026-03-05") .param("endDate", "2026-03-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(1))) - .andExpect(jsonPath("$[0].name").value("Belgrade Apartment")) - .andExpect(jsonPath("$[0].unitPrice").value(50.00)) - .andExpect(jsonPath("$[0].totalPrice").value(500.00)) - .andExpect(jsonPath("$[0].numberOfNights").value(5)); + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].name").value("Belgrade Apartment")) + .andExpect(jsonPath("$.content[0].unitPrice").value(50.00)) + .andExpect(jsonPath("$.content[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$.content[0].numberOfNights").value(5)); } @Test @@ -198,50 +200,53 @@ void search_PerUnitAccommodation_CalculatesWithoutGuestMultiplier() throws Excep .param("startDate", "2026-03-05") .param("endDate", "2026-03-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(1))) - .andExpect(jsonPath("$[0].name").value("Belgrade Studio")) - .andExpect(jsonPath("$[0].unitPrice").value(100.00)) - .andExpect(jsonPath("$[0].totalPrice").value(500.00)) - .andExpect(jsonPath("$[0].numberOfNights").value(5)); + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].name").value("Belgrade Studio")) + .andExpect(jsonPath("$.content[0].unitPrice").value(100.00)) + .andExpect(jsonPath("$.content[0].totalPrice").value(500.00)) + .andExpect(jsonPath("$.content[0].numberOfNights").value(5)); } @Test @Order(8) - @DisplayName("Search with non-matching location returns empty list") - void search_WithNonMatchingLocation_ReturnsEmptyList() throws Exception { + @DisplayName("Search with non-matching location returns empty page") + void search_WithNonMatchingLocation_ReturnsEmptyPage() throws Exception { mockMvc.perform(get(SEARCH_PATH) .param("location", "Paris") .param("guests", "2") .param("startDate", "2026-03-05") .param("endDate", "2026-03-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(0))); + .andExpect(jsonPath("$.content", hasSize(0))) + .andExpect(jsonPath("$.totalElements").value(0)); } @Test @Order(9) - @DisplayName("Search with too many guests returns empty list") - void search_WithTooManyGuests_ReturnsEmptyList() throws Exception { + @DisplayName("Search with too many guests returns empty page") + void search_WithTooManyGuests_ReturnsEmptyPage() throws Exception { mockMvc.perform(get(SEARCH_PATH) .param("location", "Belgrade") .param("guests", "10") .param("startDate", "2026-03-05") .param("endDate", "2026-03-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(0))); + .andExpect(jsonPath("$.content", hasSize(0))) + .andExpect(jsonPath("$.totalElements").value(0)); } @Test @Order(10) - @DisplayName("Search with dates outside availability returns empty list") - void search_WithDatesOutsideAvailability_ReturnsEmptyList() throws Exception { + @DisplayName("Search with dates outside availability returns empty page") + void search_WithDatesOutsideAvailability_ReturnsEmptyPage() throws Exception { mockMvc.perform(get(SEARCH_PATH) .param("location", "Belgrade") .param("guests", "2") .param("startDate", "2026-05-01") .param("endDate", "2026-05-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(0))); + .andExpect(jsonPath("$.content", hasSize(0))) + .andExpect(jsonPath("$.totalElements").value(0)); } @Test @@ -279,7 +284,7 @@ void search_WithInvalidDates_Returns400() throws Exception { @Test @Order(14) - @DisplayName("Search returns all accommodation fields") + @DisplayName("Search returns all accommodation fields in paginated response") void search_ReturnsAllAccommodationFields() throws Exception { mockMvc.perform(get(SEARCH_PATH) .param("location", "Belgrade Center") @@ -287,13 +292,32 @@ void search_ReturnsAllAccommodationFields() throws Exception { .param("startDate", "2026-03-05") .param("endDate", "2026-03-10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(accommodationId)) - .andExpect(jsonPath("$[0].hostId").value(HOST_ID.toString())) - .andExpect(jsonPath("$[0].name").value("Belgrade Apartment")) - .andExpect(jsonPath("$[0].address").value("123 Belgrade Center, Serbia")) - .andExpect(jsonPath("$[0].minGuests").value(1)) - .andExpect(jsonPath("$[0].maxGuests").value(4)) - .andExpect(jsonPath("$[0].pricingMode").value("PER_GUEST")) - .andExpect(jsonPath("$[0].approvalMode").value("MANUAL")); + .andExpect(jsonPath("$.content[0].id").value(accommodationId)) + .andExpect(jsonPath("$.content[0].hostId").value(HOST_ID.toString())) + .andExpect(jsonPath("$.content[0].name").value("Belgrade Apartment")) + .andExpect(jsonPath("$.content[0].address").value("123 Belgrade Center, Serbia")) + .andExpect(jsonPath("$.content[0].minGuests").value(1)) + .andExpect(jsonPath("$.content[0].maxGuests").value(4)) + .andExpect(jsonPath("$.content[0].pricingMode").value("PER_GUEST")) + .andExpect(jsonPath("$.content[0].approvalMode").value("MANUAL")); + } + + @Test + @Order(15) + @DisplayName("Search with pagination returns correct page") + void search_WithPagination_ReturnsCorrectPage() throws Exception { + mockMvc.perform(get(SEARCH_PATH) + .param("location", "Belgrade") + .param("guests", "2") + .param("startDate", "2026-03-05") + .param("endDate", "2026-03-10") + .param("page", "0") + .param("size", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.totalElements").value(2)) + .andExpect(jsonPath("$.totalPages").value(2)) + .andExpect(jsonPath("$.last").value(false)) + .andExpect(jsonPath("$.number").value(0)); } } diff --git a/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java b/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java index 0a83a09..dcfd137 100644 --- a/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java +++ b/src/test/java/com/devoops/accommodation/service/AccommodationSearchServiceTest.java @@ -15,6 +15,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; import java.math.BigDecimal; import java.time.LocalDate; @@ -84,11 +85,12 @@ void search_WithMatchingCriteria_ReturnsResults() { when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) .thenReturn(Optional.of(period)); - List results = accommodationService.search("Belgrade", 2, startDate, endDate); + Page results = accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12); - assertThat(results).hasSize(1); - assertThat(results.get(0).id()).isEqualTo(ACCOMMODATION_ID); - assertThat(results.get(0).name()).isEqualTo("Test Apartment"); + assertThat(results.getContent()).hasSize(1); + assertThat(results.getContent().get(0).id()).isEqualTo(ACCOMMODATION_ID); + assertThat(results.getContent().get(0).name()).isEqualTo("Test Apartment"); + assertThat(results.getTotalElements()).isEqualTo(1); } @Test @@ -104,12 +106,12 @@ void search_WithPerGuestPricing_CalculatesTotalCorrectly() { when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) .thenReturn(Optional.of(period)); - List results = accommodationService.search("Belgrade", 2, startDate, endDate); + Page results = accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12); - assertThat(results.get(0).unitPrice()).isEqualByComparingTo(new BigDecimal("50.00")); + assertThat(results.getContent().get(0).unitPrice()).isEqualByComparingTo(new BigDecimal("50.00")); // 50 * 5 nights * 2 guests = 500 - assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("500.00")); - assertThat(results.get(0).numberOfNights()).isEqualTo(5); + assertThat(results.getContent().get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("500.00")); + assertThat(results.getContent().get(0).numberOfNights()).isEqualTo(5); } @Test @@ -125,12 +127,12 @@ void search_WithPerUnitPricing_CalculatesTotalWithoutGuestMultiplier() { when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) .thenReturn(Optional.of(period)); - List results = accommodationService.search("Belgrade", 3, startDate, endDate); + Page results = accommodationService.search("Belgrade", 3, startDate, endDate, 0, 12); - assertThat(results.get(0).unitPrice()).isEqualByComparingTo(new BigDecimal("100.00")); + assertThat(results.getContent().get(0).unitPrice()).isEqualByComparingTo(new BigDecimal("100.00")); // 100 * 5 nights = 500 (no guest multiplier) - assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("500.00")); - assertThat(results.get(0).numberOfNights()).isEqualTo(5); + assertThat(results.getContent().get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("500.00")); + assertThat(results.getContent().get(0).numberOfNights()).isEqualTo(5); } @Test @@ -145,23 +147,25 @@ void search_WithNoCoveringPeriod_ExcludesAccommodation() { when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) .thenReturn(Optional.empty()); - List results = accommodationService.search("Belgrade", 2, startDate, endDate); + Page results = accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12); - assertThat(results).isEmpty(); + assertThat(results.getContent()).isEmpty(); + assertThat(results.getTotalElements()).isZero(); } @Test - @DisplayName("With no matching location returns empty list") - void search_WithNoMatchingLocation_ReturnsEmptyList() { + @DisplayName("With no matching location returns empty page") + void search_WithNoMatchingLocation_ReturnsEmptyPage() { var startDate = LocalDate.of(2026, 3, 5); var endDate = LocalDate.of(2026, 3, 10); when(accommodationRepository.searchByLocationAndGuests("Nowhere", 2)) .thenReturn(List.of()); - List results = accommodationService.search("Nowhere", 2, startDate, endDate); + Page results = accommodationService.search("Nowhere", 2, startDate, endDate, 0, 12); - assertThat(results).isEmpty(); + assertThat(results.getContent()).isEmpty(); + assertThat(results.getTotalElements()).isZero(); } @Test @@ -170,7 +174,7 @@ void search_WithEndDateBeforeStartDate_ThrowsIllegalArgumentException() { var startDate = LocalDate.of(2026, 3, 10); var endDate = LocalDate.of(2026, 3, 5); - assertThatThrownBy(() -> accommodationService.search("Belgrade", 2, startDate, endDate)) + assertThatThrownBy(() -> accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("End date must be after start date"); } @@ -180,7 +184,7 @@ void search_WithEndDateBeforeStartDate_ThrowsIllegalArgumentException() { void search_WithEqualDates_ThrowsIllegalArgumentException() { var date = LocalDate.of(2026, 3, 10); - assertThatThrownBy(() -> accommodationService.search("Belgrade", 2, date, date)) + assertThatThrownBy(() -> accommodationService.search("Belgrade", 2, date, date, 0, 12)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("End date must be after start date"); } @@ -211,10 +215,11 @@ void search_WithMultipleCandidates_ReturnsOnlyAvailable() { when(availabilityPeriodRepository.findCoveringPeriod(id2, startDate, endDate)) .thenReturn(Optional.empty()); - List results = accommodationService.search("Belgrade", 2, startDate, endDate); + Page results = accommodationService.search("Belgrade", 2, startDate, endDate, 0, 12); - assertThat(results).hasSize(1); - assertThat(results.get(0).id()).isEqualTo(ACCOMMODATION_ID); + assertThat(results.getContent()).hasSize(1); + assertThat(results.getContent().get(0).id()).isEqualTo(ACCOMMODATION_ID); + assertThat(results.getTotalElements()).isEqualTo(1); } @Test @@ -230,11 +235,49 @@ void search_WithSingleNight_CalculatesPriceCorrectly() { when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) .thenReturn(Optional.of(period)); - List results = accommodationService.search("Belgrade", 1, startDate, endDate); + Page results = accommodationService.search("Belgrade", 1, startDate, endDate, 0, 12); - assertThat(results.get(0).numberOfNights()).isEqualTo(1); + assertThat(results.getContent().get(0).numberOfNights()).isEqualTo(1); // 80 * 1 night * 1 guest = 80 - assertThat(results.get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("80.00")); + assertThat(results.getContent().get(0).totalPrice()).isEqualByComparingTo(new BigDecimal("80.00")); + } + + @Test + @DisplayName("Pagination returns correct page slice") + void search_WithPagination_ReturnsCorrectSlice() { + var accommodation1 = createAccommodation(PricingMode.PER_GUEST); + var id2 = UUID.randomUUID(); + var accommodation2 = Accommodation.builder() + .id(id2).hostId(HOST_ID).name("Second").address("Belgrade") + .minGuests(1).maxGuests(4).pricingMode(PricingMode.PER_GUEST) + .approvalMode(ApprovalMode.MANUAL).build(); + var period1 = createPeriod(new BigDecimal("50.00")); + var period2 = AvailabilityPeriod.builder() + .id(UUID.randomUUID()).accommodationId(id2) + .startDate(LocalDate.of(2026, 3, 1)).endDate(LocalDate.of(2026, 3, 31)) + .pricePerDay(new BigDecimal("60.00")).build(); + var startDate = LocalDate.of(2026, 3, 5); + var endDate = LocalDate.of(2026, 3, 10); + + when(accommodationRepository.searchByLocationAndGuests("Belgrade", 2)) + .thenReturn(List.of(accommodation1, accommodation2)); + when(availabilityPeriodRepository.findCoveringPeriod(ACCOMMODATION_ID, startDate, endDate)) + .thenReturn(Optional.of(period1)); + when(availabilityPeriodRepository.findCoveringPeriod(id2, startDate, endDate)) + .thenReturn(Optional.of(period2)); + + // Page 0, size 1 — should return first result only + Page page0 = accommodationService.search("Belgrade", 2, startDate, endDate, 0, 1); + assertThat(page0.getContent()).hasSize(1); + assertThat(page0.getTotalElements()).isEqualTo(2); + assertThat(page0.getTotalPages()).isEqualTo(2); + assertThat(page0.isLast()).isFalse(); + + // Page 1, size 1 — should return second result only + Page page1 = accommodationService.search("Belgrade", 2, startDate, endDate, 1, 1); + assertThat(page1.getContent()).hasSize(1); + assertThat(page1.getTotalElements()).isEqualTo(2); + assertThat(page1.isLast()).isTrue(); } } } From 72503f44b08290273928415a1acc8a7b053493a0 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:32:37 +0100 Subject: [PATCH 20/24] feat: Delete accommodations of a host after the host gets deleted. --- .../grpc/AccommodationGrpcService.java | 45 +++++++++++++++++-- .../repository/AccommodationRepository.java | 17 +++++++ .../service/AccommodationService.java | 13 ++++++ src/main/proto/accommodation_internal.proto | 11 +++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java b/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java index 2f13bc0..301e1d6 100644 --- a/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java +++ b/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java @@ -3,11 +3,10 @@ import com.devoops.accommodation.entity.Accommodation; import com.devoops.accommodation.entity.AvailabilityPeriod; import com.devoops.accommodation.entity.PricingMode; -import com.devoops.accommodation.grpc.proto.AccommodationInternalServiceGrpc; -import com.devoops.accommodation.grpc.proto.ReservationValidationRequest; -import com.devoops.accommodation.grpc.proto.ReservationValidationResponse; +import com.devoops.accommodation.grpc.proto.*; import com.devoops.accommodation.repository.AccommodationRepository; import com.devoops.accommodation.repository.AvailabilityPeriodRepository; +import com.devoops.accommodation.service.AccommodationService; import io.grpc.stub.StreamObserver; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,6 +26,7 @@ public class AccommodationGrpcService extends AccommodationInternalServiceGrpc.A private final AccommodationRepository accommodationRepository; private final AvailabilityPeriodRepository availabilityPeriodRepository; + private final AccommodationService accommodationService; @Override public void validateAndCalculatePrice( @@ -126,4 +126,43 @@ private ReservationValidationResponse buildErrorResponse(String errorCode, Strin .setErrorMessage(errorMessage) .build(); } + + @Override + public void deleteAccommodationsByHost(DeleteByHostRequest request, + StreamObserver responseObserver) { + log.debug("gRPC: Deleting all accommodations for host: {}", request.getHostId()); + + try { + UUID hostId = UUID.fromString(request.getHostId()); + int deletedCount = accommodationService.deleteAllByHostId(hostId); + + log.info("Deleted {} accommodations for host {}", deletedCount, hostId); + + DeleteByHostResponse response = DeleteByHostResponse.newBuilder() + .setSuccess(true) + .setDeletedCount(deletedCount) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } catch (IllegalArgumentException e) { + log.error("Invalid host ID format: {}", request.getHostId(), e); + DeleteByHostResponse response = DeleteByHostResponse.newBuilder() + .setSuccess(false) + .setDeletedCount(0) + .setErrorMessage("Invalid host ID format") + .build(); + responseObserver.onNext(response); + responseObserver.onCompleted(); + } catch (Exception e) { + log.error("Failed to delete accommodations for host {}", request.getHostId(), e); + DeleteByHostResponse response = DeleteByHostResponse.newBuilder() + .setSuccess(false) + .setDeletedCount(0) + .setErrorMessage("Failed to delete accommodations: " + e.getMessage()) + .build(); + responseObserver.onNext(response); + responseObserver.onCompleted(); + } + } } diff --git a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java index 8b78295..a7e3875 100644 --- a/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java +++ b/src/main/java/com/devoops/accommodation/repository/AccommodationRepository.java @@ -4,9 +4,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -24,4 +26,19 @@ WHERE LOWER(a.address) LIKE LOWER(CONCAT('%', :location, '%')) List searchByLocationAndGuests( @Param("location") String location, @Param("guests") int guests); + + /** + * Soft delete all accommodations for a host. + * Used when host deletes their account. + * + * @return the number of accommodations soft-deleted + */ + @Modifying + @Query(""" + UPDATE Accommodation a + SET a.isDeleted = true, a.updatedAt = :now + WHERE a.hostId = :hostId + AND a.isDeleted = false + """) + int softDeleteByHostId(@Param("hostId") UUID hostId, @Param("now") LocalDateTime now); } diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationService.java b/src/main/java/com/devoops/accommodation/service/AccommodationService.java index 9233b60..0ea7fe7 100644 --- a/src/main/java/com/devoops/accommodation/service/AccommodationService.java +++ b/src/main/java/com/devoops/accommodation/service/AccommodationService.java @@ -24,6 +24,7 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -115,6 +116,18 @@ public void delete(UUID id, UserContext userContext) { accommodationRepository.save(accommodation); } + /** + * Soft delete all accommodations owned by a host. + * Used when a host deletes their account (cascade deletion). + * + * @param hostId the ID of the host whose accommodations should be deleted + * @return the number of accommodations soft-deleted + */ + @Transactional + public int deleteAllByHostId(UUID hostId) { + return accommodationRepository.softDeleteByHostId(hostId, LocalDateTime.now()); + } + @Transactional(readOnly = true) public Page search(String location, int guests, LocalDate startDate, LocalDate endDate, int page, int size) { if (!endDate.isAfter(startDate)) { diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto index 86bc71f..fa227cd 100644 --- a/src/main/proto/accommodation_internal.proto +++ b/src/main/proto/accommodation_internal.proto @@ -6,6 +6,7 @@ option java_package = "com.devoops.accommodation.grpc.proto"; service AccommodationInternalService { rpc ValidateAndCalculatePrice(ReservationValidationRequest) returns (ReservationValidationResponse); + rpc DeleteAccommodationsByHost(DeleteByHostRequest) returns (DeleteByHostResponse); } message ReservationValidationRequest { @@ -25,3 +26,13 @@ message ReservationValidationResponse { string approval_mode = 7; string accommodation_name = 8; } + +message DeleteByHostRequest { + string host_id = 1; +} + +message DeleteByHostResponse { + bool success = 1; + int32 deleted_count = 2; + string error_message = 3; +} From e1b3664406e811cd43e8875e5d2555a1b1c5dea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 21 Feb 2026 22:13:46 +0100 Subject: [PATCH 21/24] feat: Add GetAccommodationSummary gRPC endpoint Expose a new GetAccommodationSummary RPC in the internal proto and implement the server handler. Adds AccommodationSummaryRequest/AccommodationSummaryResponse messages and implements getAccommodationSummary in AccommodationGrpcService to look up an accommodation by UUID, return found=true with accommodation_name and host_id when present, and return found=false for not-found or invalid ID inputs. Includes logging for lookup and invalid ID errors. --- .../grpc/AccommodationGrpcService.java | 30 +++++++++++++++++++ src/main/proto/accommodation_internal.proto | 11 +++++++ 2 files changed, 41 insertions(+) diff --git a/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java b/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java index 301e1d6..6ca7d04 100644 --- a/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java +++ b/src/main/java/com/devoops/accommodation/grpc/AccommodationGrpcService.java @@ -127,6 +127,36 @@ private ReservationValidationResponse buildErrorResponse(String errorCode, Strin .build(); } + @Override + public void getAccommodationSummary(AccommodationSummaryRequest request, + StreamObserver responseObserver) { + log.debug("gRPC: Getting accommodation summary for: {}", request.getAccommodationId()); + + try { + UUID accommodationId = UUID.fromString(request.getAccommodationId()); + accommodationRepository.findById(accommodationId).ifPresentOrElse( + accommodation -> { + AccommodationSummaryResponse response = AccommodationSummaryResponse.newBuilder() + .setFound(true) + .setAccommodationName(accommodation.getName()) + .setHostId(accommodation.getHostId().toString()) + .build(); + responseObserver.onNext(response); + responseObserver.onCompleted(); + }, + () -> { + log.debug("Accommodation not found: {}", accommodationId); + responseObserver.onNext(AccommodationSummaryResponse.newBuilder().setFound(false).build()); + responseObserver.onCompleted(); + } + ); + } catch (IllegalArgumentException e) { + log.error("Invalid accommodation ID format: {}", request.getAccommodationId()); + responseObserver.onNext(AccommodationSummaryResponse.newBuilder().setFound(false).build()); + responseObserver.onCompleted(); + } + } + @Override public void deleteAccommodationsByHost(DeleteByHostRequest request, StreamObserver responseObserver) { diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto index fa227cd..32aa0ea 100644 --- a/src/main/proto/accommodation_internal.proto +++ b/src/main/proto/accommodation_internal.proto @@ -7,6 +7,7 @@ option java_package = "com.devoops.accommodation.grpc.proto"; service AccommodationInternalService { rpc ValidateAndCalculatePrice(ReservationValidationRequest) returns (ReservationValidationResponse); rpc DeleteAccommodationsByHost(DeleteByHostRequest) returns (DeleteByHostResponse); + rpc GetAccommodationSummary(AccommodationSummaryRequest) returns (AccommodationSummaryResponse); } message ReservationValidationRequest { @@ -36,3 +37,13 @@ message DeleteByHostResponse { int32 deleted_count = 2; string error_message = 3; } + +message AccommodationSummaryRequest { + string accommodation_id = 1; +} + +message AccommodationSummaryResponse { + bool found = 1; + string accommodation_name = 2; + string host_id = 3; +} From a3e957e62ce42567c017b02017487a28ffc44c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Mon, 23 Feb 2026 18:51:20 +0100 Subject: [PATCH 22/24] fix: Removed duplicate tests from pipeline --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81b5476..dea81ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,6 @@ jobs: - name: Build with Gradle run: ./gradlew clean build - - name: Run tests - run: ./gradlew test - - name: Upload build artifact uses: actions/upload-artifact@v4 with: From 79065ece6f52bb5e0aae26090c65fac3f3e27332 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:21:15 +0100 Subject: [PATCH 23/24] feat: Add Helm values. --- environment/helm/values.yaml | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 environment/helm/values.yaml diff --git a/environment/helm/values.yaml b/environment/helm/values.yaml new file mode 100644 index 0000000..111bd09 --- /dev/null +++ b/environment/helm/values.yaml @@ -0,0 +1,54 @@ +fullnameOverride: devoops-accommodation-service +replicaCount: 1 + +image: + registry: docker.io + repository: threeamigoscoding/devoops-accommodation-service + tag: "latest" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + httpPort: 8080 + grpc: + enabled: true + port: 9090 + +ingress: + enabled: false + +resources: + requests: + memory: 256Mi + cpu: 250m + limits: + memory: 512Mi + +health: + path: /actuator/health + periodSeconds: 10 + failureThreshold: 3 + startup: + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 60 + +configData: + JAVA_TOOL_OPTIONS: "-XX:+UseSerialGC -Xms128m -Xmx384m -XX:ActiveProcessorCount=1" + SERVER_PORT: "8080" + LOGSTASH_HOST: "devoops-logstash:5000" + ZIPKIN_HOST: "devoops-jaeger" + ZIPKIN_PORT: "9411" + POSTGRES_HOST: "devoops-postgres" + POSGTES_PORT: "5432" + MINIO_ENDPOINT: "http://devoops-minio:9000" + MINIO_BUCKET: "accommodation-photos" + GRPC_PORT: "9090" + RESERVATION_GRPC_HOST: "devoops-reservation-service" + RESERVATION_GRPC_PORT: "9090" + +secretData: + DB_USERNAME: "accommodation-service" + DB_PASSWORD: "accommodation-service-pass" + MINIO_ACCESS_KEY: "devoops" + MINIO_SECRET_KEY: "devoops123" From 84c844febff6e1c920a69a521e7d4aa81802d7e6 Mon Sep 17 00:00:00 2001 From: Dusan Date: Tue, 24 Feb 2026 22:00:26 +0100 Subject: [PATCH 24/24] feat: Build image with Kaniko --- .github/workflows/ci.yml | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dea81ba..b8282f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,15 +53,6 @@ jobs: name: build-artifact path: build/libs - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - - - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 - with: - username: ${{ env.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Generate version tag id: version run: | @@ -77,13 +68,19 @@ jobs: echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT echo "Generated version tag: $VERSION_TAG" - - name: Build and push Docker image - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 - with: - context: . - push: true - tags: | - ${{ env.DOCKERHUB_USERNAME }}/devoops-accommodation-service:${{ steps.version.outputs.tag }} - ${{ env.DOCKERHUB_USERNAME }}/devoops-accommodation-service:latest - cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + - name: Build and push Docker image with Kaniko + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + mkdir -p /tmp/kaniko/.docker + echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(echo -n ${{ env.DOCKERHUB_USERNAME }}:${DOCKERHUB_TOKEN} | base64)\"}}}" > /tmp/kaniko/.docker/config.json + docker run \ + -v ${{ github.workspace }}:/workspace \ + -v /tmp/kaniko/.docker:/kaniko/.docker \ + gcr.io/kaniko-project/executor:latest \ + --context=/workspace \ + --dockerfile=/workspace/Dockerfile \ + --destination=${{ env.DOCKERHUB_USERNAME }}/devoops-accommodation-service:${{ steps.version.outputs.tag }} \ + --destination=${{ env.DOCKERHUB_USERNAME }}/devoops-accommodation-service:latest \ + --cache=true \ + --cache-repo=${{ env.DOCKERHUB_USERNAME }}/devoops-accommodation-service-cache \ No newline at end of file