From fdcb1b7263961fecaabc2b93c2b47eda3fb120b7 Mon Sep 17 00:00:00 2001 From: sumanas27 Date: Sun, 3 Aug 2025 23:32:35 +0200 Subject: [PATCH] SumanaSaha | Feature impl : Shipment Notification by Merchants --- build.gradle.kts | 13 +- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew.bat | 178 +++++------ src/main/kotlin/io/billie/Application.kt | 3 + .../resource/OrganisationResource.kt | 2 - .../command/CreateShipmentCommand.kt | 12 + .../application/dto/CreateShipmentRequest.kt | 28 ++ .../application/dto/CreateShipmentResponse.kt | 15 + .../application/result/ShipmentResult.kt | 11 + .../shipment/domain/events/DomainEvent.kt | 8 + .../domain/events/OrderFullyShippedEvent.kt | 12 + .../domain/events/ShipmentCreatedEvent.kt | 16 + .../InsufficientOrderAmountException.kt | 4 + .../exceptions/OrderNotFoundException.kt | 6 + .../UnauthorizedMerchantException.kt | 7 + .../io/billie/shipment/domain/model/Order.kt | 56 ++++ .../shipment/domain/model/OrderStatus.kt | 8 + .../billie/shipment/domain/model/Shipment.kt | 19 ++ .../shipment/domain/model/ShipmentItem.kt | 9 + .../domain/model/valueobjects/MerchantId.kt | 8 + .../domain/model/valueobjects/Money.kt | 31 ++ .../domain/model/valueobjects/OrderId.kt | 8 + .../domain/model/valueobjects/ShipmentId.kt | 8 + .../domain/repository/OrderRepository.kt | 9 + .../shipment/domain/service/EventPublisher.kt | 7 + .../shipment/domain/service/PaymentResult.kt | 7 + .../shipment/domain/service/PaymentService.kt | 8 + .../domain/service/ShipmentService.kt | 79 +++++ .../config/ApplicationConfiguration.kt | 21 ++ .../config/InfrastructureConfiguration.kt | 26 ++ .../events/ShipmentEventPublisher.kt | 34 +++ .../payment/MockPaymentService.kt | 47 +++ .../persistence/entity/OrderEntity.kt | 45 +++ .../persistence/entity/ShipmentEntity.kt | 45 +++ .../repository/OrderJpaRepository.kt | 6 + .../repository/OrderRepositoryImpl.kt | 76 +++++ .../web/GlobalExceptionHandler.kt | 84 ++++++ .../infrastructure/web/ShipmentController.kt | 82 +++++ src/main/resources/application.properties | 4 + .../db/migration/V10__add_shipments_table.sql | 12 + .../db/migration/V11__Add_orders_data.sql | 6 + .../db/migration/V9__add_orders_table.sql | 15 + .../resource/CanReadLocationsTest.kt | 79 +++++ .../billie/functional/CanReadLocationsTest.kt | 84 ------ .../CanStoreAndReadOrganisationTest.kt | 76 ++--- .../billie/shipment/domain/model/OrderTest.kt | 183 ++++++++++++ .../shipment/domain/model/ShipmentTest.kt | 56 ++++ .../domain/model/valueobjects/MoneyTest.kt | 98 ++++++ .../domain/service/ShipmentServiceTest.kt | 260 ++++++++++++++++ .../infrastructure/ShipmentControllerTest.kt | 282 ++++++++++++++++++ .../repository/OrderRepositoryImplTest.kt | 99 ++++++ .../{functional => util}/data/Fixtures.kt | 2 +- .../{functional => util}/matcher/IsUUID.kt | 2 +- 53 files changed, 2075 insertions(+), 223 deletions(-) create mode 100644 src/main/kotlin/io/billie/shipment/application/command/CreateShipmentCommand.kt create mode 100644 src/main/kotlin/io/billie/shipment/application/dto/CreateShipmentRequest.kt create mode 100644 src/main/kotlin/io/billie/shipment/application/dto/CreateShipmentResponse.kt create mode 100644 src/main/kotlin/io/billie/shipment/application/result/ShipmentResult.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/events/DomainEvent.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/events/OrderFullyShippedEvent.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/events/ShipmentCreatedEvent.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/exceptions/InsufficientOrderAmountException.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/exceptions/OrderNotFoundException.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/exceptions/UnauthorizedMerchantException.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/model/Order.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/model/OrderStatus.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/model/Shipment.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/model/ShipmentItem.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/model/valueobjects/MerchantId.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/model/valueobjects/Money.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/model/valueobjects/OrderId.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/model/valueobjects/ShipmentId.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/repository/OrderRepository.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/service/EventPublisher.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/service/PaymentResult.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/service/PaymentService.kt create mode 100644 src/main/kotlin/io/billie/shipment/domain/service/ShipmentService.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/config/ApplicationConfiguration.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/config/InfrastructureConfiguration.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/events/ShipmentEventPublisher.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/payment/MockPaymentService.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/persistence/entity/OrderEntity.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/persistence/entity/ShipmentEntity.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderJpaRepository.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderRepositoryImpl.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/web/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/io/billie/shipment/infrastructure/web/ShipmentController.kt create mode 100644 src/main/resources/db/migration/V10__add_shipments_table.sql create mode 100644 src/main/resources/db/migration/V11__Add_orders_data.sql create mode 100644 src/main/resources/db/migration/V9__add_orders_table.sql create mode 100644 src/test/kotlin/io/billie/countries/resource/CanReadLocationsTest.kt delete mode 100644 src/test/kotlin/io/billie/functional/CanReadLocationsTest.kt rename src/test/kotlin/io/billie/{functional => organisations/resource}/CanStoreAndReadOrganisationTest.kt (53%) create mode 100644 src/test/kotlin/io/billie/shipment/domain/model/OrderTest.kt create mode 100644 src/test/kotlin/io/billie/shipment/domain/model/ShipmentTest.kt create mode 100644 src/test/kotlin/io/billie/shipment/domain/model/valueobjects/MoneyTest.kt create mode 100644 src/test/kotlin/io/billie/shipment/domain/service/ShipmentServiceTest.kt create mode 100644 src/test/kotlin/io/billie/shipment/infrastructure/ShipmentControllerTest.kt create mode 100644 src/test/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderRepositoryImplTest.kt rename src/test/kotlin/io/billie/{functional => util}/data/Fixtures.kt (99%) rename src/test/kotlin/io/billie/{functional => util}/matcher/IsUUID.kt (94%) diff --git a/build.gradle.kts b/build.gradle.kts index 209c614..c11f576 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,8 +7,8 @@ plugins { id("org.flywaydb.flyway") version "9.3.1" id("org.springframework.boot") version "2.7.3" id("io.spring.dependency-management") version "1.0.14.RELEASE" - kotlin("jvm") version "1.5.0" - kotlin("plugin.spring") version "1.5.0" + kotlin("jvm") version "1.8.22" + kotlin("plugin.spring") version "1.8.22" application distribution } @@ -27,20 +27,29 @@ repositories { } dependencies { + // Spring Boot Starters implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springdoc:springdoc-openapi-data-rest:1.6.11") implementation("org.springdoc:springdoc-openapi-ui:1.6.11") implementation("org.springdoc:springdoc-openapi-kotlin:1.6.11") + // Kotlin implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + // Database runtimeOnly("org.postgresql:postgresql") + // Test Dependencies testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") + testImplementation("com.h2database:h2") + testImplementation("org.mockito:mockito-inline:4.+") } flyway { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f371643..3994438 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew.bat b/gradlew.bat index 107acd3..ac1b06f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,89 +1,89 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/kotlin/io/billie/Application.kt b/src/main/kotlin/io/billie/Application.kt index 16ec860..6899850 100644 --- a/src/main/kotlin/io/billie/Application.kt +++ b/src/main/kotlin/io/billie/Application.kt @@ -1,9 +1,12 @@ package io.billie import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.domain.EntityScan import org.springframework.boot.runApplication +import org.springframework.context.annotation.ComponentScan @SpringBootApplication +@ComponentScan(basePackages = ["io.billie.shipment"]) class Application fun main(args: Array) { diff --git a/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt b/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt index b108a1f..93956ee 100644 --- a/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt +++ b/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt @@ -8,11 +8,9 @@ import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses -import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus.BAD_REQUEST import org.springframework.web.bind.annotation.* import org.springframework.web.server.ResponseStatusException -import java.util.* import javax.validation.Valid diff --git a/src/main/kotlin/io/billie/shipment/application/command/CreateShipmentCommand.kt b/src/main/kotlin/io/billie/shipment/application/command/CreateShipmentCommand.kt new file mode 100644 index 0000000..052728c --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/application/command/CreateShipmentCommand.kt @@ -0,0 +1,12 @@ +package io.billie.shipment.application.command + +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId + +data class CreateShipmentCommand( + val orderId: OrderId, + val merchantId: MerchantId, + val amount: Money, + val trackingNumber: String? = null +) diff --git a/src/main/kotlin/io/billie/shipment/application/dto/CreateShipmentRequest.kt b/src/main/kotlin/io/billie/shipment/application/dto/CreateShipmentRequest.kt new file mode 100644 index 0000000..388d8da --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/application/dto/CreateShipmentRequest.kt @@ -0,0 +1,28 @@ +package io.billie.shipment.application.dto + +import java.math.BigDecimal +import javax.validation.constraints.DecimalMin +import javax.validation.constraints.Digits +import javax.validation.constraints.NotBlank +import javax.validation.constraints.NotNull +import javax.validation.constraints.Pattern +import javax.validation.constraints.Size + +data class CreateShipmentRequest( + @field:NotBlank(message = "Order ID cannot be blank") + @field:Size(max = 100, message = "Order ID cannot exceed 100 characters") + val orderId: String, + + @field:NotNull(message = "Amount is required") + @field:DecimalMin(value = "0.01", message = "Amount must be positive") + @field:Digits(integer = 10, fraction = 2, message = "Invalid amount format") + val amount: BigDecimal, + + @field:NotBlank(message = "Currency is required") + @field:Size(min = 3, max = 3, message = "Currency must be 3 characters (ISO 4217)") + @field:Pattern(regexp = "[A-Z]{3}", message = "Currency must be uppercase ISO 4217 code") + val currency: String, + + @field:Size(max = 255, message = "Tracking number cannot exceed 255 characters") + val trackingNumber: String? = null +) diff --git a/src/main/kotlin/io/billie/shipment/application/dto/CreateShipmentResponse.kt b/src/main/kotlin/io/billie/shipment/application/dto/CreateShipmentResponse.kt new file mode 100644 index 0000000..6185106 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/application/dto/CreateShipmentResponse.kt @@ -0,0 +1,15 @@ +package io.billie.shipment.application.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL +import java.math.BigDecimal + +@JsonInclude(NON_NULL) +data class CreateShipmentResponse( + val success: Boolean, + val shipmentId: String? = null, + val transactionId: String? = null, + val remainingAmount: BigDecimal? = null, + val currency: String? = null, + val errorMessage: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/application/result/ShipmentResult.kt b/src/main/kotlin/io/billie/shipment/application/result/ShipmentResult.kt new file mode 100644 index 0000000..98ba872 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/application/result/ShipmentResult.kt @@ -0,0 +1,11 @@ +package io.billie.shipment.application.result + +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import io.billie.shipment.domain.service.PaymentResult + +data class ShipmentResult( + val shipmentId: ShipmentId, + val paymentResult: PaymentResult, + val remainingAmount: Money +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/events/DomainEvent.kt b/src/main/kotlin/io/billie/shipment/domain/events/DomainEvent.kt new file mode 100644 index 0000000..e2db777 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/events/DomainEvent.kt @@ -0,0 +1,8 @@ +package io.billie.shipment.domain.events + +import java.time.Instant + +sealed class DomainEvent { + abstract val occurredAt: Instant + abstract val aggregateId: String +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/events/OrderFullyShippedEvent.kt b/src/main/kotlin/io/billie/shipment/domain/events/OrderFullyShippedEvent.kt new file mode 100644 index 0000000..8b5a6df --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/events/OrderFullyShippedEvent.kt @@ -0,0 +1,12 @@ +package io.billie.shipment.domain.events + +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.OrderId +import java.time.Instant + +data class OrderFullyShippedEvent( + override val aggregateId: String, + val orderId: OrderId, + val merchantId: MerchantId, + override val occurredAt: Instant = Instant.now() +) : DomainEvent() \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/events/ShipmentCreatedEvent.kt b/src/main/kotlin/io/billie/shipment/domain/events/ShipmentCreatedEvent.kt new file mode 100644 index 0000000..8ff5847 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/events/ShipmentCreatedEvent.kt @@ -0,0 +1,16 @@ +package io.billie.shipment.domain.events + +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import java.time.Instant + +data class ShipmentCreatedEvent( + override val aggregateId: String, + val shipmentId: ShipmentId, + val orderId: OrderId, + val merchantId: MerchantId, + val amount: Money, + override val occurredAt: Instant = Instant.now() +) : DomainEvent() diff --git a/src/main/kotlin/io/billie/shipment/domain/exceptions/InsufficientOrderAmountException.kt b/src/main/kotlin/io/billie/shipment/domain/exceptions/InsufficientOrderAmountException.kt new file mode 100644 index 0000000..dacd40d --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/exceptions/InsufficientOrderAmountException.kt @@ -0,0 +1,4 @@ +package io.billie.shipment.domain.exceptions + +class InsufficientOrderAmountException(message: String) : + RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/exceptions/OrderNotFoundException.kt b/src/main/kotlin/io/billie/shipment/domain/exceptions/OrderNotFoundException.kt new file mode 100644 index 0000000..692bc35 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/exceptions/OrderNotFoundException.kt @@ -0,0 +1,6 @@ +package io.billie.shipment.domain.exceptions + +import io.billie.shipment.domain.model.valueobjects.OrderId + +class OrderNotFoundException(orderId: OrderId) : + RuntimeException("Order not found: ${orderId.value}") \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/exceptions/UnauthorizedMerchantException.kt b/src/main/kotlin/io/billie/shipment/domain/exceptions/UnauthorizedMerchantException.kt new file mode 100644 index 0000000..be6d853 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/exceptions/UnauthorizedMerchantException.kt @@ -0,0 +1,7 @@ +package io.billie.shipment.domain.exceptions + +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.OrderId + +class UnauthorizedMerchantException(merchantId: MerchantId, orderId: OrderId) : + RuntimeException("Merchant ${merchantId.value} not authorized for order ${orderId.value}") diff --git a/src/main/kotlin/io/billie/shipment/domain/model/Order.kt b/src/main/kotlin/io/billie/shipment/domain/model/Order.kt new file mode 100644 index 0000000..8a51f6e --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/model/Order.kt @@ -0,0 +1,56 @@ +package io.billie.shipment.domain.model + +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import java.math.BigDecimal +import java.time.Instant + +data class Order( + val id: OrderId, + val merchantId: MerchantId, + val totalAmount: Money, + val status: OrderStatus, + val createdAt: Instant, + val shipments: MutableList = mutableListOf() +) { + fun getTotalShippedAmount(): Money { + if (shipments.isEmpty()) { + return Money(BigDecimal.ZERO, totalAmount.currency) + } + return shipments + .map { it.amount } + .reduce { acc, amount -> acc + amount } + } + + fun getRemainingAmount(): Money { + return totalAmount - getTotalShippedAmount() + } + + fun canAcceptShipment(shipmentAmount: Money): Boolean { + require(shipmentAmount.currency == totalAmount.currency) { + "Shipment currency must match order currency" + } + return getTotalShippedAmount() + shipmentAmount <= totalAmount + } + + fun addShipment(shipment: Shipment): Order { + require(canAcceptShipment(shipment.amount)) { + "Shipment amount would exceed order total" + } + require(shipment.orderId == this.id) { + "Shipment must belong to this order" + } + + shipments.add(shipment) + val newStatus = when { + getRemainingAmount().isZero() -> OrderStatus.FULLY_SHIPPED + getRemainingAmount().isGreaterThanZero() -> OrderStatus.PARTIALLY_SHIPPED + else -> status + } + + return copy(shipments = shipments, status = newStatus) + } + + fun isFullyShipped(): Boolean = status == OrderStatus.FULLY_SHIPPED +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/model/OrderStatus.kt b/src/main/kotlin/io/billie/shipment/domain/model/OrderStatus.kt new file mode 100644 index 0000000..b94cd05 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/model/OrderStatus.kt @@ -0,0 +1,8 @@ +package io.billie.shipment.domain.model + +enum class OrderStatus { + PENDING, + PARTIALLY_SHIPPED, + FULLY_SHIPPED, + CANCELLED +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/model/Shipment.kt b/src/main/kotlin/io/billie/shipment/domain/model/Shipment.kt new file mode 100644 index 0000000..2b61d78 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/model/Shipment.kt @@ -0,0 +1,19 @@ +package io.billie.shipment.domain.model + +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import java.math.BigDecimal +import java.time.Instant + +data class Shipment( + val id: ShipmentId, + val orderId: OrderId, + val amount: Money, + val shippedAt: Instant, + val trackingNumber: String? = null +) { + init { + require(amount.amount > BigDecimal.ZERO) { "Shipment amount must be positive" } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/model/ShipmentItem.kt b/src/main/kotlin/io/billie/shipment/domain/model/ShipmentItem.kt new file mode 100644 index 0000000..79201cd --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/model/ShipmentItem.kt @@ -0,0 +1,9 @@ +package io.billie.shipment.domain.model + +import io.billie.shipment.domain.model.valueobjects.Money + +data class ShipmentItem( + val name: String, + val quantity: Int, + val unitPrice: Money +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/MerchantId.kt b/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/MerchantId.kt new file mode 100644 index 0000000..8018c53 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/MerchantId.kt @@ -0,0 +1,8 @@ +package io.billie.shipment.domain.model.valueobjects + +data class MerchantId(val value: String) { + init { + require(value.isNotBlank()) { "Merchant ID cannot be blank" } + require(value.length <= 100) { "Merchant ID cannot exceed 100 characters" } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/Money.kt b/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/Money.kt new file mode 100644 index 0000000..350c394 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/Money.kt @@ -0,0 +1,31 @@ +package io.billie.shipment.domain.model.valueobjects + +import java.math.BigDecimal + +data class Money(val amount: BigDecimal, val currency: String) { + init { + require(amount >= BigDecimal.ZERO) { "Amount cannot be negative" } + require(currency.isNotBlank()) { "Currency cannot be blank" } + require(currency.length == 3) { "Currency must be 3 characters (ISO 4217)" } + } + + operator fun plus(other: Money): Money { + require(this.currency == other.currency) { "Cannot add different currencies" } + return Money(this.amount + other.amount, this.currency) + } + + operator fun minus(other: Money): Money { + require(this.currency == other.currency) { "Cannot subtract different currencies" } + require(this.amount >= other.amount) { "Cannot have negative result" } + return Money(this.amount - other.amount, this.currency) + } + + operator fun compareTo(other: Money): Int { + require(this.currency == other.currency) { "Cannot compare different currencies" } + return this.amount.compareTo(other.amount) + } + + fun isZero(): Boolean = amount.compareTo(BigDecimal.ZERO) == 0 + + fun isGreaterThanZero(): Boolean = amount >= BigDecimal.ZERO +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/OrderId.kt b/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/OrderId.kt new file mode 100644 index 0000000..13bc07a --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/OrderId.kt @@ -0,0 +1,8 @@ +package io.billie.shipment.domain.model.valueobjects + +data class OrderId(val value: String) { + init { + require(value.isNotBlank()) { "Order ID cannot be blank" } + require(value.length <= 100) { "Order ID cannot exceed 100 characters" } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/ShipmentId.kt b/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/ShipmentId.kt new file mode 100644 index 0000000..3ffeeb6 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/model/valueobjects/ShipmentId.kt @@ -0,0 +1,8 @@ +package io.billie.shipment.domain.model.valueobjects + +data class ShipmentId(val value: String) { + init { + require(value.isNotBlank()) { "Shipment ID cannot be blank" } + require(value.length <= 100) { "Shipment ID cannot exceed 100 characters" } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/domain/repository/OrderRepository.kt b/src/main/kotlin/io/billie/shipment/domain/repository/OrderRepository.kt new file mode 100644 index 0000000..1012309 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/repository/OrderRepository.kt @@ -0,0 +1,9 @@ +package io.billie.shipment.domain.repository + +import io.billie.shipment.domain.model.Order +import io.billie.shipment.domain.model.valueobjects.OrderId + +interface OrderRepository { + fun findById(orderId: OrderId): Order? + fun save(order: Order): Order +} diff --git a/src/main/kotlin/io/billie/shipment/domain/service/EventPublisher.kt b/src/main/kotlin/io/billie/shipment/domain/service/EventPublisher.kt new file mode 100644 index 0000000..27cd85e --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/service/EventPublisher.kt @@ -0,0 +1,7 @@ +package io.billie.shipment.domain.service + +import io.billie.shipment.domain.events.DomainEvent + +interface EventPublisher { + fun publish(event: DomainEvent) +} diff --git a/src/main/kotlin/io/billie/shipment/domain/service/PaymentResult.kt b/src/main/kotlin/io/billie/shipment/domain/service/PaymentResult.kt new file mode 100644 index 0000000..23427ae --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/service/PaymentResult.kt @@ -0,0 +1,7 @@ +package io.billie.shipment.domain.service + +data class PaymentResult( + val success: Boolean, + val transactionId: String? = null, + val errorMessage: String? = null +) diff --git a/src/main/kotlin/io/billie/shipment/domain/service/PaymentService.kt b/src/main/kotlin/io/billie/shipment/domain/service/PaymentService.kt new file mode 100644 index 0000000..23dbf61 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/service/PaymentService.kt @@ -0,0 +1,8 @@ +package io.billie.shipment.domain.service + +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money + +interface PaymentService { + fun processPayment(merchantId: MerchantId, amount: Money): PaymentResult +} diff --git a/src/main/kotlin/io/billie/shipment/domain/service/ShipmentService.kt b/src/main/kotlin/io/billie/shipment/domain/service/ShipmentService.kt new file mode 100644 index 0000000..6115786 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/domain/service/ShipmentService.kt @@ -0,0 +1,79 @@ +package io.billie.shipment.domain.service + +import io.billie.shipment.application.command.CreateShipmentCommand +import io.billie.shipment.application.result.ShipmentResult +import io.billie.shipment.domain.events.OrderFullyShippedEvent +import io.billie.shipment.domain.events.ShipmentCreatedEvent +import io.billie.shipment.domain.exceptions.InsufficientOrderAmountException +import io.billie.shipment.domain.exceptions.OrderNotFoundException +import io.billie.shipment.domain.exceptions.UnauthorizedMerchantException +import io.billie.shipment.domain.model.Shipment +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import io.billie.shipment.domain.repository.OrderRepository +import org.springframework.stereotype.Service +import java.time.Instant +import java.util.UUID + +class ShipmentService( + private val orderRepository: OrderRepository, + private val eventPublisher: EventPublisher, + private val paymentService: PaymentService +) { + fun createShipment(command: CreateShipmentCommand): ShipmentResult { + val order = orderRepository.findById(command.orderId) + ?: throw OrderNotFoundException(command.orderId) + + if (order.merchantId != command.merchantId) { + throw UnauthorizedMerchantException(command.merchantId, command.orderId) + } + + if (!order.canAcceptShipment(command.amount)) { + val remaining = order.getRemainingAmount() + throw InsufficientOrderAmountException( + "Shipment amount ${command.amount.amount} exceeds remaining order amount ${remaining.amount}" + ) + } + + val shipmentId = ShipmentId(UUID.randomUUID().toString()) + val shipment = Shipment( + id = shipmentId, + orderId = command.orderId, + amount = command.amount, + shippedAt = Instant.now(), + trackingNumber = command.trackingNumber + ) + + val updatedOrder = order.addShipment(shipment) + orderRepository.save(updatedOrder) + + // Process payment to merchant + val paymentResult = paymentService.processPayment(command.merchantId, command.amount) + + // Publish events + eventPublisher.publish( + ShipmentCreatedEvent( + aggregateId = shipment.id.value, + shipmentId = shipment.id, + orderId = command.orderId, + merchantId = command.merchantId, + amount = command.amount + ) + ) + + if (updatedOrder.isFullyShipped()) { + eventPublisher.publish( + OrderFullyShippedEvent( + aggregateId = updatedOrder.id.value, + orderId = updatedOrder.id, + merchantId = updatedOrder.merchantId + ) + ) + } + + return ShipmentResult( + shipmentId = shipment.id, + paymentResult = paymentResult, + remainingAmount = updatedOrder.getRemainingAmount() + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/config/ApplicationConfiguration.kt b/src/main/kotlin/io/billie/shipment/infrastructure/config/ApplicationConfiguration.kt new file mode 100644 index 0000000..803cbf7 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/config/ApplicationConfiguration.kt @@ -0,0 +1,21 @@ +package io.billie.shipment.infrastructure.config + +import io.billie.shipment.domain.repository.OrderRepository +import io.billie.shipment.domain.service.EventPublisher +import io.billie.shipment.domain.service.PaymentService +import io.billie.shipment.domain.service.ShipmentService +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ApplicationConfiguration { + + @Bean + fun shipmentService( + orderRepository: OrderRepository, + eventPublisher: EventPublisher, + paymentService: PaymentService + ): ShipmentService { + return ShipmentService(orderRepository, eventPublisher, paymentService) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/config/InfrastructureConfiguration.kt b/src/main/kotlin/io/billie/shipment/infrastructure/config/InfrastructureConfiguration.kt new file mode 100644 index 0000000..f117221 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/config/InfrastructureConfiguration.kt @@ -0,0 +1,26 @@ +package io.billie.shipment.infrastructure.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean +import javax.validation.Validator + +@Configuration +class InfrastructureConfiguration { + + @Bean + fun objectMapper(): ObjectMapper { + return ObjectMapper().apply { + registerModule(KotlinModule.Builder().build()) + registerModule(JavaTimeModule()) + } + } + + @Bean + fun validator(): Validator { + return LocalValidatorFactoryBean() + } +} diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/events/ShipmentEventPublisher.kt b/src/main/kotlin/io/billie/shipment/infrastructure/events/ShipmentEventPublisher.kt new file mode 100644 index 0000000..e29ce11 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/events/ShipmentEventPublisher.kt @@ -0,0 +1,34 @@ +package io.billie.shipment.infrastructure.events + +import com.fasterxml.jackson.databind.ObjectMapper +import io.billie.shipment.domain.events.DomainEvent +import io.billie.shipment.domain.service.EventPublisher +import org.slf4j.LoggerFactory +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class ShipmentEventPublisher( + private val applicationEventPublisher: ApplicationEventPublisher, + private val objectMapper: ObjectMapper +) : EventPublisher { + + private val logger = LoggerFactory.getLogger(ShipmentEventPublisher::class.java) + + override fun publish(event: DomainEvent) { + try { + logger.info("Publishing domain event: ${event::class.simpleName} for aggregate: ${event.aggregateId}") + + // Publish to Spring's event system + applicationEventPublisher.publishEvent(event) + + // Log event details for monitoring/debugging + val eventJson = objectMapper.writeValueAsString(event) + logger.debug("Event details: $eventJson") + + } catch (e: Exception) { + logger.error("Failed to publish domain event: ${event::class.simpleName}", e) + // Don't rethrow - event publishing failures shouldn't break the main flow + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/payment/MockPaymentService.kt b/src/main/kotlin/io/billie/shipment/infrastructure/payment/MockPaymentService.kt new file mode 100644 index 0000000..146e689 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/payment/MockPaymentService.kt @@ -0,0 +1,47 @@ +package io.billie.shipment.infrastructure.payment + +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.service.PaymentResult +import io.billie.shipment.domain.service.PaymentService +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.Random +import java.util.UUID + +@Service +class MockPaymentService( + @Value("\${payment.mock.failure-rate:0.1}") private val failureRate: Double = 0.1 +) : PaymentService { + + private val logger = LoggerFactory.getLogger(MockPaymentService::class.java) + private val random = Random() + + override fun processPayment(merchantId: MerchantId, amount: Money): PaymentResult { + logger.info("Processing payment for merchant: ${merchantId.value}, amount: ${amount.amount} ${amount.currency}") + + // Simulate processing time + Thread.sleep(100) + + // Simulate random failures based on configured failure rate + val shouldFail = random.nextDouble() < failureRate + + return if (shouldFail) { + logger.warn("Payment failed for merchant: ${merchantId.value}") + PaymentResult( + success = false, + transactionId = null, + errorMessage = "Payment processing failed - insufficient funds or invalid payment method" + ) + } else { + val transactionId = "TXN-${UUID.randomUUID().toString().substring(0, 8).uppercase()}" + logger.info("Payment successful for merchant: ${merchantId.value}, transaction: $transactionId") + PaymentResult( + success = true, + transactionId = transactionId, + errorMessage = null + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/persistence/entity/OrderEntity.kt b/src/main/kotlin/io/billie/shipment/infrastructure/persistence/entity/OrderEntity.kt new file mode 100644 index 0000000..7014541 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/persistence/entity/OrderEntity.kt @@ -0,0 +1,45 @@ +package io.billie.shipment.infrastructure.persistence.entity + +import io.billie.shipment.domain.model.OrderStatus +import java.math.BigDecimal +import java.time.Instant +import javax.persistence.* + +@Entity +@Table(name = "orders") +class OrderEntity( + + @Id + @Column(name = "id", nullable = false, length = 100) + var id: String = "", + + @Column(name = "merchant_id", nullable = false, length = 100) + var merchantId: String = "", + + @Column(name = "total_amount", nullable = false, precision = 19, scale = 2) + var totalAmount: BigDecimal = BigDecimal.ZERO, + + @Column(name = "currency", nullable = false, length = 3) + var currency: String = "", + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: OrderStatus = OrderStatus.PENDING, + + @Column(name = "created_at", nullable = false) + var createdAt: Instant = Instant.now(), + + @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) + var shipments: MutableList = mutableListOf() + +) { + constructor() : this( + id = "", + merchantId = "", + totalAmount = BigDecimal.ZERO, + currency = "", + status = OrderStatus.PENDING, + createdAt = Instant.now(), + shipments = mutableListOf() + ) +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/persistence/entity/ShipmentEntity.kt b/src/main/kotlin/io/billie/shipment/infrastructure/persistence/entity/ShipmentEntity.kt new file mode 100644 index 0000000..660742d --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/persistence/entity/ShipmentEntity.kt @@ -0,0 +1,45 @@ +package io.billie.shipment.infrastructure.persistence.entity + +import io.billie.shipment.domain.model.OrderStatus +import java.math.BigDecimal +import java.time.Instant +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.Id +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "shipments") +class ShipmentEntity( + @Id + @Column(name = "id", nullable = false, length = 100) + var id: String = "", + + @Column(name = "amount", nullable = false, precision = 19, scale = 2) + var amount: BigDecimal = BigDecimal.ZERO, + + @Column(name = "currency", nullable = false, length = 3) + var currency: String = "", + + @Column(name = "shipped_at", nullable = false) + var shippedAt: Instant = Instant.now(), + + @Column(name = "tracking_number", length = 255) + var trackingNumber: String? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + var order: OrderEntity = OrderEntity() +) { + constructor() : this( + id = "", + amount = BigDecimal.ZERO, + currency = "", + shippedAt = Instant.now(), + trackingNumber = "", + order = OrderEntity() + ) +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderJpaRepository.kt b/src/main/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderJpaRepository.kt new file mode 100644 index 0000000..3406db6 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderJpaRepository.kt @@ -0,0 +1,6 @@ +package io.billie.shipment.infrastructure.persistence.repository + +import io.billie.shipment.infrastructure.persistence.entity.OrderEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface OrderJpaRepository : JpaRepository \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderRepositoryImpl.kt b/src/main/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderRepositoryImpl.kt new file mode 100644 index 0000000..0864e17 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderRepositoryImpl.kt @@ -0,0 +1,76 @@ +package io.billie.shipment.infrastructure.persistence.repository + +import io.billie.shipment.domain.model.Order +import io.billie.shipment.domain.model.Shipment +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import io.billie.shipment.domain.repository.OrderRepository +import io.billie.shipment.infrastructure.persistence.entity.OrderEntity +import io.billie.shipment.infrastructure.persistence.entity.ShipmentEntity +import org.springframework.stereotype.Repository + +@Repository +class OrderRepositoryImpl( + private val jpaRepository: OrderJpaRepository +) : OrderRepository { + override fun findById(orderId: OrderId): Order? { + return jpaRepository.findById(orderId.value) + .map { it.toDomainModel() } + .orElse(null) + } + + override fun save(order: Order): Order { + val entity = order.toEntity() + val savedEntity = jpaRepository.save(entity) + return savedEntity.toDomainModel() + } +} + +private fun OrderEntity.toDomainModel(): Order { + return Order( + id = OrderId(this.id), + merchantId = MerchantId(this.merchantId), + totalAmount = Money(this.totalAmount, this.currency), + status = this.status, + createdAt = this.createdAt, + shipments = this.shipments.map { it.toDomainModel() }.toMutableList() + ) +} + +private fun ShipmentEntity.toDomainModel(): Shipment { + return Shipment( + id = ShipmentId(this.id), + orderId = OrderId(this.order.id), + amount = Money(this.amount, this.currency), + shippedAt = this.shippedAt, + trackingNumber = this.trackingNumber + ) +} + +private fun Order.toEntity(): OrderEntity { + val orderEntity = OrderEntity( + id = this.id.value, + merchantId = this.merchantId.value, + totalAmount = this.totalAmount.amount, + currency = this.totalAmount.currency, + status = this.status, + createdAt = this.createdAt + ) + + // Add shipments + this.shipments.forEach { shipment -> + val shipmentEntity = ShipmentEntity( + id = shipment.id.value, + amount = shipment.amount.amount, + currency = shipment.amount.currency, + shippedAt = shipment.shippedAt, + trackingNumber = shipment.trackingNumber, + order = orderEntity + ) + orderEntity.shipments.add(shipmentEntity) + } + + return orderEntity +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/web/GlobalExceptionHandler.kt b/src/main/kotlin/io/billie/shipment/infrastructure/web/GlobalExceptionHandler.kt new file mode 100644 index 0000000..a1c5810 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/web/GlobalExceptionHandler.kt @@ -0,0 +1,84 @@ +package io.billie.shipment.infrastructure.web + +import io.billie.shipment.application.dto.CreateShipmentResponse +import io.billie.shipment.domain.exceptions.InsufficientOrderAmountException +import io.billie.shipment.domain.exceptions.OrderNotFoundException +import io.billie.shipment.domain.exceptions.UnauthorizedMerchantException +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.FORBIDDEN +import org.springframework.http.HttpStatus.NOT_FOUND +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + + private val logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + + @ExceptionHandler(OrderNotFoundException::class) + fun handleOrderNotFound(e: OrderNotFoundException): ResponseEntity { + logger.warn("Order not found: ${e.message}") + return ResponseEntity.status(NOT_FOUND) + .body(CreateShipmentResponse( + success = false, + errorMessage = e.message + )) + } + + @ExceptionHandler(UnauthorizedMerchantException::class) + fun handleUnauthorizedMerchant(e: UnauthorizedMerchantException): ResponseEntity { + logger.warn("Unauthorized merchant access: ${e.message}") + return ResponseEntity.status(FORBIDDEN) + .body(CreateShipmentResponse( + success = false, + errorMessage = e.message + )) + } + + @ExceptionHandler(InsufficientOrderAmountException::class) + fun handleInsufficientOrderAmount(e: InsufficientOrderAmountException): ResponseEntity { + logger.warn("Insufficient order amount: ${e.message}") + return ResponseEntity.status(BAD_REQUEST) + .body(CreateShipmentResponse( + success = false, + errorMessage = e.message + )) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationErrors(e: MethodArgumentNotValidException): ResponseEntity { + val errors = e.bindingResult.fieldErrors + .joinToString("; ") { "${it.field}: ${it.defaultMessage}" } + + logger.warn("Validation errors: $errors") + return ResponseEntity.status(BAD_REQUEST) + .body(CreateShipmentResponse( + success = false, + errorMessage = "Validation failed: $errors" + )) + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgument(e: IllegalArgumentException): ResponseEntity { + logger.warn("Invalid argument: ${e.message}") + return ResponseEntity.status(BAD_REQUEST) + .body(CreateShipmentResponse( + success = false, + errorMessage = e.message ?: "Invalid request" + )) + } + + @ExceptionHandler(Exception::class) + fun handleGenericException(e: Exception): ResponseEntity { + logger.error("Unexpected error occurred", e) + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(CreateShipmentResponse( + success = false, + errorMessage = "Internal server error" + )) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/shipment/infrastructure/web/ShipmentController.kt b/src/main/kotlin/io/billie/shipment/infrastructure/web/ShipmentController.kt new file mode 100644 index 0000000..9f28aa0 --- /dev/null +++ b/src/main/kotlin/io/billie/shipment/infrastructure/web/ShipmentController.kt @@ -0,0 +1,82 @@ +package io.billie.shipment.infrastructure.web + +import io.billie.shipment.application.command.CreateShipmentCommand +import io.billie.shipment.application.dto.CreateShipmentRequest +import io.billie.shipment.application.dto.CreateShipmentResponse +import io.billie.shipment.domain.exceptions.InsufficientOrderAmountException +import io.billie.shipment.domain.exceptions.OrderNotFoundException +import io.billie.shipment.domain.exceptions.UnauthorizedMerchantException +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.service.ShipmentService +import org.springframework.http.HttpStatus.* +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* +import javax.validation.Valid + +@RestController +@RequestMapping("/api/v1/merchants/{merchantId}/orders/{orderId}/shipments") +@Validated +class ShipmentController( + private val shipmentService: ShipmentService +) { + + @PostMapping + fun createShipment( + @PathVariable merchantId: String, + @PathVariable orderId: String, + @Valid @RequestBody request: CreateShipmentRequest + ): ResponseEntity { + return try { + val command = CreateShipmentCommand( + orderId = OrderId(orderId), + merchantId = MerchantId(merchantId), + amount = Money(request.amount, request.currency), + trackingNumber = request.trackingNumber + ) + + val result = shipmentService.createShipment(command) + + val response = CreateShipmentResponse( + success = true, + shipmentId = result.shipmentId.value, + transactionId = result.paymentResult.transactionId, + remainingAmount = result.remainingAmount.amount, + currency = result.remainingAmount.currency, + errorMessage = result.paymentResult.errorMessage + ) + + ResponseEntity.ok(response) + + } catch (e: OrderNotFoundException) { + ResponseEntity.status(NOT_FOUND) + .body(CreateShipmentResponse( + success = false, + errorMessage = e.message ?: "Order not found" + )) + + } catch (e: UnauthorizedMerchantException) { + ResponseEntity.status(FORBIDDEN) + .body(CreateShipmentResponse( + success = false, + errorMessage = e.message ?: "Unauthorized merchant" + )) + + } catch (e: InsufficientOrderAmountException) { + ResponseEntity.status(BAD_REQUEST) + .body(CreateShipmentResponse( + success = false, + errorMessage = e.message ?: "Insufficient order amount" + )) + + } catch (e: Exception) { + ResponseEntity.status(INTERNAL_SERVER_ERROR) + .body(CreateShipmentResponse( + success = false, + errorMessage = "Internal server error" + )) + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 09b6bce..5e7b7ae 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,3 +5,7 @@ spring.datasource.username=${POSTGRES_USER} spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.schema=${DATABASE_SCHEMA} spring.datasource.initialization-mode=always +spring.jpa.hibernate.ddl-auto=create +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true diff --git a/src/main/resources/db/migration/V10__add_shipments_table.sql b/src/main/resources/db/migration/V10__add_shipments_table.sql new file mode 100644 index 0000000..b7ecd75 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_shipments_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.shipments ( + id VARCHAR(100) PRIMARY KEY, + order_id VARCHAR(100) NOT NULL, + amount DECIMAL(19,4) NOT NULL, + currency VARCHAR(3) NOT NULL, + shipped_at TIMESTAMP NOT NULL, + tracking_number VARCHAR(255), + FOREIGN KEY (order_id) REFERENCES orders(id) +); + +CREATE INDEX idx_shipments_order_id ON shipments(order_id); +CREATE INDEX idx_shipments_shipped_at ON shipments(shipped_at); \ No newline at end of file diff --git a/src/main/resources/db/migration/V11__Add_orders_data.sql b/src/main/resources/db/migration/V11__Add_orders_data.sql new file mode 100644 index 0000000..d4c2336 --- /dev/null +++ b/src/main/resources/db/migration/V11__Add_orders_data.sql @@ -0,0 +1,6 @@ +INSERT INTO organisations_schema.orders (id, merchant_id, total_amount, currency, status, created_at) VALUES +('ORDER-001', 'MERCHANT-DEMO', 150.00, 'EUR', 'PENDING', NOW()); +INSERT INTO organisations_schema.orders (id, merchant_id, total_amount, currency, status, created_at) VALUES +('ORDER-002', 'MERCHANT-DEMO', 75.50, 'EUR', 'PENDING', NOW()); +INSERT INTO organisations_schema.orders (id, merchant_id, total_amount, currency, status, created_at) VALUES +('ORDER-003', 'MERCHANT-TEST', 200.00, 'USD', 'PENDING', NOW()); \ No newline at end of file diff --git a/src/main/resources/db/migration/V9__add_orders_table.sql b/src/main/resources/db/migration/V9__add_orders_table.sql new file mode 100644 index 0000000..2043a91 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_orders_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.orders ( + id VARCHAR(100) PRIMARY KEY, + merchant_id VARCHAR(100) NOT NULL, + total_amount DECIMAL(19,2) NOT NULL, + currency VARCHAR(3) NOT NULL, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + + CONSTRAINT chk_orders_amount_positive CHECK (total_amount > 0), + CONSTRAINT chk_orders_currency_format CHECK (LENGTH(currency) = 3) +); + +CREATE INDEX idx_orders_merchant_id ON orders(merchant_id); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_orders_created_at ON orders(created_at); \ No newline at end of file diff --git a/src/test/kotlin/io/billie/countries/resource/CanReadLocationsTest.kt b/src/test/kotlin/io/billie/countries/resource/CanReadLocationsTest.kt new file mode 100644 index 0000000..3502d16 --- /dev/null +++ b/src/test/kotlin/io/billie/countries/resource/CanReadLocationsTest.kt @@ -0,0 +1,79 @@ +package io.billie.countries.resource + +import io.billie.util.matcher.IsUUID +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = RANDOM_PORT) +class CanReadLocationsTest { + + @LocalServerPort + private val port = 8080 + + @Autowired + private lateinit var mockMvc: MockMvc + + @Test + fun notFoundForUnknownCountry() { + mockMvc.perform( + MockMvcRequestBuilders.get("/countries/xx/cities") + .contentType(MediaType.APPLICATION_JSON) + ).andExpect(MockMvcResultMatchers.status().isNotFound) + } + + @Test + fun canViewZWCities() { + mockMvc.perform( + MockMvcRequestBuilders.get("/countries/zw/cities") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].name").value("Harare")) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].id").value(IsUUID.isUuid())) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].country_code").value("ZW")) + .andExpect(MockMvcResultMatchers.jsonPath("$.[25].name").value("Mazoe")) + .andExpect(MockMvcResultMatchers.jsonPath("$.[25].id").value(IsUUID.isUuid())) + .andExpect(MockMvcResultMatchers.jsonPath("$.[25].country_code").value("ZW")) + } + + @Test + fun canViewBECities() { + mockMvc.perform( + MockMvcRequestBuilders.get("/countries/be/cities") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].name").value("Brussels")) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].id").value(IsUUID.isUuid())) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].country_code").value("BE")) + .andExpect(MockMvcResultMatchers.jsonPath("$.size()").value(468)) + .andExpect(MockMvcResultMatchers.jsonPath("$.[467].name").value("Alveringem")) + .andExpect(MockMvcResultMatchers.jsonPath("$.[467].id").value(IsUUID.isUuid())) + .andExpect(MockMvcResultMatchers.jsonPath("$.[467].country_code").value("BE")) + } + + @Test + fun canViewCountries() { + mockMvc.perform( + MockMvcRequestBuilders.get("/countries") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].name").value("Andorra")) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].id").value(IsUUID.isUuid())) + .andExpect(MockMvcResultMatchers.jsonPath("$.[0].country_code").value("AD")) + .andExpect(MockMvcResultMatchers.jsonPath("$.[239].name").value("Zimbabwe")) + .andExpect(MockMvcResultMatchers.jsonPath("$.[239].id").value(IsUUID.isUuid())) + .andExpect(MockMvcResultMatchers.jsonPath("$.[239].country_code").value("ZW")) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/CanReadLocationsTest.kt b/src/test/kotlin/io/billie/functional/CanReadLocationsTest.kt deleted file mode 100644 index 91782d7..0000000 --- a/src/test/kotlin/io/billie/functional/CanReadLocationsTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package io.billie.functional - -import io.billie.functional.matcher.IsUUID.isUuid -import org.hamcrest.Description -import org.hamcrest.TypeSafeMatcher -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT -import org.springframework.boot.test.web.server.LocalServerPort -import org.springframework.http.MediaType.APPLICATION_JSON -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import java.util.* - - -@AutoConfigureMockMvc -@SpringBootTest(webEnvironment = DEFINED_PORT) -class CanReadLocationsTest { - - @LocalServerPort - private val port = 8080 - - @Autowired - private lateinit var mockMvc: MockMvc - - @Test - fun notFoundForUnknownCountry() { - mockMvc.perform( - get("/countries/xx/cities") - .contentType(APPLICATION_JSON) - ).andExpect(status().isNotFound) - } - - @Test - fun canViewZWCities() { - mockMvc.perform( - get("/countries/zw/cities") - .contentType(APPLICATION_JSON) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.[0].name").value("Harare")) - .andExpect(jsonPath("$.[0].id").value(isUuid())) - .andExpect(jsonPath("$.[0].country_code").value("ZW")) - .andExpect(jsonPath("$.[25].name").value("Mazoe")) - .andExpect(jsonPath("$.[25].id").value(isUuid())) - .andExpect(jsonPath("$.[25].country_code").value("ZW")) - } - - @Test - fun canViewBECities() { - mockMvc.perform( - get("/countries/be/cities") - .contentType(APPLICATION_JSON) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.[0].name").value("Brussels")) - .andExpect(jsonPath("$.[0].id").value(isUuid())) - .andExpect(jsonPath("$.[0].country_code").value("BE")) - .andExpect(jsonPath("$.size()").value(468)) - .andExpect(jsonPath("$.[467].name").value("Alveringem")) - .andExpect(jsonPath("$.[467].id").value(isUuid())) - .andExpect(jsonPath("$.[467].country_code").value("BE")) - } - - @Test - fun canViewCountries() { - mockMvc.perform( - get("/countries") - .contentType(APPLICATION_JSON) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.[0].name").value("Andorra")) - .andExpect(jsonPath("$.[0].id").value(isUuid())) - .andExpect(jsonPath("$.[0].country_code").value("AD")) - .andExpect(jsonPath("$.[239].name").value("Zimbabwe")) - .andExpect(jsonPath("$.[239].id").value(isUuid())) - .andExpect(jsonPath("$.[239].country_code").value("ZW")) - } - -} diff --git a/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt b/src/test/kotlin/io/billie/organisations/resource/CanStoreAndReadOrganisationTest.kt similarity index 53% rename from src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt rename to src/test/kotlin/io/billie/organisations/resource/CanStoreAndReadOrganisationTest.kt index 2d57630..985a768 100644 --- a/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt +++ b/src/test/kotlin/io/billie/organisations/resource/CanStoreAndReadOrganisationTest.kt @@ -1,36 +1,26 @@ -package io.billie.functional +package io.billie.organisations.resource import com.fasterxml.jackson.databind.ObjectMapper -import io.billie.functional.data.Fixtures.bbcContactFixture -import io.billie.functional.data.Fixtures.bbcFixture -import io.billie.functional.data.Fixtures.orgRequestJson -import io.billie.functional.data.Fixtures.orgRequestJsonCountryCodeBlank -import io.billie.functional.data.Fixtures.orgRequestJsonCountryCodeIncorrect -import io.billie.functional.data.Fixtures.orgRequestJsonNoName -import io.billie.functional.data.Fixtures.orgRequestJsonNameBlank -import io.billie.functional.data.Fixtures.orgRequestJsonNoContactDetails -import io.billie.functional.data.Fixtures.orgRequestJsonNoCountryCode -import io.billie.functional.data.Fixtures.orgRequestJsonNoLegalEntityType +import io.billie.util.data.Fixtures import io.billie.organisations.viewmodel.Entity -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.core.IsEqual.equalTo +import org.hamcrest.MatcherAssert +import org.hamcrest.core.IsEqual import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT import org.springframework.boot.test.web.server.LocalServerPort -import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import java.util.* - +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import java.util.UUID @AutoConfigureMockMvc -@SpringBootTest(webEnvironment = DEFINED_PORT) +@SpringBootTest(webEnvironment = RANDOM_PORT) class CanStoreAndReadOrganisationTest { @LocalServerPort @@ -48,89 +38,89 @@ class CanStoreAndReadOrganisationTest { @Test fun orgs() { mockMvc.perform( - get("/organisations") - .contentType(APPLICATION_JSON) + MockMvcRequestBuilders.get("/organisations") + .contentType(MediaType.APPLICATION_JSON) ) - .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.status().isOk()) } @Test fun cannotStoreOrgWhenNameIsBlank() { mockMvc.perform( - post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonNameBlank()) + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJsonNameBlank()) ) - .andExpect(status().isBadRequest) + .andExpect(MockMvcResultMatchers.status().isBadRequest) } @Test fun cannotStoreOrgWhenNameIsMissing() { mockMvc.perform( - post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonNoName()) + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJsonNoName()) ) - .andExpect(status().isBadRequest) + .andExpect(MockMvcResultMatchers.status().isBadRequest) } @Test fun cannotStoreOrgWhenCountryCodeIsMissing() { mockMvc.perform( - post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonNoCountryCode()) + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJsonNoCountryCode()) ) - .andExpect(status().isBadRequest) + .andExpect(MockMvcResultMatchers.status().isBadRequest) } @Test fun cannotStoreOrgWhenCountryCodeIsBlank() { mockMvc.perform( - post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonCountryCodeBlank()) + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJsonCountryCodeBlank()) ) - .andExpect(status().isBadRequest) + .andExpect(MockMvcResultMatchers.status().isBadRequest) } @Test fun cannotStoreOrgWhenCountryCodeIsNotRecognised() { mockMvc.perform( - post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonCountryCodeIncorrect()) + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJsonCountryCodeIncorrect()) ) - .andExpect(status().isBadRequest) + .andExpect(MockMvcResultMatchers.status().isBadRequest) } @Test fun cannotStoreOrgWhenNoLegalEntityType() { mockMvc.perform( - post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonNoLegalEntityType()) + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJsonNoLegalEntityType()) ) - .andExpect(status().isBadRequest) + .andExpect(MockMvcResultMatchers.status().isBadRequest) } @Test fun cannotStoreOrgWhenNoContactDetails() { mockMvc.perform( - post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonNoContactDetails()) + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJsonNoContactDetails()) ) - .andExpect(status().isBadRequest) + .andExpect(MockMvcResultMatchers.status().isBadRequest) } @Test fun canStoreOrg() { val result = mockMvc.perform( - post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJson()) + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJson()) ) - .andExpect(status().isOk) + .andExpect(MockMvcResultMatchers.status().isOk) .andReturn() val response = mapper.readValue(result.response.contentAsString, Entity::class.java) val org: Map = orgFromDatabase(response.id) - assertDataMatches(org, bbcFixture(response.id)) + assertDataMatches(org, Fixtures.bbcFixture(response.id)) val contactDetailsId: UUID = UUID.fromString(org["contact_details_id"] as String) val contactDetails: Map = contactDetailsFromDatabase(contactDetailsId) - assertDataMatches(contactDetails, bbcContactFixture(contactDetailsId)) + assertDataMatches(contactDetails, Fixtures.bbcContactFixture(contactDetailsId)) } fun assertDataMatches(reply: Map, assertions: Map) { for (key in assertions.keys) { - assertThat(reply[key], equalTo(assertions[key])) + MatcherAssert.assertThat(reply[key], IsEqual.equalTo(assertions[key])) } } @@ -143,4 +133,4 @@ class CanStoreAndReadOrganisationTest { private fun contactDetailsFromDatabase(id: UUID): MutableMap = queryEntityFromDatabase("select * from organisations_schema.contact_details where id = ?", id) -} +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/shipment/domain/model/OrderTest.kt b/src/test/kotlin/io/billie/shipment/domain/model/OrderTest.kt new file mode 100644 index 0000000..761e38a --- /dev/null +++ b/src/test/kotlin/io/billie/shipment/domain/model/OrderTest.kt @@ -0,0 +1,183 @@ +package io.billie.shipment.domain.model + +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal +import java.time.Instant + +@DisplayName("Order Domain Entity") +class OrderTest { + + private val orderId = OrderId("ORDER-123") + private val merchantId = MerchantId("MERCHANT-456") + private val totalAmount = Money(BigDecimal("100.00"), "EUR") + private val createdAt = Instant.now() + + private fun createOrder( + id: OrderId = orderId, + merchant: MerchantId = merchantId, + amount: Money = totalAmount, + status: OrderStatus = OrderStatus.PENDING, + shipments: MutableList = mutableListOf() + ) = Order(id, merchant, amount, status, createdAt, shipments) + + @Nested + @DisplayName("Order Creation") + inner class OrderCreation { + + @Test + fun `should create order with valid parameters`() { + val order = createOrder() + + assertEquals(orderId, order.id) + assertEquals(merchantId, order.merchantId) + assertEquals(totalAmount, order.totalAmount) + assertEquals(OrderStatus.PENDING, order.status) + assertTrue(order.shipments.isEmpty()) + } + } + + @Nested + @DisplayName("Shipment Amount Calculations") + inner class ShipmentAmountCalculations { + + @Test + fun `should return zero shipped amount for order with no shipments`() { + val order = createOrder() + + val shippedAmount = order.getTotalShippedAmount() + + assertTrue(shippedAmount.isZero()) + assertEquals("EUR", shippedAmount.currency) + } + + @Test + fun `should calculate total shipped amount correctly`() { + val shipment1 = createShipment(amount = Money(BigDecimal("30.00"), "EUR")) + val shipment2 = createShipment( + id = ShipmentId("SHIP-2"), + amount = Money(BigDecimal("20.00"), "EUR") + ) + val order = createOrder(shipments = mutableListOf(shipment1, shipment2)) + + val shippedAmount = order.getTotalShippedAmount() + + assertEquals(BigDecimal("50.00"), shippedAmount.amount) + } + + @Test + fun `should calculate remaining amount correctly`() { + val shipment = createShipment(amount = Money(BigDecimal("30.00"), "EUR")) + val order = createOrder(shipments = mutableListOf(shipment)) + + val remainingAmount = order.getRemainingAmount() + + assertEquals(BigDecimal("70.00"), remainingAmount.amount) + } + } + + @Nested + @DisplayName("Shipment Validation") + inner class ShipmentValidation { + + @Test + fun `should accept shipment when amount is within remaining total`() { + val order = createOrder() + val shipmentAmount = Money(BigDecimal("50.00"), "EUR") + + assertTrue(order.canAcceptShipment(shipmentAmount)) + } + + @Test + fun `should reject shipment when amount exceeds remaining total`() { + val order = createOrder() + val shipmentAmount = Money(BigDecimal("150.00"), "EUR") + + assertFalse(order.canAcceptShipment(shipmentAmount)) + } + + @Test + fun `should reject shipment with different currency`() { + val order = createOrder() + val shipmentAmount = Money(BigDecimal("50.00"), "USD") + + assertThrows { + order.canAcceptShipment(shipmentAmount) + } + } + + @Test + fun `should accept shipment for exact remaining amount`() { + val existingShipment = createShipment(amount = Money(BigDecimal("30.00"), "EUR")) + val order = createOrder(shipments = mutableListOf(existingShipment)) + val shipmentAmount = Money(BigDecimal("70.00"), "EUR") + + assertTrue(order.canAcceptShipment(shipmentAmount)) + } + } + + @Nested + @DisplayName("Adding Shipments") + inner class AddingShipments { + + @Test + fun `should add shipment successfully`() { + val order = createOrder() + val shipment = createShipment(amount = Money(BigDecimal("50.00"), "EUR")) + + val updatedOrder = order.addShipment(shipment) + + assertEquals(1, updatedOrder.shipments.size) + assertEquals(shipment, updatedOrder.shipments.first()) + assertEquals(OrderStatus.PARTIALLY_SHIPPED, updatedOrder.status) + } + + @Test + fun `should update status to fully shipped when order complete`() { + val order = createOrder() + val shipment = createShipment(amount = Money(BigDecimal("100.00"), "EUR")) + + val updatedOrder = order.addShipment(shipment) + + assertEquals(OrderStatus.FULLY_SHIPPED, updatedOrder.status) + assertTrue(updatedOrder.isFullyShipped()) + } + + @Test + fun `should reject shipment that exceeds order total`() { + val order = createOrder() + val shipment = createShipment(amount = Money(BigDecimal("150.00"), "EUR")) + + assertThrows { + order.addShipment(shipment) + } + } + + @Test + fun `should reject shipment with mismatched order ID`() { + val order = createOrder() + val shipment = createShipment( + orderId = OrderId("DIFFERENT-ORDER"), + amount = Money(BigDecimal("50.00"), "EUR") + ) + + assertThrows { + order.addShipment(shipment) + } + } + } + + private fun createShipment( + id: ShipmentId = ShipmentId("SHIP-1"), + orderId: OrderId = this.orderId, + amount: Money, + shippedAt: Instant = Instant.now() + ) = Shipment(id, orderId, amount, shippedAt) +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/shipment/domain/model/ShipmentTest.kt b/src/test/kotlin/io/billie/shipment/domain/model/ShipmentTest.kt new file mode 100644 index 0000000..112cb5a --- /dev/null +++ b/src/test/kotlin/io/billie/shipment/domain/model/ShipmentTest.kt @@ -0,0 +1,56 @@ +package io.billie.shipment.domain.model + +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal +import java.time.Instant + +@DisplayName("Shipment Domain Entity") +class ShipmentTest { + + @Test + fun `should create shipment with valid parameters`() { + val shipmentId = ShipmentId("SHIP-123") + val orderId = OrderId("ORDER-456") + val amount = Money(BigDecimal("50.00"), "EUR") + val shippedAt = Instant.now() + val trackingNumber = "TRACK-789" + + val shipment = Shipment(shipmentId, orderId, amount, shippedAt, trackingNumber) + + assertEquals(shipmentId, shipment.id) + assertEquals(orderId, shipment.orderId) + assertEquals(amount, shipment.amount) + assertEquals(shippedAt, shipment.shippedAt) + assertEquals(trackingNumber, shipment.trackingNumber) + } + + @Test + fun `should reject shipment with zero amount`() { + assertThrows { + Shipment( + ShipmentId("SHIP-123"), + OrderId("ORDER-456"), + Money(BigDecimal.ZERO, "EUR"), + Instant.now() + ) + } + } + + @Test + fun `should reject shipment with negative amount`() { + assertThrows { + Shipment( + ShipmentId("SHIP-123"), + OrderId("ORDER-456"), + Money(BigDecimal("-10.00"), "EUR"), + Instant.now() + ) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/shipment/domain/model/valueobjects/MoneyTest.kt b/src/test/kotlin/io/billie/shipment/domain/model/valueobjects/MoneyTest.kt new file mode 100644 index 0000000..5763e9e --- /dev/null +++ b/src/test/kotlin/io/billie/shipment/domain/model/valueobjects/MoneyTest.kt @@ -0,0 +1,98 @@ +package io.billie.shipment.domain.model.valueobjects + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal + +@DisplayName("Money Value Object") +class MoneyTest { + + @Test + fun `should create money with valid amount and currency`() { + val money = Money(BigDecimal("100.50"), "EUR") + + assertEquals(BigDecimal("100.50"), money.amount) + assertEquals("EUR", money.currency) + } + + @Test + fun `should reject negative amount`() { + assertThrows { + Money(BigDecimal("-10.00"), "EUR") + } + } + + @Test + fun `should reject invalid currency format`() { + assertThrows { + Money(BigDecimal("100.00"), "EURO") + } + + assertThrows { + Money(BigDecimal("100.00"), "") + } + } + + @Test + fun `should add money with same currency`() { + val money1 = Money(BigDecimal("50.00"), "EUR") + val money2 = Money(BigDecimal("30.00"), "EUR") + + val result = money1 + money2 + + assertEquals(BigDecimal("80.00"), result.amount) + assertEquals("EUR", result.currency) + } + + @Test + fun `should not add money with different currencies`() { + val money1 = Money(BigDecimal("50.00"), "EUR") + val money2 = Money(BigDecimal("30.00"), "USD") + + assertThrows { + money1 + money2 + } + } + + @Test + fun `should subtract money correctly`() { + val money1 = Money(BigDecimal("100.00"), "EUR") + val money2 = Money(BigDecimal("30.00"), "EUR") + + val result = money1 - money2 + + assertEquals(BigDecimal("70.00"), result.amount) + } + + @Test + fun `should not allow negative subtraction result`() { + val money1 = Money(BigDecimal("30.00"), "EUR") + val money2 = Money(BigDecimal("100.00"), "EUR") + + assertThrows { + money1 - money2 + } + } + + @Test + fun `should compare money correctly`() { + val money1 = Money(BigDecimal("100.00"), "EUR") + val money2 = Money(BigDecimal("50.00"), "EUR") + val money3 = Money(BigDecimal("100.00"), "EUR") + + assertTrue(money1 > money2) + assertTrue(money2 < money1) + assertEquals(0, money1.compareTo(money3)) + } + + @Test + fun `should identify zero amount`() { + val zeroMoney = Money(BigDecimal.ZERO, "EUR") + val nonZeroMoney = Money(BigDecimal("10.00"), "EUR") + + assertTrue(zeroMoney.isZero()) + assertFalse(nonZeroMoney.isZero()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/shipment/domain/service/ShipmentServiceTest.kt b/src/test/kotlin/io/billie/shipment/domain/service/ShipmentServiceTest.kt new file mode 100644 index 0000000..0c97f68 --- /dev/null +++ b/src/test/kotlin/io/billie/shipment/domain/service/ShipmentServiceTest.kt @@ -0,0 +1,260 @@ +package io.billie.shipment.domain.service + +import io.billie.shipment.application.command.CreateShipmentCommand +import io.billie.shipment.domain.events.OrderFullyShippedEvent +import io.billie.shipment.domain.events.ShipmentCreatedEvent +import io.billie.shipment.domain.exceptions.InsufficientOrderAmountException +import io.billie.shipment.domain.exceptions.OrderNotFoundException +import io.billie.shipment.domain.exceptions.UnauthorizedMerchantException +import io.billie.shipment.domain.model.Order +import io.billie.shipment.domain.model.OrderStatus +import io.billie.shipment.domain.model.Shipment +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import io.billie.shipment.domain.repository.OrderRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.math.BigDecimal +import java.time.Instant + +@DisplayName("Shipment Service") +class ShipmentServiceTest { + + private lateinit var orderRepository: OrderRepository + private lateinit var eventPublisher: EventPublisher + private lateinit var paymentService: PaymentService + private lateinit var shipmentService: ShipmentService + + private val orderId = OrderId("ORDER-123") + private val merchantId = MerchantId("MERCHANT-456") + private val differentMerchantId = MerchantId("DIFFERENT-MERCHANT") + private val orderAmount = Money(BigDecimal("100.00"), "EUR") + + @BeforeEach + fun setup() { + orderRepository = mock() + eventPublisher = mock() + paymentService = mock() + shipmentService = ShipmentService(orderRepository, eventPublisher, paymentService) + } + + @Nested + @DisplayName("Successful Shipment Creation") + inner class SuccessfulShipmentCreation { + + @Test + fun `should create shipment successfully when all conditions are met`() { + // Given + val shipmentAmount = Money(BigDecimal("50.00"), "EUR") + val order = createOrder() + val command = CreateShipmentCommand(orderId, merchantId, shipmentAmount) + val paymentResult = PaymentResult(true, "TXN-123") + + whenever(orderRepository.findById(orderId)).thenReturn(order) + whenever(orderRepository.save(order)).thenReturn(order) + whenever(paymentService.processPayment(merchantId, shipmentAmount)).thenReturn(paymentResult) + + // When + val result = shipmentService.createShipment(command) + + // Then + assertTrue(result.paymentResult.success) + assertEquals("TXN-123", result.paymentResult.transactionId) + assertEquals(BigDecimal("50.00"), result.remainingAmount.amount) + assertEquals("EUR", result.remainingAmount.currency) + assertNotNull(result.shipmentId) + + verify(orderRepository).findById(orderId) + verify(orderRepository).save(any()) + verify(paymentService).processPayment(merchantId, shipmentAmount) + verify(eventPublisher).publish(any()) + verify(eventPublisher, never()).publish(any()) + } + + @Test + fun `should publish OrderFullyShippedEvent when order is completely shipped`() { + // Given + val shipmentAmount = Money(BigDecimal("100.00"), "EUR") // Full order amount + val order = createOrder() + val command = CreateShipmentCommand(orderId, merchantId, shipmentAmount) + val paymentResult = PaymentResult(true, "TXN-123") + + whenever(orderRepository.findById(orderId)).thenReturn(order) + whenever(orderRepository.save(any())).thenReturn(order) + whenever(paymentService.processPayment(merchantId, shipmentAmount)).thenReturn(paymentResult) + + // When + val result = shipmentService.createShipment(command) + + // Then + assertTrue(result.remainingAmount.isZero()) + + verify(eventPublisher).publish(any()) + verify(eventPublisher).publish(any()) + } + + @Test + fun `should handle partial shipments correctly`() { + // Given + val firstShipmentAmount = Money(BigDecimal("30.00"), "EUR") + val secondShipmentAmount = Money(BigDecimal("20.00"), "EUR") + val order = createOrder() + + // First shipment + val firstCommand = CreateShipmentCommand(orderId, merchantId, firstShipmentAmount) + val paymentResult = PaymentResult(true, "TXN-123") + + whenever(orderRepository.findById(orderId)).thenReturn(order) + whenever(orderRepository.save(any())).thenReturn(order) + whenever(paymentService.processPayment(merchantId, firstShipmentAmount)).thenReturn(paymentResult) + + // When + val firstResult = shipmentService.createShipment(firstCommand) + + // Then + assertEquals(BigDecimal("70.00"), firstResult.remainingAmount.amount) + verify(eventPublisher, times(1)).publish(any()) + verify(eventPublisher, never()).publish(any()) + } + } + + @Nested + @DisplayName("Error Scenarios") + inner class ErrorScenarios { + + @Test + fun `should throw OrderNotFoundException when order does not exist`() { + // Given + val command = CreateShipmentCommand( + orderId, merchantId, Money(BigDecimal("50.00"), "EUR") + ) + whenever(orderRepository.findById(orderId)).thenReturn(null) + + // When & Then + val exception = assertThrows { + shipmentService.createShipment(command) + } + + assertEquals("Order not found: ${orderId.value}", exception.message) + verify(orderRepository).findById(orderId) + verify(orderRepository, never()).save(any()) + verify(paymentService, never()).processPayment(any(), any()) + verify(eventPublisher, never()).publish(any()) + } + + @Test + fun `should throw UnauthorizedMerchantException when merchant does not own order`() { + // Given + val order = createOrder() + val command = CreateShipmentCommand( + orderId, differentMerchantId, Money(BigDecimal("50.00"), "EUR") + ) + whenever(orderRepository.findById(orderId)).thenReturn(order) + + // When & Then + val exception = assertThrows { + shipmentService.createShipment(command) + } + + assertTrue(exception.message!!.contains("DIFFERENT-MERCHANT")) + assertTrue(exception.message!!.contains("not authorized")) + verify(orderRepository, never()).save(any()) + verify(paymentService, never()).processPayment(any(), any()) + verify(eventPublisher, never()).publish(any()) + } + + @Test + fun `should throw InsufficientOrderAmountException when shipment exceeds order total`() { + // Given + val excessiveAmount = Money(BigDecimal("150.00"), "EUR") + val order = createOrder() + val command = CreateShipmentCommand(orderId, merchantId, excessiveAmount) + whenever(orderRepository.findById(orderId)).thenReturn(order) + + // When & Then + val exception = assertThrows { + shipmentService.createShipment(command) + } + + assertTrue(exception.message!!.contains("exceeds remaining order amount")) + verify(orderRepository, never()).save(any()) + verify(paymentService, never()).processPayment(any(), any()) + verify(eventPublisher, never()).publish(any()) + } + + @Test + fun `should throw InsufficientOrderAmountException when partial shipments exceed total`() { + // Given + val existingShipment = Shipment( + ShipmentId("EXISTING-SHIP"), + orderId, + Money(BigDecimal("80.00"), "EUR"), + Instant.now() + ) + val orderWithExistingShipment = createOrder(shipments = mutableListOf(existingShipment)) + val excessiveAmount = Money(BigDecimal("30.00"), "EUR") // 80 + 30 = 110 > 100 + val command = CreateShipmentCommand(orderId, merchantId, excessiveAmount) + + whenever(orderRepository.findById(orderId)).thenReturn(orderWithExistingShipment) + + // When & Then + assertThrows { + shipmentService.createShipment(command) + } + } + } + + @Nested + @DisplayName("Payment Integration") + inner class PaymentIntegration { + + @Test + fun `should handle payment failure gracefully`() { + // Given + val shipmentAmount = Money(BigDecimal("50.00"), "EUR") + val order = createOrder() + val command = CreateShipmentCommand(orderId, merchantId, shipmentAmount) + val failedPaymentResult = PaymentResult(false, null, "Payment failed") + + whenever(orderRepository.findById(orderId)).thenReturn(order) + whenever(orderRepository.save(any())).thenReturn(order) + whenever(paymentService.processPayment(merchantId, shipmentAmount)).thenReturn(failedPaymentResult) + + // When + val result = shipmentService.createShipment(command) + + // Then + assertFalse(result.paymentResult.success) + assertEquals("Payment failed", result.paymentResult.errorMessage) + assertNull(result.paymentResult.transactionId) + + // Shipment should still be created and events published even if payment fails + verify(orderRepository).save(any()) + verify(eventPublisher).publish(any()) + } + } + + private fun createOrder( + id: OrderId = orderId, + merchant: MerchantId = merchantId, + amount: Money = orderAmount, + status: OrderStatus = OrderStatus.PENDING, + shipments: MutableList = mutableListOf() + ) = Order(id, merchant, amount, status, Instant.now(), shipments) +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/shipment/infrastructure/ShipmentControllerTest.kt b/src/test/kotlin/io/billie/shipment/infrastructure/ShipmentControllerTest.kt new file mode 100644 index 0000000..4e76f93 --- /dev/null +++ b/src/test/kotlin/io/billie/shipment/infrastructure/ShipmentControllerTest.kt @@ -0,0 +1,282 @@ +package io.billie.shipment.infrastructure + +import com.fasterxml.jackson.databind.ObjectMapper +import io.billie.shipment.application.dto.CreateShipmentRequest +import io.billie.shipment.application.result.ShipmentResult +import io.billie.shipment.domain.exceptions.InsufficientOrderAmountException +import io.billie.shipment.domain.exceptions.OrderNotFoundException +import io.billie.shipment.domain.exceptions.UnauthorizedMerchantException +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import io.billie.shipment.domain.service.PaymentResult +import io.billie.shipment.domain.service.ShipmentService +import io.billie.shipment.infrastructure.persistence.repository.OrderJpaRepository +import io.billie.shipment.infrastructure.web.ShipmentController +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.math.BigDecimal + +@WebMvcTest(ShipmentController::class) +@DisplayName("Shipment Controller") +class ShipmentControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @MockBean + private lateinit var shipmentService: ShipmentService + + @MockBean + private lateinit var orderJpaRepository: OrderJpaRepository + + private val merchantId = "MERCHANT-123" + private val orderId = "ORDER-456" + private val baseUrl = "/api/v1/merchants/$merchantId/orders/$orderId/shipments" + + @Nested + @DisplayName("Successful Requests") + inner class SuccessfulRequests { + + @Test + fun `should create shipment successfully`() { + // Given + val request = CreateShipmentRequest( + orderId = orderId, + amount = BigDecimal("50.00"), + currency = "EUR", + trackingNumber = "TRACK-123" + ) + + val shipmentResult = ShipmentResult( + shipmentId = ShipmentId("SHIP-789"), + paymentResult = PaymentResult(true, "TXN-456"), + remainingAmount = Money(BigDecimal("50.00"), "EUR") + ) + + whenever(shipmentService.createShipment(any())).thenReturn(shipmentResult) + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.shipmentId").value("SHIP-789")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.transactionId").value("TXN-456")) + .andExpect(jsonPath("$.remainingAmount").value(50.00)) + .andExpect(jsonPath("$.currency").value("EUR")) + + verify(shipmentService).createShipment(argThat { command -> + command.orderId.value == orderId && + command.merchantId.value == merchantId && + command.amount.amount == BigDecimal("50.00") && + command.amount.currency == "EUR" && + command.trackingNumber == "TRACK-123" + }) + } + + @Test + fun `should create shipment without tracking number`() { + // Given + val request = CreateShipmentRequest( + orderId = orderId, + amount = BigDecimal("50.00"), + currency = "EUR" + ) + + val shipmentResult = ShipmentResult( + shipmentId = ShipmentId("SHIP-789"), + paymentResult = PaymentResult(true, "TXN-456"), + remainingAmount = Money(BigDecimal("50.00"), "EUR") + ) + + whenever(shipmentService.createShipment(any())).thenReturn(shipmentResult) + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + verify(shipmentService).createShipment(argThat { command -> + command.trackingNumber == null + }) + } + } + + @Nested + @DisplayName("Error Handling") + inner class ErrorHandling { + + @Test + fun `should return 404 when order not found`() { + // Given + val request = CreateShipmentRequest( + orderId = orderId, + amount = BigDecimal("50.00"), + currency = "EUR" + ) + + whenever(shipmentService.createShipment(any())) + .thenThrow(OrderNotFoundException(OrderId(orderId))) + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.errorMessage").value("Order not found: $orderId")) + } + + @Test + fun `should return 403 when merchant unauthorized`() { + // Given + val request = CreateShipmentRequest( + orderId = orderId, + amount = BigDecimal("50.00"), + currency = "EUR" + ) + + whenever(shipmentService.createShipment(any())) + .thenThrow(UnauthorizedMerchantException(MerchantId(merchantId), OrderId(orderId))) + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.errorMessage").value("Merchant $merchantId not authorized for order $orderId")) + } + + @Test + fun `should return 400 when shipment amount exceeds order total`() { + // Given + val request = CreateShipmentRequest( + orderId = orderId, + amount = BigDecimal("150.00"), + currency = "EUR" + ) + + whenever(shipmentService.createShipment(any())) + .thenThrow(InsufficientOrderAmountException("Shipment amount exceeds remaining order amount")) + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.errorMessage").value("Shipment amount exceeds remaining order amount")) + } + + @Test + fun `should return 500 for unexpected errors`() { + // Given + val request = CreateShipmentRequest( + orderId = orderId, + amount = BigDecimal("50.00"), + currency = "EUR" + ) + + whenever(shipmentService.createShipment(any())) + .thenThrow(RuntimeException("Database connection failed")) + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.errorMessage").value("Internal server error")) + } + } + + @Nested + @DisplayName("Input Validation") + inner class InputValidation { + + @Test + fun `should reject request with negative amount`() { + // Given + val request = CreateShipmentRequest( + orderId = orderId, + amount = BigDecimal("-50.00"), + currency = "EUR" + ) + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isBadRequest) + } + + @Test + fun `should reject request with invalid currency`() { + // Given + val request = CreateShipmentRequest( + orderId = orderId, + amount = BigDecimal("50.00"), + currency = "INVALID" + ) + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isBadRequest) + } + + @Test + fun `should reject request with missing required fields`() { + // Given + val incompleteRequest = """{"orderId": "$orderId"}""" + + // When & Then + mockMvc.perform( + post(baseUrl) + .contentType(APPLICATION_JSON) + .content(incompleteRequest) + ) + .andExpect(status().isInternalServerError) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderRepositoryImplTest.kt b/src/test/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderRepositoryImplTest.kt new file mode 100644 index 0000000..a88a1a4 --- /dev/null +++ b/src/test/kotlin/io/billie/shipment/infrastructure/persistence/repository/OrderRepositoryImplTest.kt @@ -0,0 +1,99 @@ +package io.billie.shipment.infrastructure.persistence.repository + +import io.billie.shipment.domain.model.Order +import io.billie.shipment.domain.model.OrderStatus +import io.billie.shipment.domain.model.Shipment +import io.billie.shipment.domain.model.valueobjects.MerchantId +import io.billie.shipment.domain.model.valueobjects.Money +import io.billie.shipment.domain.model.valueobjects.OrderId +import io.billie.shipment.domain.model.valueobjects.ShipmentId +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import java.math.BigDecimal +import java.time.Instant + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DisplayName("JPA Order Repository") +class OrderRepositoryImplTest { + + @Autowired + private lateinit var orderRepository: OrderRepositoryImpl + + @Nested + @DisplayName("Order Persistence") + inner class OrderPersistence { + + @Test + fun `should save and retrieve order successfully`() { + // Given + val order = Order( + id = OrderId("ORDER-123"), + merchantId = MerchantId("MERCHANT-456"), + totalAmount = Money(BigDecimal("100.00"), "EUR"), + status = OrderStatus.PENDING, + createdAt = Instant.now() + ) + + // When + val savedOrder = orderRepository.save(order) + val retrievedOrder = orderRepository.findById(order.id) + + // Then + assertNotNull(retrievedOrder) + assertEquals(order.id, retrievedOrder!!.id) + assertEquals(order.merchantId, retrievedOrder.merchantId) + assertEquals(order.totalAmount, retrievedOrder.totalAmount) + assertEquals(order.status, retrievedOrder.status) + } + + @Test + fun `should return null when order not found`() { + // Given + val nonExistentOrderId = OrderId("NON-EXISTENT") + + // When + val result = orderRepository.findById(nonExistentOrderId) + + // Then + assertNull(result) + } + + @Test + fun `should save order with shipments`() { + // Given + val shipment = Shipment( + id = ShipmentId("SHIP-123"), + orderId = OrderId("ORDER-123"), + amount = Money(BigDecimal("50.00"), "EUR"), + shippedAt = Instant.now(), + trackingNumber = "TRACK-456" + ) + + val order = Order( + id = OrderId("ORDER-123"), + merchantId = MerchantId("MERCHANT-456"), + totalAmount = Money(BigDecimal("100.00"), "EUR"), + status = OrderStatus.PARTIALLY_SHIPPED, + createdAt = Instant.now(), + shipments = mutableListOf(shipment) + ) + + // When + val savedOrder = orderRepository.save(order) + val retrievedOrder = orderRepository.findById(order.id) + + // Then + assertNotNull(retrievedOrder) + assertEquals(1, retrievedOrder!!.shipments.size) + assertEquals(shipment.id, retrievedOrder.shipments.first().id) + assertEquals(shipment.amount, retrievedOrder.shipments.first().amount) + assertEquals(shipment.trackingNumber, retrievedOrder.shipments.first().trackingNumber) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/data/Fixtures.kt b/src/test/kotlin/io/billie/util/data/Fixtures.kt similarity index 99% rename from src/test/kotlin/io/billie/functional/data/Fixtures.kt rename to src/test/kotlin/io/billie/util/data/Fixtures.kt index 9954801..9f136a4 100644 --- a/src/test/kotlin/io/billie/functional/data/Fixtures.kt +++ b/src/test/kotlin/io/billie/util/data/Fixtures.kt @@ -1,4 +1,4 @@ -package io.billie.functional.data +package io.billie.util.data import java.text.SimpleDateFormat import java.util.* diff --git a/src/test/kotlin/io/billie/functional/matcher/IsUUID.kt b/src/test/kotlin/io/billie/util/matcher/IsUUID.kt similarity index 94% rename from src/test/kotlin/io/billie/functional/matcher/IsUUID.kt rename to src/test/kotlin/io/billie/util/matcher/IsUUID.kt index eb031fe..b3ff2b2 100644 --- a/src/test/kotlin/io/billie/functional/matcher/IsUUID.kt +++ b/src/test/kotlin/io/billie/util/matcher/IsUUID.kt @@ -1,4 +1,4 @@ -package io.billie.functional.matcher +package io.billie.util.matcher import org.hamcrest.Description import org.hamcrest.TypeSafeMatcher