From 3bd327ba48b8b8a344f0c80d74e5060c29f3ff86 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 1/3] 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 fd6bc54b63aa93f4bf6297646a317c2650c77235 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 2/3] 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 a1c59a6cfe65a39ec9af762a60c29cdc1b282e49 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 3/3] 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