From 697fc740e6efe326092a0b22b293d5150ae489cb Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 13 Aug 2026 21:08:10 +0900 Subject: [PATCH] chore: remove sample portfolio module --- .github/ci-gate-matrix.yml | 7 - .github/scripts/verify-gate-matrix.sh | 5 +- .github/scripts/verify-gradle-wrapper.sh | 2 +- .../object-storage-qualification.yml | 20 - AGENTS.md | 14 +- CLAUDE.md | 7 +- README.md | 16 +- docs/registries/env-keys.yaml | 5 + src/Dockerfile.sample | 129 ----- src/README.md | 14 +- src/adapter/inbound/graphql/CLAUDE.md | 2 +- src/adapter/inbound/graphql/README.md | 11 +- src/adapter/inbound/web/CLAUDE.md | 13 +- src/adapter/inbound/web/README.md | 3 +- src/adapter/inbound/websocket/CLAUDE.md | 2 +- src/adapter/inbound/websocket/README.md | 4 +- src/adapter/outbound/fileserver/CLAUDE.md | 2 +- src/adapter/outbound/identifier/CLAUDE.md | 3 +- src/adapter/outbound/objectstorage/CLAUDE.md | 2 +- .../outbound/persistence-jpa/CLAUDE.md | 9 +- .../audit/AuditableEntityTest.java | 3 +- .../outbound/persistence-mongo/CLAUDE.md | 2 +- src/app-bootstrap/CLAUDE.md | 2 +- src/app-bootstrap/README.md | 10 +- src/app-bootstrap/build.gradle | 38 -- src/app-bootstrap/gradle.lockfile | 205 +------ .../src/main/resources/application.yml | 7 +- .../architecture/CleanArchitectureTest.java | 36 +- ...nalTransportQualificationContractTest.java | 21 +- .../ContractSuiteCompletenessTest.java | 5 +- .../DeveloperExperienceContractTest.java | 18 +- .../SampleRemovalSmokeContractTest.java | 81 +-- .../outbox/EventPayloadPiiContractTest.java | 14 +- .../DistributedLockProviderContractTest.java | 3 +- .../outbox/OutboxContainerTestSupport.java | 5 +- .../OperationalContractRuntimeTest.java | 17 +- src/application-core/CLAUDE.md | 7 +- .../storage/ObjectStoragePort.java | 2 +- src/build.gradle | 30 +- src/config/architecture/modules.json | 42 +- src/domain-core/README.md | 11 +- src/sample-portfolio/.jqwik-database | Bin 4 -> 0 bytes src/sample-portfolio/CLAUDE.md | 53 -- src/sample-portfolio/README.md | 477 ---------------- src/sample-portfolio/build.gradle | 159 ------ src/sample-portfolio/gradle.lockfile | 338 ----------- .../portfolio/SamplePortfolioApplication.java | 52 -- .../LegacyPosterImageController.java | 52 -- .../web/controller/OperationsController.java | 45 -- .../web/controller/PosterController.java | 164 ------ .../PosterImagePublicationController.java | 113 ---- .../web/controller/WorkLogController.java | 254 --------- .../web/dto/request/CreatePosterRequest.java | 11 - .../web/dto/request/CreateWorkLogRequest.java | 32 -- .../dto/request/SamplePolymorphicRequest.java | 23 - .../web/dto/request/UpdatePosterRequest.java | 27 - .../web/dto/request/UpdateWorkLogRequest.java | 40 -- .../dto/request/WorkLogValidationGroups.java | 10 - .../PosterImagePublicationResponse.java | 4 - .../PosterImagePublicationStatusResponse.java | 10 - .../web/dto/response/PosterResponse.java | 8 - .../web/dto/response/RepoStatsResponse.java | 3 - .../dto/response/StoredObjectResponse.java | 4 - .../web/dto/response/WorkLogResponse.java | 19 - .../dto/response/WorkLogSummaryResponse.java | 14 - .../web/error/DomainExceptionHandler.java | 70 --- .../PortfolioClientSafeErrorMessages.java | 17 - .../inbound/web/error/PortfolioErrorCode.java | 47 -- .../inbound/web/json/PosterIdSerializer.java | 23 - .../inbound/web/json/WorkLogIdSerializer.java | 23 - .../PosterImagePublicationWebMapper.java | 33 -- .../inbound/web/mapper/PosterWebMapper.java | 37 -- .../inbound/web/mapper/WorkLogWebMapper.java | 79 --- .../MultipartObjectContentProducer.java | 65 --- .../web/operation/SampleOperationStore.java | 36 -- .../web/operation/WorkLogExportResult.java | 7 - ...acPosterImageIdempotencyScopeDigester.java | 128 ----- .../identifier/UuidOutboxEventIdFactory.java | 19 - .../identifier/UuidPosterIdFactory.java | 21 - .../UuidPosterImageOperationIdFactory.java | 18 - .../identifier/UuidWorkLogIdFactory.java | 21 - .../persistence/config/JpaConfig.java | 17 - .../persistence/entity/PosterEntity.java | 103 ---- .../PosterImageIdempotencyKeyEpochEntity.java | 58 -- .../PosterImageRetirementIntentEntity.java | 198 ------- .../entity/PosterImageUploadIntentEntity.java | 311 ----------- .../persistence/entity/WorkLogEntity.java | 170 ------ ...mageRetirementIntentPersistenceMapper.java | 54 -- ...terImageUploadIntentPersistenceMapper.java | 110 ---- .../mapper/PosterPersistenceMapper.java | 50 -- .../mapper/WorkLogPersistenceMapper.java | 54 -- .../PosterImageAttachmentCasRepository.java | 72 --- ...ImageIdempotencyKeyEpochJpaRepository.java | 20 - ...eIdempotencyKeyEpochRepositoryAdapter.java | 48 -- ...terImageRetirementIntentJpaRepository.java | 40 -- ...mageRetirementIntentRepositoryAdapter.java | 95 ---- .../PosterImageUploadIntentJpaRepository.java | 41 -- ...terImageUploadIntentRepositoryAdapter.java | 119 ---- .../repository/PosterJpaRepository.java | 16 - .../repository/PosterRepositoryAdapter.java | 95 ---- .../repository/WorkLogJpaRepository.java | 26 - .../repository/WorkLogRepositoryAdapter.java | 91 --- .../WorkLogSummaryQueryAdapter.java | 39 -- .../repository/WorkLogSummaryRow.java | 14 - .../repostats/RawRepoStatsResponse.java | 4 - .../repostats/RepoStatsAclMapper.java | 21 - .../repostats/RepoStatsPortClient.java | 33 -- .../AbortPosterImageUploadCommand.java | 21 - .../AdoptLegacyPosterImageCommand.java | 52 -- .../command/ArchivePosterCommand.java | 12 - .../command/BatchCreateWorkLogsCommand.java | 15 - .../command/CreatePosterCommand.java | 15 - .../command/CreateWorkLogCommand.java | 31 -- .../command/DeletePosterCommand.java | 12 - .../command/DeleteWorkLogCommand.java | 13 - .../command/PublishPosterCommand.java | 12 - .../command/PublishPosterImageCommand.java | 39 -- ...ReconcilePosterImageRetirementCommand.java | 31 -- .../ReconcilePosterImageUploadCommand.java | 21 - .../command/RetirePosterImageCommand.java | 24 - .../command/UpdatePosterCommand.java | 21 - .../command/UpdateWorkLogCommand.java | 29 - .../command/UploadPosterImageCommand.java | 20 - .../event/PosterEventPublisher.java | 110 ---- .../event/PosterImageAttachmentPrepared.java | 13 - .../event/PosterImageAttachmentReadyV2.java | 15 - .../PosterImagePublicationEventPublisher.java | 81 --- .../WorkLogReservedContractContribution.java | 69 --- .../WorkLogReservedIntegrationEvent.java | 10 - ...WorkLogReservedIntegrationEventMapper.java | 75 --- .../event/WorkLogReservedPayload.java | 19 - .../exception/PosterNotFoundException.java | 13 - .../PosterTitleAlreadyExistsException.java | 11 - .../exception/WorkLogNotFoundException.java | 12 - .../application/port/RepoStatsPort.java | 8 - .../port/WorkLogSummaryQueryPort.java | 16 - .../poster/AbortPosterImageUploadUseCase.java | 111 ---- .../poster/ArchivePosterUseCase.java | 54 -- .../poster/CreatePosterUseCase.java | 61 -- .../poster/DeletePosterUseCase.java | 89 --- ...etPosterImagePublicationStatusUseCase.java | 57 -- .../application/poster/GetPosterUseCase.java | 35 -- .../poster/ListPostersUseCase.java | 34 -- .../poster/PublishPosterImageUseCase.java | 266 --------- .../poster/PublishPosterUseCase.java | 55 -- ...ReconcilePosterImageRetirementUseCase.java | 116 ---- .../ReconcilePosterImageUploadUseCase.java | 213 ------- .../poster/RetirePosterImageUseCase.java | 91 --- .../poster/UpdatePosterUseCase.java | 55 -- .../poster/UploadPosterImageUseCase.java | 69 --- .../AdoptLegacyPosterImageUseCase.java | 101 ---- ...osterImageAdoptionAuthorizationPolicy.java | 24 - ...yPosterImageAdoptionExecutionIdentity.java | 18 - ...terImageAdoptionExecutionIdentityPort.java | 7 - .../LegacyPosterImageAdoptionResult.java | 16 - ...osterImageAttachmentCompareAndSetPort.java | 21 - .../PosterImageIdempotencyKeyEpochPort.java | 9 - .../PosterImageIdempotencyScopeDigest.java | 28 - ...sterImageIdempotencyScopeDigesterPort.java | 16 - .../PosterImageOperationIdFactory.java | 19 - .../PosterImagePublicationFingerprint.java | 44 -- .../PosterImagePublicationPolicy.java | 34 -- .../PosterImagePublicationReservation.java | 14 - ...osterImagePublicationReservationCodec.java | 28 - .../PosterImagePublicationStatus.java | 38 -- .../PosterImageRetirementIntent.java | 213 ------- .../PosterImageRetirementIntentState.java | 14 - .../PosterImageRetirementIntentStorePort.java | 20 - .../PosterImageRetirementResult.java | 25 - ...ageSanitizedIdempotencyContextFactory.java | 63 --- .../posterimage/PosterImageUploadIntent.java | 333 ----------- .../PosterImageUploadIntentClaim.java | 14 - ...terImageUploadIntentConflictException.java | 11 - .../PosterImageUploadIntentState.java | 16 - .../PosterImageUploadIntentStorePort.java | 26 - .../GetPosterImagePublicationStatusQuery.java | 15 - .../application/query/GetPosterQuery.java | 12 - .../application/query/GetRepoStatsQuery.java | 12 - .../application/query/GetWorkLogQuery.java | 13 - .../application/query/ListPostersQuery.java | 14 - .../ListRecentWorkLogSummariesQuery.java | 11 - .../application/query/ListWorkLogsQuery.java | 13 - .../application/query/WorkLogSummary.java | 14 - .../worklog/BatchCreateWorkLogsUseCase.java | 68 --- .../worklog/CreateWorkLogUseCase.java | 121 ---- .../worklog/DeleteWorkLogUseCase.java | 44 -- .../worklog/GetRepoStatsUseCase.java | 31 -- .../worklog/GetWorkLogUseCase.java | 35 -- .../ListRecentWorkLogSummariesUseCase.java | 41 -- .../worklog/ListWorkLogsUseCase.java | 34 -- .../worklog/UpdateWorkLogUseCase.java | 76 --- .../context/SampleDomainContextConfig.java | 20 - .../idempotency/SampleIdempotencyConfig.java | 31 -- .../SampleIdempotencySettings.java | 34 -- .../logging/SamplePseudonymizationConfig.java | 25 - .../SampleManagementSecurityConfig.java | 30 - .../metrics/SampleMetricsContractConfig.java | 87 --- .../objectstorage/PosterImageApiConfig.java | 29 - .../objectstorage/PosterImageApiSettings.java | 18 - .../PosterImageIdempotencyConfig.java | 110 ---- ...PosterImageIdempotencyKeyRingSettings.java | 20 - .../PosterImageObjectStorageConfig.java | 156 ------ .../PosterImagePublicationSettings.java | 36 -- .../PosterImageRetirementConfig.java | 23 - .../PosterImageRetirementJob.java | 42 -- .../PosterImageRetirementSettings.java | 49 -- .../SamplePostgreSqlPersistenceConfig.java | 62 --- .../scan/TestEnclosedConfigurationFilter.java | 29 - .../SamplePublicAccessSecurityConfig.java | 30 - .../settings/SamplePrivacySettings.java | 33 -- .../SampleMicrometerSpanErrorRecorder.java | 33 -- .../tracing/SampleTracingConfig.java | 83 --- .../SampleTracingSampleRateResolver.java | 51 -- ...acingSamplingEnvironmentPostProcessor.java | 38 -- .../tracing/SampleTracingSettings.java | 76 --- .../poster/LegacyPosterImageAttachment.java | 16 - .../portfolio/domain/poster/Poster.java | 185 ------- .../domain/poster/PosterArchived.java | 13 - .../domain/poster/PosterCreated.java | 18 - .../domain/poster/PosterDeleted.java | 13 - .../portfolio/domain/poster/PosterId.java | 29 - .../domain/poster/PosterIdFactory.java | 9 - .../domain/poster/PosterImageAttached.java | 17 - .../domain/poster/PosterImageAttachment.java | 8 - .../domain/poster/PosterImageReference.java | 46 -- .../poster/PosterInvariantException.java | 29 - .../portfolio/domain/poster/PosterPage.java | 15 - .../domain/poster/PosterPublished.java | 13 - .../domain/poster/PosterRepository.java | 24 - .../domain/poster/PosterSortField.java | 32 -- .../portfolio/domain/poster/PosterStatus.java | 24 - .../PublishedPosterImageAttachment.java | 17 - .../domain/worklog/OutboxEventIdFactory.java | 16 - .../portfolio/domain/worklog/Period.java | 21 - .../portfolio/domain/worklog/RepoStats.java | 4 - .../domain/worklog/WorkCategory.java | 9 - .../portfolio/domain/worklog/WorkLog.java | 243 -------- .../portfolio/domain/worklog/WorkLogId.java | 29 - .../domain/worklog/WorkLogIdFactory.java | 9 - .../worklog/WorkLogInvariantException.java | 29 - .../domain/worklog/WorkLogOwner.java | 16 - .../portfolio/domain/worklog/WorkLogPage.java | 14 - .../domain/worklog/WorkLogRepository.java | 22 - .../domain/worklog/WorkLogReserved.java | 20 - .../domain/worklog/WorkLogSortField.java | 31 -- .../domain/worklog/WorkLogStatus.java | 7 - .../sample/portfolio/package-info.java | 2 - .../main/resources/META-INF/spring.factories | 2 - .../src/main/resources/application.yml | 323 ----------- .../portfolio.worklog.reserved/v1.schema.json | 18 - .../v1.schema.sha256 | 1 - .../db/sample-migration/V2__work_log.sql | 65 --- .../db/sample-migration/V7__poster.sql | 31 -- .../V8__poster_image_publication.sql | 128 ----- ...eIdempotencyRotationQualificationTest.java | 30 - ...osterImageRetirementQualificationTest.java | 110 ---- ...sterImageV8MigrationQualificationTest.java | 176 ------ .../SampleApplicationContextTest.java | 84 --- .../SamplePortfolioTestApplication.java | 18 - .../contract/OpenApiDriftContractTest.java | 172 ------ .../OperationsControllerWireTest.java | 69 --- .../controller/PosterControllerWireTest.java | 246 -------- ...terImagePublicationControllerWireTest.java | 91 --- .../controller/WorkLogControllerWireTest.java | 447 --------------- .../web/date/DateHeaderContractTest.java | 76 --- ...PolymorphicTypeValidatorAllowlistTest.java | 133 ----- .../request/SamplePolymorphicRequestTest.java | 78 --- .../web/dto/response/BulkEnvelopeTest.java | 75 --- .../web/error/DomainExceptionHandlerTest.java | 83 --- ...PortfolioErrorCodeRegistryMappingTest.java | 95 ---- .../web/error/PortfolioErrorCodeTest.java | 16 - .../web/filter/VirtualThreadMdcE2ETest.java | 174 ------ .../VirtualThreadMdcPropagationTest.java | 94 ---- .../web/json/WorkLogIdSerializerTest.java | 23 - .../MultipartObjectContentProducerTest.java | 63 --- .../web/openapi/OpenApiSnapshotTest.java | 104 ---- .../web/versioning/VersioningPrefixTest.java | 57 -- ...sterImageIdempotencyScopeDigesterTest.java | 48 -- .../identifier/UuidWorkLogIdFactoryTest.java | 41 -- .../mapper/WorkLogPersistenceMapperTest.java | 39 -- ...UploadIntentRepositoryIntegrationTest.java | 102 ---- ...osterRepositoryAdapterIntegrationTest.java | 155 ------ ...rkLogRepositoryAdapterIntegrationTest.java | 135 ----- .../WorkLogRepositoryAdapterTest.java | 151 ----- .../WorkLogSummaryQueryAdapterTest.java | 63 --- .../repostats/RepoStatsAclMapperTest.java | 45 -- .../command/CommandSelfValidationTest.java | 81 --- .../event/PosterEventPublisherTest.java | 65 --- ...LogReservedConsumerDedupeContractTest.java | 134 ----- ...rkLogReservedContractContributionTest.java | 181 ------ ...eservedIntegrationEventMapperJsonTest.java | 93 ---- ...LogReservedIntegrationEventMapperTest.java | 32 -- .../DeletePosterImageRetirementTest.java | 143 ----- ...PosterImageUploadCharacterizationTest.java | 160 ------ .../PosterImagePublicationWorkflowTest.java | 314 ----------- .../PosterImageRetirementCrashMatrixTest.java | 191 ------- ...anitizedIdempotencyContextFactoryTest.java | 34 -- .../worklog/CreateWorkLogOutboxTest.java | 299 ---------- .../worklog/GetRepoStatsUseCaseTest.java | 18 - ...ListRecentWorkLogSummariesUseCaseTest.java | 79 --- .../worklog/WorkLogUseCasesTest.java | 317 ----------- .../WorkLogAuthorizationContractTest.java | 237 -------- .../authz/WorkLogAuthorizationE2ETest.java | 181 ------ .../PosterImageIdempotencyConfigTest.java | 78 --- .../PosterImageRetirementConfigTest.java | 52 -- .../SamplePublicAccessSecurityConfigTest.java | 116 ---- .../portfolio/domain/poster/PosterIdTest.java | 26 - .../poster/PosterImageReferenceTest.java | 20 - .../portfolio/domain/poster/PosterTest.java | 117 ---- .../portfolio/domain/worklog/PeriodTest.java | 41 -- .../domain/worklog/WorkLogIdPropertyTest.java | 75 --- .../domain/worklog/WorkLogIdTest.java | 55 -- .../domain/worklog/WorkLogInvariantTest.java | 63 --- .../portfolio/domain/worklog/WorkLogTest.java | 85 --- .../fixtures/SamplePortfolioFixture.java | 18 - .../testinfra/PostgreSqlTestContainer.java | 18 - .../src/test/resources/application-test.yml | 195 ------- .../v1.invalid-unknown-field.json | 1 - .../portfolio.worklog.reserved/v1.valid.json | 1 - .../openapi/worklogs-openapi-snapshot.json | 523 ------------------ src/settings.gradle | 6 +- 321 files changed, 190 insertions(+), 18961 deletions(-) delete mode 100644 src/Dockerfile.sample delete mode 100644 src/sample-portfolio/.jqwik-database delete mode 100644 src/sample-portfolio/CLAUDE.md delete mode 100644 src/sample-portfolio/README.md delete mode 100644 src/sample-portfolio/build.gradle delete mode 100644 src/sample-portfolio/gradle.lockfile delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/SamplePortfolioApplication.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsController.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationController.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogController.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreatePosterRequest.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreateWorkLogRequest.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequest.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdatePosterRequest.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdateWorkLogRequest.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/WorkLogValidationGroups.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationResponse.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationStatusResponse.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterResponse.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/RepoStatsResponse.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/StoredObjectResponse.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogResponse.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogSummaryResponse.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioClientSafeErrorMessages.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCode.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/PosterIdSerializer.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializer.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/WorkLogWebMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/SampleOperationStore.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/WorkLogExportResult.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidOutboxEventIdFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterIdFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/config/JpaConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageIdempotencyKeyEpochEntity.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageRetirementIntentEntity.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageUploadIntentEntity.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/WorkLogEntity.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageAttachmentCasRepository.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochJpaRepository.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochRepositoryAdapter.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentJpaRepository.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentRepositoryAdapter.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentJpaRepository.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryAdapter.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterJpaRepository.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapter.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogJpaRepository.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapter.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapter.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryRow.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RawRepoStatsResponse.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsPortClient.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AdoptLegacyPosterImageCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ArchivePosterCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/BatchCreateWorkLogsCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreatePosterCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreateWorkLogCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeletePosterCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeleteWorkLogCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageRetirementCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/RetirePosterImageCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdatePosterCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdateWorkLogCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentPrepared.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentReadyV2.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEvent.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapper.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterNotFoundException.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterTitleAlreadyExistsException.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/WorkLogNotFoundException.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/RepoStatsPort.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/WorkLogSummaryQueryPort.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ArchivePosterUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/CreatePosterUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ListPostersUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UpdatePosterUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionAuthorizationPolicy.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentity.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentityPort.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionResult.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageAttachmentCompareAndSetPort.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyKeyEpochPort.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigest.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigesterPort.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprint.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationStatus.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntent.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentState.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentStorePort.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementResult.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntent.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentClaim.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentConflictException.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentState.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentStorePort.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterQuery.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetRepoStatsQuery.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetWorkLogQuery.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListPostersQuery.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListRecentWorkLogSummariesQuery.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListWorkLogsQuery.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/WorkLogSummary.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/BatchCreateWorkLogsUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/DeleteWorkLogUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetWorkLogUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListWorkLogsUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/UpdateWorkLogUseCase.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/context/SampleDomainContextConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencyConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencySettings.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/logging/SamplePseudonymizationConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/management/SampleManagementSecurityConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/metrics/SampleMetricsContractConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiSettings.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyKeyRingSettings.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImagePublicationSettings.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementJob.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementSettings.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/scan/TestEnclosedConfigurationFilter.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/settings/SamplePrivacySettings.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleMicrometerSpanErrorRecorder.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingConfig.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSampleRateResolver.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSamplingEnvironmentPostProcessor.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSettings.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/LegacyPosterImageAttachment.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterArchived.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterCreated.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterDeleted.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterId.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttached.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttachment.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReference.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterInvariantException.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPage.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPublished.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterRepository.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterSortField.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterStatus.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PublishedPosterImageAttachment.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/OutboxEventIdFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/Period.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/RepoStats.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkCategory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLog.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogId.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdFactory.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantException.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogOwner.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogPage.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogRepository.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogReserved.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogSortField.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogStatus.java delete mode 100644 src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/package-info.java delete mode 100644 src/sample-portfolio/src/main/resources/META-INF/spring.factories delete mode 100644 src/sample-portfolio/src/main/resources/application.yml delete mode 100644 src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json delete mode 100644 src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256 delete mode 100644 src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql delete mode 100644 src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster.sql delete mode 100644 src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql delete mode 100644 src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java delete mode 100644 src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java delete mode 100644 src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SampleApplicationContextTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SamplePortfolioTestApplication.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsControllerWireTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogControllerWireTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/date/DateHeaderContractTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/BasicPolymorphicTypeValidatorAllowlistTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequestTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/BulkEnvelopeTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeRegistryMappingTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcE2ETest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcPropagationTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializerTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/versioning/VersioningPrefixTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactoryTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapperTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapterTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/command/CommandSelfValidationTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedConsumerDedupeContractTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperJsonTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactoryTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCaseTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfigTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/PeriodTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdPropertyTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/fixtures/SamplePortfolioFixture.java delete mode 100644 src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/testinfra/PostgreSqlTestContainer.java delete mode 100644 src/sample-portfolio/src/test/resources/application-test.yml delete mode 100644 src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json delete mode 100644 src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json delete mode 100644 src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml index 1991f07..51e6732 100644 --- a/.github/ci-gate-matrix.yml +++ b/.github/ci-gate-matrix.yml @@ -178,13 +178,6 @@ gates: workflow: object-storage-qualification.yml job: minio-managed-contract execution: explicit - - id: poster-image-migration - release_blocking: true - mechanism: gradle-custom-task - ref: posterImageMigrationTest - workflow: object-storage-qualification.yml - job: poster-image-v7-migration - execution: explicit - id: object-storage-minio-managed-fault release_blocking: conditional mechanism: gradle-custom-task diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh index aa488dd..264b925 100644 --- a/.github/scripts/verify-gate-matrix.sh +++ b/.github/scripts/verify-gate-matrix.sh @@ -25,10 +25,11 @@ fi readonly REPO_ROOT readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" # Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to -# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening, +# catch, so growing the matrix is an explicit edit here. 37 after removing the sample-only Poster +# migration gate; the HTTP Client platform hardening # which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime* # claim, distinct from the API-surface scan that was standing in for it. -readonly EXPECTED_GATE_COUNT=38 +readonly EXPECTED_GATE_COUNT=37 if [[ ! -f "${MATRIX}" ]]; then printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2 diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh index 8dd8fe7..c926497 100755 --- a/.github/scripts/verify-gradle-wrapper.sh +++ b/.github/scripts/verify-gradle-wrapper.sh @@ -26,7 +26,7 @@ readonly EXPECTED_WORKFLOW_LOCK=( 'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml' '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' - '64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' + '2f1c46cc8b7b39890e5debe90f78d19b9347ed53f499b282539dae88aefdd574 .github/workflows/object-storage-qualification.yml' 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml' ) readonly EXPECTED_WRAPPER_PROPERTIES=( diff --git a/.github/workflows/object-storage-qualification.yml b/.github/workflows/object-storage-qualification.yml index 4f256d1..ba8fdcc 100644 --- a/.github/workflows/object-storage-qualification.yml +++ b/.github/workflows/object-storage-qualification.yml @@ -19,26 +19,6 @@ env: TESTCONTAINERS_REUSE_ENABLE: "false" jobs: - poster-image-v7-migration: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 - with: - distribution: temurin - java-version: "21.0.11+10" - cache: gradle - cache-dependency-path: | - src/**/*.gradle - src/**/gradle-wrapper.properties - src/**/gradle.lockfile - - name: Run non-skipping Poster image migration qualification - working-directory: src - run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace - minio-managed-contract: runs-on: ubuntu-latest steps: diff --git a/AGENTS.md b/AGENTS.md index 6c72383..df99bc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ 이 저장소는 단순한 예제 블로그 애플리케이션이 아니라, Java 21 + Spring Boot 4.0.0 + Gradle 멀티모듈 기반의 Clean Architecture 템플릿이다. -기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 엔지니어링 작업 기록 게시판)에 격리한다. 새 프로젝트를 시작할 때는 도메인 이름, 패키지, 엔티티, 유스케이스를 교체할 수 있지만, 모듈 경계와 의존성 방향은 유지해야 한다. +기본 패키지는 `dev.caskeleton`이며 구체적인 예시 도메인은 제거되어 있다. 새 프로젝트를 시작할 때는 production 모듈에 도메인, 엔티티, 유스케이스를 추가할 수 있지만, 모듈 경계와 의존성 방향은 유지해야 한다. ## Prime Directive @@ -49,8 +49,8 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어 ## Gradle 정책 권위 -- `src/config/architecture/modules.json`: 정확히 19개 leaf의 ID, repository-relative 소스 경로, - Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime +- `src/config/architecture/modules.json`: 정확히 18개 leaf의 ID, repository-relative 소스 경로, + Gradle path, 허용 production project dependency edge, production composition root의 실제 runtime membership - `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping - `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의 @@ -102,7 +102,7 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit ## 모듈 책임 -19개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은 +18개 leaf 모듈의 ID, 실제 소스 경로, Gradle path, 허용 production 의존성, runtime membership은 `src/config/architecture/modules.json`이 SSOT다. focused test는 소유 leaf의 `gradle_path`에서 파생한다. 이 문서는 leaf 목록을 복제하지 않고 family 책임만 정의한다. 작업 파일에서는 가장 가까운 `src/**/CLAUDE.md`를 함께 읽는다. @@ -118,7 +118,6 @@ commit 정책은 모든 플랫폼에서 `human-only`이며 agent는 stage/commit file server, HTTP client, identifier 능력을 port 뒤에서 구현한다. adapter 간 허용 edge는 registry만 따른다. - `shared-contract`: skeleton-wide 운영 계약. business/domain 개념 저장 금지. -- `sample-portfolio`: 샘플/fixture consumer. production leaf가 의존하면 안 된다. - `app-bootstrap`: Spring Boot entrypoint와 composition root. 비즈니스 유스케이스 금지. ## 의존성 방향 @@ -129,7 +128,6 @@ family 수준 기본 방향: app-bootstrap -> adapter:inbound:* -> application-core -> domain-core app-bootstrap -> adapter:outbound:* -> application-core -> domain-core runtime modules -> shared-contract -sample-portfolio -> registered runtime leaves (fixture consumer only) ``` 개별 edge는 `src/config/architecture/modules.json`의 `allowed_dependencies`가 유일한 목록이다. @@ -182,7 +180,7 @@ cd src ``` 소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test -명령을 파생한다. root 문서에 19개 명령 목록을 복제하지 않는다. +명령을 파생한다. root 문서에 18개 명령 목록을 복제하지 않는다. ## 설정과 런타임 @@ -208,7 +206,7 @@ Docker/runtime 규칙: 1. `src/settings.gradle`의 `rootProject.name`을 새 프로젝트명으로 바꾼다. 2. Java package `dev.caskeleton`을 새 organization/project package로 바꾼다. 3. `CaSkeletonApplication` 이름을 새 애플리케이션 이름으로 바꾼다. -4. production 모듈에는 목표 도메인의 entity, repository port, use case, adapter만 추가하고, 예시 코드는 `sample-portfolio`에 격리한다. +4. production 모듈에는 목표 도메인의 entity, repository port, use case, adapter만 추가한다. 5. 모듈 이름과 경계는 유지한다. 6. Docker image/application 이름을 새 프로젝트 기준으로 수정한다. 7. `.env`, `.env.local`, `application.yml`의 예시 값을 새 런타임 요구사항에 맞춘다. diff --git a/CLAUDE.md b/CLAUDE.md index 377f21c..8de208c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,9 +21,9 @@ If this summary drifts from `AGENTS.md`, `AGENTS.md` wins and this summary must ## Gradle policy authorities -- `src/config/architecture/modules.json`: exactly 19 leaf identities, repository-relative source +- `src/config/architecture/modules.json`: exactly 18 leaf identities, repository-relative source paths, Gradle paths, allowed production project dependency edges, and the exact runtime - memberships of both composition roots. + membership of the production composition root. - `src/settings.gradle`: fail-closed registry validation, project inclusion, and directory mapping. - `src/build.gradle`: `verifyCleanArchitectureDependencies` and the other architecture-wide verification tasks. @@ -43,7 +43,7 @@ count. ## Module families -`src/config/architecture/modules.json` owns the complete 19-leaf list. Root guidance summarizes +`src/config/architecture/modules.json` owns the complete 18-leaf list. Root guidance summarizes families; the nearest `src/**/CLAUDE.md` owns local rules. | Family | Responsibility | Stable dependency direction | @@ -54,7 +54,6 @@ families; the nearest `src/**/CLAUDE.md` owns local rules. | `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered | | `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge | | `shared-contract` | Skeleton-wide operational contracts | Java stdlib only | -| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency | | `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves | Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table. diff --git a/README.md b/README.md index e8574ac..1e36963 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Initialized from `clean-architecture-backend-template` revision `0a6dd0e419620683de48f69b5c6d22d9964b6f44` as a tracked snapshot. Template updates are applied explicitly and recorded in `template.lock.json`. -ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스만 교체하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 `dev.caskeleton`이며, 예시 도메인은 production 모듈이 아니라 `sample-portfolio` 모듈(WorkLog 작업 기록 게시판)에 격리합니다. +ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean Architecture 백엔드 템플릿입니다. fork해서 도메인·패키지·엔티티·유스케이스를 추가하면 새 서비스를 시작할 수 있고, 모듈 경계와 의존 방향은 그대로 유지합니다. 기본 패키지는 `dev.caskeleton`이며, 구체적인 예시 도메인은 제거되어 있습니다. 이 문서는 전체 구조와 첫 실행만 다룹니다. 모듈별 상세 규칙과 설계 근거는 각 모듈의 README와 `CLAUDE.md`가, 빌드·환경 변수 상세는 [src/README.md](src/README.md)가 소유합니다. @@ -16,7 +16,6 @@ ca-skeleton은 Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 기반의 Clean A app-bootstrap -> adapter:inbound:* -> application-core -> domain-core app-bootstrap -> adapter:outbound:* -> application-core -> domain-core 모든 런타임 모듈 -> shared-contract -sample-portfolio -> 등록된 런타임 리프 (fixture 소비자 전용) ``` family 수준의 책임은 다음과 같습니다. @@ -30,9 +29,8 @@ family 수준의 책임은 다음과 같습니다. | `adapter:outbound:*` | support(공유 베이스)·messaging·cache·notification·object storage·file·HTTP client·identifier 능력을 port 뒤에서 구현. 외부 연동 어댑터(messaging·cache·notification·HTTP client)는 기본 비활성 | | `shared-contract` | skeleton 전역 운영 계약. business/domain 개념 저장 금지 | | `app-bootstrap` | Spring Boot entrypoint와 composition root | -| `sample-portfolio` | WorkLog 예시 도메인(fixture/reference). production이 의존하지 않음 | -정확한 19개 leaf 목록과 각 leaf의 Gradle path·소스 경로·허용 production 의존 edge는 +정확한 18개 leaf 목록과 각 leaf의 Gradle path·소스 경로·허용 production 의존 edge는 [src/config/architecture/modules.json](src/config/architecture/modules.json)이 SSOT입니다. focused test는 해당 Gradle path에서 `./gradlew :test --console=plain` 형태로 파생하며, root 문서나 기억에서 개별 leaf edge를 추론하지 않습니다. @@ -46,7 +44,7 @@ cd src ./gradlew bootstrap ``` -`bootstrap`은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), sample 격리 검증, `GET /api/healthcheck` HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다. +`bootstrap`은 compile 검사, PostgreSQL Compose 기동, 애플리케이션 이미지 build·기동(startup Flyway 포함), `GET /api/healthcheck` HTTP smoke를 순서대로 실행합니다. 각 단계가 별도 Gradle task라 실패 단계가 task 이름으로 드러납니다. 기동을 확인하려면 health endpoint를 호출합니다. ```bash curl -fsS http://localhost:8080/api/healthcheck @@ -91,10 +89,10 @@ cd src 애플리케이션 이름 등 나머지 rename 단계는 위 체크리스트를 따릅니다. -3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다. +3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 4. 모듈 이름과 경계는 그대로 유지합니다. -검증은 sample-on과 sample-off를 모두 통과시킵니다. +검증은 전체 테스트와 sample-off 재유입 방지 계약을 모두 통과시킵니다. ```bash cd src @@ -102,7 +100,7 @@ cd src ./gradlew :app-bootstrap:sampleOffTest ``` -`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다. +`sampleOffTest`는 삭제된 샘플 타입이 production bootstrap classpath에 다시 들어오지 않는지 검증합니다. ## 아키텍처 규칙과 검증 @@ -129,7 +127,7 @@ cd src ## 더 알아보기 - 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md) -- 모듈 레지스트리(19개 leaf SSOT): [src/config/architecture/modules.json](src/config/architecture/modules.json) +- 모듈 레지스트리(18개 leaf SSOT): [src/config/architecture/modules.json](src/config/architecture/modules.json) - 에이전트·기여자 작업 규칙: [AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md) - 빌드·릴리스 공급망 파이프라인은 현재 Mode B 복구 범위에 포함되지 않았다. 현재 저장소가 제공하는 canonical workflow는 품질·의존성 취약점·링크 검사이며, release/publish 자동화는 별도 diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index e631fb0..bb941e7 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -3063,6 +3063,11 @@ env_keys: required_test: cache-contract:redis-soft-hard-ttl-order - name: APP_CACHE_REDIS_TTL_JITTER + # DEPRECATED 2026-08-13: removing the installed sample portfolio also removed the only + # application.yml binding for this key; app-bootstrap has no TTL-jitter consumer. The row is + # retained so deployments still setting it can be warned until its scheduled removal. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: float default: 0.10 allowed_values: null diff --git a/src/Dockerfile.sample b/src/Dockerfile.sample deleted file mode 100644 index 9da94f4..0000000 --- a/src/Dockerfile.sample +++ /dev/null @@ -1,129 +0,0 @@ -# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894 -# ============================================================================= -# sample-portfolio standalone demo image — twin of src/Dockerfile. -# -# Builds and runs the REFERENCE app (SamplePortfolioApplication), not the -# production composition root (CaSkeletonApplication) that src/Dockerfile builds. -# The sample is the demo-friendly entrypoint: its application.yml self-provides -# defaults for every env placeholder, so the only external dependency it needs -# to boot is a reachable PostgreSQL (datasource + Flyway sample migrations). -# -# Build (no build-args required — this is a disposable demo, not a release artifact): -# docker build -f src/Dockerfile.sample src/ -t ca-sample:local -# -# Run (point APP_DATASOURCE_URL at a reachable Postgres; localhost default shown): -# docker run --rm -p 8080:8080 -p 9001:9001 \ -# -e APP_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/ca_skeleton \ -# ca-sample:local -# -# The multi-stage build, --parents descriptor glob, STRICT lock verification, -# non-root user, read-only-root-fs writable mounts, and JVM container ergonomics -# are all identical to src/Dockerfile — only the bootJar target differs. Keep the -# two files' builder stages in sync. -# ============================================================================= - -# Demo defaults so the image builds with zero build-args. Two format constraints from the root -# build.gradle configuration guard (feature-build-release-supply-chain-contract D1/D9): -# - RELEASE_VERSION must be bare MAJOR.MINOR.PATCH — no pre-release/build suffix (build.gradle L21). -# The "-sample" marker therefore lives only on BUILD_VERSION, which is a label, not a gradle prop. -# - GIT_SHA must be 7-40 hex chars (build.gradle L35); 0000000 is the placeholder. -ARG RELEASE_VERSION=0.0.0 -ARG BUILD_VERSION=0.0.0-sample -ARG GIT_SHA=0000000 -ARG SOURCE_URL=https://example.invalid/ca-tmpl-sample - -# ---- Stage 1: builder ------------------------------------------------------- -# Uses the full JDK only in the build stage, never in the final image. -FROM eclipse-temurin:21-jdk-jammy@sha256:801b7e1a9c4befaf82bf9a2a58025ef43a7694bbc84779187ad0524d84742772 AS builder - -ARG RELEASE_VERSION -ARG GIT_SHA - -WORKDIR /build/src - -# Copy the Gradle wrapper and every module's build descriptor + dependency lockfile FIRST, -# so the expensive dependency-resolution layer is cached and only re-runs when a build.gradle -# or gradle.lockfile changes (D8). `--parents` preserves each file's directory structure, so a -# single structure-preserving glob replaces a per-module COPY list: new modules are picked up -# automatically and this stage never drifts out of sync with settings.gradle. -# (Requires the labs Dockerfile frontend — see the `# syntax` directive at the top of this file.) -COPY gradlew ./ -COPY gradle/ gradle/ -COPY config/ ./config/ -COPY --parents settings.gradle build.gradle **/build.gradle **/gradle.lockfile ./ - -# Resolve every module configuration in STRICT mode (no --write-locks in a demo build either). -RUN test -n "${RELEASE_VERSION}" \ - && test -n "${GIT_SHA}" \ - && ./gradlew verifyDependencyLocks --no-daemon --quiet \ - -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}" - -# Copy full source and stage the executable sample JAR at Gradle's declared Docker output path. -COPY . . -RUN ./gradlew :sample-portfolio:stageDockerJar --no-daemon -x test \ - -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}" - -# ---- Stage 2: runtime image ------------------------------------------------- -# JRE-only slim image (no full JDK in the demo image either). -FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime - -ARG BUILD_VERSION -ARG GIT_SHA -ARG SOURCE_URL - -# OCI image labels. Unlike the release image (src/Dockerfile), the demo image does NOT hard-fail -# on missing metadata — the ARG defaults above keep it buildable with no build-args. -LABEL org.opencontainers.image.title="caskeleton-sample" \ - org.opencontainers.image.description="ca-tmpl sample-portfolio reference/demo application" \ - org.opencontainers.image.source="${SOURCE_URL}" \ - org.opencontainers.image.revision="${GIT_SHA}" \ - org.opencontainers.image.version="${BUILD_VERSION}" - -# ---- Locale / timezone ------------------------------------------------------ -ENV TZ=UTC \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 - -# ---- Writable HOME under read-only root fs ---------------------------------- -ENV HOME=/tmp - -# ---- JVM ergonomics --------------------------------------------------------- -# Identical to src/Dockerfile: container-aware heap, fail-fast on OOM, heap dump to a -# writable mount, and Tomcat temp redirected to /tmp for a read-only root filesystem. -ENV JAVA_TOOL_OPTIONS="\ - -XX:MaxRAMPercentage=75 \ - -XX:+UseContainerSupport \ - -XX:+ExitOnOutOfMemoryError \ - -XX:+HeapDumpOnOutOfMemoryError \ - -XX:HeapDumpPath=/var/tmp/heap \ - -Dserver.tomcat.basedir=/tmp" - -# ---- Filesystem layout (read-only root filesystem) -------------------------- -# At runtime /var/tmp/heap and /tmp MUST be writable mounts (tmpfs/emptyDir). -RUN mkdir -p /var/tmp/heap && chmod 1777 /var/tmp/heap - -# ---- Non-root user ---------------------------------------------------------- -RUN groupadd --system --gid 1000 app \ - && useradd --system --uid 1000 --gid app --no-create-home --shell /usr/sbin/nologin app - -WORKDIR /app - -COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/application.jar app.jar - -USER app - -# ---- Ports ------------------------------------------------------------------ -# 8080 — application HTTP port -# 9001 — management / actuator port -EXPOSE 8080 9001 - -# ---- Health check ----------------------------------------------------------- -# Actuator readiness probe on the management port (9001). Ignored by Kubernetes, -# which uses its own probes — kept for docker/compose parity with src/Dockerfile. -HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider \ - http://localhost:9001/actuator/health/readiness || exit 1 - -# ---- Entrypoint ------------------------------------------------------------- -# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest. -ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/src/README.md b/src/README.md index d229319..f3df746 100644 --- a/src/README.md +++ b/src/README.md @@ -17,7 +17,7 @@ | 게이트 | 하는 일 | | --- | --- | | `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 | -| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 | +| `verifyRuntimeModuleMembership` | registry의 production composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 | | `verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env` 가 어긋나지 않는지 검사 | | `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 | | `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 | @@ -26,7 +26,7 @@ ### Local bootstrap `./gradlew bootstrap`은 `bootstrapCompile` → `bootstrapDependencies` → -`bootstrapMigrateAndStart` → `bootstrapSampleContract` → `bootstrapSmoke`를 순서대로 실행합니다. +`bootstrapMigrateAndStart` → `bootstrapSmoke`를 순서대로 실행합니다. DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유하며, app startup Flyway가 끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의 SSOT이고 bootstrap이 별도 env template을 만들지 않습니다. @@ -88,10 +88,10 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 ### `verifyRuntimeModuleMembership` - **하는 일.** 같은 registry의 `runtime_compositions`와 각 leaf의 `runtime_memberships`를 읽어 - `app-bootstrap`/`sample-portfolio`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly` + `app-bootstrap`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly` project dependency와 정확히 대조합니다. -- **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만 두 - shipped runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는 +- **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만 + production runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는 실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다. - **변경 규칙.** production edge를 추가하거나 제거할 때 `allowed_dependencies`, `runtime_memberships`, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면 `check`가 실패합니다. @@ -282,10 +282,6 @@ ca-skeleton: - 기존 `APP_OUTBOUND_HTTP_*`와 `app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`, `app-bootstrap`의 application YAML, env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와 무관하게 기동을 거부합니다. -- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아 - 있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은 - `verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를 - 가리킨다고 읽히지 않도록 범위를 명시합니다. - legacy JDK facade가 필요한 fork만 canonical composition 밖에서 `OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다. timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider diff --git a/src/adapter/inbound/graphql/CLAUDE.md b/src/adapter/inbound/graphql/CLAUDE.md index 0ee7b72..0bffe7a 100644 --- a/src/adapter/inbound/graphql/CLAUDE.md +++ b/src/adapter/inbound/graphql/CLAUDE.md @@ -20,7 +20,7 @@ Package root: `dev.caskeleton.adapter.inbound.graphql`. 자동 합성/바인딩하도록 얹는 얇은 계층이다. - feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/ `@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.** -- classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를 +- classpath opt-in: 현재 `app-bootstrap` production runtime 은 이 leaf 를 의존하지 않는다. 실제 채택 시 composition root 가 GraphQL leaf 와 인증/인가·CORS 정책, GraphiQL/introspection 운영 설정을 함께 명시해야 한다. diff --git a/src/adapter/inbound/graphql/README.md b/src/adapter/inbound/graphql/README.md index ce6966a..c0fddd3 100644 --- a/src/adapter/inbound/graphql/README.md +++ b/src/adapter/inbound/graphql/README.md @@ -19,18 +19,17 @@ Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하 ## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리 -스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** 향후 composition root 가 이 모듈을 classpath 에 +스켈레톤은 구체적인 제품 기능을 이름으로 알지 못한다. 향후 composition root 가 이 모듈을 classpath 에 명시적으로 채택하고 feature 를 추가하면 Spring for GraphQL 이 다음 두 축으로 합성할 수 있다: -- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의 - 향후 `worklog.graphqls` 같은 feature 스키마는 스켈레톤의 `skeleton.graphqls` 와 합쳐진다. +- **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. 제품 모듈의 feature 스키마는 + 스켈레톤의 `skeleton.graphqls` 와 합쳐진다. - **resolver(핸들러)**: 컨텍스트의 모든 `@Controller` 의 `@QueryMapping`/`@MutationMapping` 메서드를 바인딩한다. 향후 feature 의 GraphQL controller 는 스켈레톤을 수정하지 않고 등록할 수 있다. -현재 `app-bootstrap` 과 `sample-portfolio` 의 production runtime 은 이 leaf 를 의존하지 않는다. -즉 이 모듈은 **classpath opt-in** 이며, 현재 sample 에 feature GraphQL 스키마/controller 가 있다는 -뜻이 아니다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다. +현재 `app-bootstrap` production runtime 은 이 leaf 를 의존하지 않는다. 즉 이 모듈은 +**classpath opt-in** 이다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다. ## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제 diff --git a/src/adapter/inbound/web/CLAUDE.md b/src/adapter/inbound/web/CLAUDE.md index 90e5781..7d1f282 100644 --- a/src/adapter/inbound/web/CLAUDE.md +++ b/src/adapter/inbound/web/CLAUDE.md @@ -58,8 +58,8 @@ also drives the runtime patterns reference implementations must follow. `Optional` wrappers on request records. - **B3 — Mapper-internal failures.** Map record canonical-constructor `IllegalArgumentException`, MapStruct generated NPE, ACL normalization - failures, etc. by throwing `MappingException` (sample implementation in - `sample-portfolio`); the global handler routes it to `MAPPING_FAILED` (HTTP 400), + failures, etc. by throwing `MappingException`; the global handler routes it to + `MAPPING_FAILED` (HTTP 400), never to `BAD_PARAMETER` or `INTERNAL_ERROR`. Plain `IllegalArgumentException` remains `BAD_PARAMETER` for non-mapper callers. - **B4 — Validation layering.** Class-level Bean Validation constraints belong @@ -93,16 +93,13 @@ Domain `@RestControllerAdvice` in a consuming module must be annotated `@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this module's base `GlobalExceptionHandler`), because the base handler's catch-all `@ExceptionHandler(Exception.class)` would otherwise resolve domain exceptions -to `INTERNAL_ERROR`. See `sample-portfolio`'s `DomainExceptionHandler` for the -pattern. +to `INTERNAL_ERROR`. The base operational handler (`error/GlobalExceptionHandler`), the error-code contract (`dev.caskeleton.shared.error.ApiErrorCode` + `OperationalError`), the `error/ErrorResponseFactory`, and the `envelope/EnvelopeBodyAdvice` now live in -production modules (`adapter:inbound:web` / `shared-contract`), so the running application -provides them without depending on `sample-portfolio`. Domain-specific exception -handlers and error codes live in the consuming module (see sample's -`DomainExceptionHandler` / `PortfolioErrorCode`). +production modules (`adapter:inbound:web` / `shared-contract`). Domain-specific exception +handlers and error codes live in the consuming module. ## Schema / serialization contract diff --git a/src/adapter/inbound/web/README.md b/src/adapter/inbound/web/README.md index f4f0854..aeb4aee 100644 --- a/src/adapter/inbound/web/README.md +++ b/src/adapter/inbound/web/README.md @@ -225,8 +225,7 @@ commit-aware response wrapper 계약을 구현한다. 또한 이 모듈은 HTML ### EnvelopeBodyAdvice - 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상 `{success, data | error, traceId}` 로 보장한다. -- 위치: sample 모듈이 아니라 adapter-web. 실행 앱은 adapter-web 에 의존하지만 sample-portfolio 에는 의존하지 - 않으므로, 응답 래핑이 실제로 동작하려면 여기 있어야 한다. +- 위치: adapter-web. 실행 앱이 이 모듈을 의존하므로 응답 래핑이 실제 runtime에 적용된다. ### CacheControlFilter - 스켈레톤 기본 HTTP 캐시 정책. diff --git a/src/adapter/inbound/websocket/CLAUDE.md b/src/adapter/inbound/websocket/CLAUDE.md index b47a818..c512dfe 100644 --- a/src/adapter/inbound/websocket/CLAUDE.md +++ b/src/adapter/inbound/websocket/CLAUDE.md @@ -66,7 +66,7 @@ The broadcaster sends only when exactly one projector matches. Zero or duplicate projection failures, invalid event types, null fields, and over-limit maps are dropped. The wire envelope contains only `type`, bounded string `fields`, and `occurredAt`. -No current `sample-portfolio` feature publishes a WebSocket event or contributes a projector. +No current production feature publishes a WebSocket event or contributes a projector. That is a future adoption example, not an existing runtime feature. ## Evidence and limits diff --git a/src/adapter/inbound/websocket/README.md b/src/adapter/inbound/websocket/README.md index 5205f73..979404c 100644 --- a/src/adapter/inbound/websocket/README.md +++ b/src/adapter/inbound/websocket/README.md @@ -5,8 +5,8 @@ live in [CLAUDE.md](CLAUDE.md); this document records why the P1 boundary has th ## Opt-in instead of accidental exposure -The leaf is built and tested but is not part of `app-bootstrap` or `sample-portfolio` production -runtime membership. Even after a future composition adds it, `ca-skeleton.websocket.enabled=false` +The leaf is built and tested but is not part of `app-bootstrap` production runtime membership. +Even after a future composition adds it, `ca-skeleton.websocket.enabled=false` keeps its configuration and broadcaster absent. Enabling requires explicit non-wildcard origins, so merely adding the artifact cannot expose a wildcard STOMP broker. diff --git a/src/adapter/outbound/fileserver/CLAUDE.md b/src/adapter/outbound/fileserver/CLAUDE.md index 0526605..1341e64 100644 --- a/src/adapter/outbound/fileserver/CLAUDE.md +++ b/src/adapter/outbound/fileserver/CLAUDE.md @@ -42,7 +42,7 @@ Package root: `dev.caskeleton.adapter.outbound.fileserver`. Driven (outbound) ad ## Forbidden -- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio` +- Inbound adapters, sibling outbound adapters, persistence, or `app-bootstrap` (ArchUnit `OUTBOUND_ADAPTERS_*` family rules). - Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`. - Advertising `FILE_AND_DIRECTORY_SYNC` as physical device/controller/replica/site power-loss diff --git a/src/adapter/outbound/identifier/CLAUDE.md b/src/adapter/outbound/identifier/CLAUDE.md index c30c02f..da1c2c6 100644 --- a/src/adapter/outbound/identifier/CLAUDE.md +++ b/src/adapter/outbound/identifier/CLAUDE.md @@ -37,8 +37,7 @@ build choices) live in [README.md](README.md). - Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`; `.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단. -- inbound adapters, persistence adapters, other outbound leaves, `app-bootstrap`, - `sample-portfolio`. +- inbound adapters, persistence adapters, other outbound leaves, or `app-bootstrap`. - External IO (HTTP / messaging / cache / DB) — that belongs in `adapter-outbound`. ## Test diff --git a/src/adapter/outbound/objectstorage/CLAUDE.md b/src/adapter/outbound/objectstorage/CLAUDE.md index 848a96f..28cdb4c 100644 --- a/src/adapter/outbound/objectstorage/CLAUDE.md +++ b/src/adapter/outbound/objectstorage/CLAUDE.md @@ -34,7 +34,7 @@ readiness live in [README.md](README.md) and ## Forbidden -- Inbound adapters, sibling outbound adapters, persistence, app-bootstrap, or sample-portfolio +- Inbound adapters, sibling outbound adapters, persistence, or app-bootstrap dependencies. - Provider keys, paths, locators, SDK types, Spring types, or control-record types leaking into application-core. diff --git a/src/adapter/outbound/persistence-jpa/CLAUDE.md b/src/adapter/outbound/persistence-jpa/CLAUDE.md index 6da5ad6..a04ea2b 100644 --- a/src/adapter/outbound/persistence-jpa/CLAUDE.md +++ b/src/adapter/outbound/persistence-jpa/CLAUDE.md @@ -68,10 +68,9 @@ Adapters that serve one optional capability carry that capability's switch, unli module. The `fileserver` package is the current case: every `Jpa*` adapter there is annotated `@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")`. -Without the gate a composition root that merely includes this module builds those beans, and each of -them needs collaborators only the Fileserver configuration supplies — which is how `sample-portfolio` -came to fail on a `FileStateMachine` it has no use for. A store for a capability nobody enabled -should not exist. +Without the gate a composition root that merely includes this module builds those beans, although +their collaborators exist only when Fileserver configuration is selected. A store for a capability +nobody enabled should not exist. ## Allowed @@ -174,7 +173,7 @@ framework-neutral `shared.error.PersistenceFailureException` carrying one of the Audit metadata (`created_at` / `updated_at` / `created_by` / `updated_by`, D3) is an infrastructure concern that must never reach `domain-core` (D2). It lives only on the `audit/AuditableEntity` `@MappedSuperclass`; a domain aggregate persistence entity opts in -by extending it (D6 — e.g. the sample `WorkLogEntity`). The domain aggregate itself carries +by extending it (D6). The domain aggregate itself carries zero audit fields, enforced by ArchUnit `domain_is_pure` (no `jakarta.persistence..`) plus `domain_entities_do_not_carry_audit_fields` (no `createdAt`/`updatedAt`/`createdBy`/`updatedBy` fields under `..domain..`). diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntityTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntityTest.java index df2a7c9..f228c54 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntityTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/audit/AuditableEntityTest.java @@ -11,8 +11,7 @@ import org.junit.jupiter.api.Test; * (feature-persistence-auditing-contract D1/D3/D4/D5). * *

Exercised through a tiny concrete subclass because {@link AuditableEntity} is a - * {@code @MappedSuperclass} (JPA cannot instantiate it standalone, and the production subclass - * {@code WorkLogEntity} lives in the sample module). + * {@code @MappedSuperclass} (JPA cannot instantiate it standalone). */ class AuditableEntityTest { diff --git a/src/adapter/outbound/persistence-mongo/CLAUDE.md b/src/adapter/outbound/persistence-mongo/CLAUDE.md index c168845..1bd4852 100644 --- a/src/adapter/outbound/persistence-mongo/CLAUDE.md +++ b/src/adapter/outbound/persistence-mongo/CLAUDE.md @@ -32,7 +32,7 @@ Data MongoDB infrastructure. Design rationale lives in [README.md](README.md). ## Forbidden -- Inbound adapters, sibling outbound adapters, `app-bootstrap`, `sample-portfolio` (ArchUnit +- Inbound adapters, sibling outbound adapters, or `app-bootstrap` (ArchUnit `OUTBOUND_ADAPTERS_*` family rules). - Shipping placeholder `Example*` document, repository, record, or adapter types in production. - Adding idempotency/outbox/lock on Mongo without a separately approved contract. diff --git a/src/app-bootstrap/CLAUDE.md b/src/app-bootstrap/CLAUDE.md index 078326f..6b874e9 100644 --- a/src/app-bootstrap/CLAUDE.md +++ b/src/app-bootstrap/CLAUDE.md @@ -20,7 +20,7 @@ Package root: `dev.caskeleton.bootstrap`. ## Allowed - Runtime leaves whose dependency edge is explicitly allowed and whose `runtime_memberships` - includes `app-bootstrap` in `src/config/architecture/modules.json`; do not duplicate the 19-leaf + includes `app-bootstrap` in `src/config/architecture/modules.json`; do not duplicate the 18-leaf dependency list here. - Spring Boot startup/runtime dependencies. - ArchUnit in tests. diff --git a/src/app-bootstrap/README.md b/src/app-bootstrap/README.md index a093e0f..2348f13 100644 --- a/src/app-bootstrap/README.md +++ b/src/app-bootstrap/README.md @@ -4,14 +4,14 @@ 이 모듈은 비즈니스 로직을 담지 않는다. Spring Boot 기동, 런타임 설정 바인딩, 기본 runtime 모듈 간 최종 와이어링, 그리고 composition classpath를 대상으로 한 중앙 아키텍처 테스트만 둔다. -19개 leaf 전체의 프로젝트 edge와 두 composition root의 실제 runtime membership은 JSON registry를 +18개 leaf 전체의 프로젝트 edge와 production composition root의 실제 runtime membership은 JSON registry를 읽는 Gradle gate가 별도로 검사한다. 허용/금지 의존, 책임 범위, 테스트 명령 같은 **모듈 규칙**의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다. 기본 composition은 `build.gradle`의 main project dependency와 registry의 `runtime_memberships=["app-bootstrap"]`가 정확히 일치해야 한다. GraphQL, gRPC, WebSocket, -MongoDB leaf의 membership은 비어 있어 두 shipped runtime에 포함되지 않는다. object storage는 -sample runtime에만 있고 file server는 기본 app runtime에 이미 포함된다. +MongoDB와 object storage leaf의 membership은 비어 있어 production runtime에 포함되지 않는다. +file server는 기본 app runtime에 이미 포함된다. `conditionalTransportTest`는 GraphQL/gRPC/WebSocket 세 leaf를 test-only classpath에서 함께 resolve하는 명시적 qualification composition이다. main `implementation`/`runtimeOnly` edge가 아니므로 @@ -870,10 +870,6 @@ ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해 - `spring-cloud-context` — `@RefreshScope` 금지(no-refresh-scope) fixture 용. Spring Cloud 는 production 에 없고 규칙은 애너테이션을 FQN 문자열로 참조한다. BOM 이 spring-cloud 좌표를 관리하지 않아 버전을 명시 고정한다. -- **`testImplementation project(':sample-portfolio')`** — ArchUnit 이 템플릿 reference 구현을 분석하려고 - test 경로에만 둔다. **production 은 절대 sample-portfolio 에 의존 금지** — - `production_code_does_not_depend_on_sample_portfolio` 규칙이 강제한다. - ### 빌드 설정 - **test JVM UTC 고정(`-Duser.timezone=UTC`)** — `RuntimeHealthLifecycleContractTest`가 `TimeZone.getDefault().getID() == "UTC"`를 단언해, 호스트 로케일과 무관하게 타임스탬프 산술이 diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index 987bf99..c96b1be 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -4,25 +4,6 @@ apply plugin: 'org.springframework.boot' apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle" -// The sample fixture (sampleFixture -> sample-portfolio -> objectstorage) pulls software.amazon.awssdk:s3 -// onto the sample-on test classpath; its version is managed by the AWS SDK v2 BOM (NOT the Spring Boot -// BOM). Import that BOM at this module's scope so the transitive s3 dependency resolves for the ArchUnit -// sample-on analysis. AWS-SDK version SSOT = ext.awsSdkVersion. -dependencyManagement { - imports { - mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}" - } -} - -// feature-sample-removal-adoption-contract D7 — ordinary tests may inspect the sample fixture, -// while sampleOffTest compiles the same core suite without the sample project on either classpath. -configurations { - sampleFixture { - canBeConsumed = false - canBeResolved = false - } -} - sourceSets { sampleOffTest { java.srcDirs = sourceSets.test.java.srcDirs @@ -44,8 +25,6 @@ sourceSets { } configurations { - testCompileClasspath.extendsFrom sampleFixture - testRuntimeClasspath.extendsFrom sampleFixture sampleOffTestImplementation.extendsFrom testImplementation sampleOffTestCompileOnly.extendsFrom testCompileOnly sampleOffTestRuntimeOnly.extendsFrom testRuntimeOnly @@ -115,8 +94,6 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-json' // test-only: parses checked-in Messaging capability registries for the fail-closed drift gate. testImplementation 'org.yaml:snakeyaml' - // sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it. - sampleFixture project(':sample-portfolio') // test-only: ArchUnit violation fixtures intentionally import forbidden types. See README. testCompileOnly 'org.springframework:spring-tx' // test-only: streaming/websocket violation fixtures import these forbidden packages. See README. @@ -213,21 +190,6 @@ tasks.named('check') { dependsOn tasks.named('functionalTest') } -// feature-developer-experience-contract D2/D7 delegation: the DX entrypoint verifies production -// isolation and the sample-on build contract. sample-off is the separate sampleOffTest task. -tasks.register('bootstrapSampleContract', Test) { - group = 'developer experience' - description = 'Runs the delegated sample production-isolation/build contract for bootstrap.' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform() - filter { - includeTestsMatching 'dev.caskeleton.bootstrap.contract.SampleRemovalSmokeContractTest' - } - outputs.upToDateWhen { false } - jvmArgs '-Duser.timezone=UTC' -} - bootJar { mainClass = 'dev.caskeleton.bootstrap.CaSkeletonApplication' } diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index 6bd6f36..a4d058c 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -1,42 +1,29 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-classic:1.5.34=sampleFixture ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.34=sampleFixture com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.21.4=sampleFixture com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.21.4=sampleFixture -com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.21.4=sampleFixture com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4=sampleFixture -com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.21.4=sampleFixture com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4=sampleFixture -com.fasterxml.jackson.module:jackson-module-parameter-names:2.21.4=sampleFixture com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.21.4=sampleFixture com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.3=sampleFixture com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.f4b6a3:uuid-creator:6.1.1=sampleFixture,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs -com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath com.google.api.grpc:proto-google-common-protos:2.41.0=conditionalTransportTestRuntimeClasspath com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor @@ -72,24 +59,19 @@ com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClas com.nimbusds:content-type:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:lang-tag:1.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.squareup.okhttp3:okhttp:4.12.0=sampleFixture com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.squareup.okio:okio-jvm:3.6.0=sampleFixture com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.squareup.okio:okio:3.6.0=sampleFixture -com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.tngtech.archunit:archunit-junit5-api:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.zaxxer:HikariCP:6.3.3=sampleFixture com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -98,16 +80,16 @@ commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeC commons-io:commons-io:2.21.0=spotbugs commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.grpc:grpc-api:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-context:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-core:1.68.1=conditionalTransportTestRuntimeClasspath @@ -118,21 +100,13 @@ io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.micrometer:context-propagation:1.1.4=sampleFixture io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.15.12=sampleFixture io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.15.12=sampleFixture io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-jakarta9:1.15.12=sampleFixture io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.15.12=sampleFixture io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-registry-prometheus:1.15.12=sampleFixture io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing-bridge-otel:1.5.12=sampleFixture io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing:1.5.12=sampleFixture io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-buffer:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-base:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -142,11 +116,8 @@ io.netty:netty-codec-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspat io.netty:netty-codec-http2:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-http3:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-http:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-marshalling:4.2.17.Final=testRuntimeClasspath io.netty:netty-codec-native-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-protobuf:4.2.17.Final=testRuntimeClasspath io.netty:netty-codec-socks:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec:4.2.17.Final=testRuntimeClasspath io.netty:netty-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-handler-proxy:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-handler:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -158,113 +129,78 @@ io.netty:netty-transport-classes-epoll:4.2.17.Final=productionRuntimeClasspath,r io.netty:netty-transport-native-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-transport-native-unix-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-transport:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.opentelemetry.semconv:opentelemetry-semconv:1.32.0=sampleFixture io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-api:1.49.0=sampleFixture io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-context:1.49.0=sampleFixture io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-common:1.49.0=sampleFixture io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-otlp-common:1.49.0=sampleFixture io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-otlp:1.49.0=sampleFixture io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.49.0=sampleFixture io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-extension-trace-propagators:1.49.0=sampleFixture io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-common:1.49.0=sampleFixture io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.49.0=sampleFixture io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-logs:1.49.0=sampleFixture io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-metrics:1.49.0=sampleFixture io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-trace:1.49.0=sampleFixture io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk:1.49.0=sampleFixture io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.7.19=sampleFixture io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-config:1.3.10=sampleFixture io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-core:1.3.10=sampleFixture io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-exposition-formats:1.3.10=sampleFixture io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-exposition-textformats:1.3.10=sampleFixture io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-model:1.3.10=sampleFixture io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-tracer-common:1.3.10=sampleFixture io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.smallrye:jandex:3.2.0=sampleFixture -io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=sampleFixture io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.29=sampleFixture io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.29=sampleFixture io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:2.1.1=sampleFixture +jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -jakarta.persistence:jakarta.persistence-api:3.1.0=sampleFixture +jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.validation:jakarta.validation-api:3.0.2=sampleFixture +jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=sampleFixture javax.inject:javax.inject:1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs -org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.apache.httpcomponents:httpclient:4.5.13=checkstyle,testRuntimeClasspath -org.apache.httpcomponents:httpcore:4.4.16=checkstyle,testRuntimeClasspath +org.apache.httpcomponents:httpclient:4.5.13=checkstyle +org.apache.httpcomponents:httpcore:4.4.16=checkstyle org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath -org.apache.logging.log4j:log4j-api:2.24.3=sampleFixture org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.24.3=sampleFixture org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:10.1.55=sampleFixture org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:10.1.55=sampleFixture org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:10.1.55=sampleFixture org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.aspectj:aspectjweaver:1.9.25.1=sampleFixture org.assertj:assertj-core:3.27.6=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath @@ -275,7 +211,7 @@ org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.eclipse.jetty.compression:jetty-compression-common:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.eclipse.jetty:jetty-alpn-client:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -284,32 +220,19 @@ org.eclipse.jetty:jetty-http:12.1.4=productionRuntimeClasspath,runtimeClasspath, org.eclipse.jetty:jetty-io:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.eclipse.jetty:jetty-util:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.flywaydb:flyway-core:11.7.2=sampleFixture org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.flywaydb:flyway-database-postgresql:11.7.2=sampleFixture org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.9=sampleFixture org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.9=sampleFixture org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.9=sampleFixture org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.hibernate.common:hibernate-commons-annotations:7.0.3.Final=sampleFixture +org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.hibernate.orm:hibernate-core:6.6.53.Final=sampleFixture org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hibernate.validator:hibernate-validator:8.0.3.Final=sampleFixture org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.logging:jboss-logging:3.6.3.Final=sampleFixture -org.jetbrains.kotlin:kotlin-stdlib-common:1.9.25=sampleFixture -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.25=sampleFixture -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.25=sampleFixture -org.jetbrains.kotlin:kotlin-stdlib:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture +org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -322,11 +245,11 @@ org.junit.platform:junit-platform-launcher:6.0.1=conditionalTransportTestRuntime org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath @@ -339,26 +262,18 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -org.postgresql:postgresql:42.7.11=sampleFixture org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.18=sampleFixture org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.18=sampleFixture org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:2.8.6=sampleFixture org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=sampleFixture org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.16=sampleFixture org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator:3.5.16=sampleFixture org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:3.5.16=sampleFixture org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -384,37 +299,26 @@ org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=produ org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-actuator:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-jpa:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-websocket:4.0.0=conditionalTransportTestRuntimeClasspath -org.springframework.boot:spring-boot-starter:3.5.16=sampleFixture org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -425,62 +329,36 @@ org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRu org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath -org.springframework.boot:spring-boot:3.5.16=sampleFixture org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath -org.springframework.data:spring-data-commons:3.5.13=sampleFixture org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-jpa:3.5.13=sampleFixture org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath -org.springframework.integration:spring-integration-core:6.5.10=sampleFixture org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-jdbc:6.5.10=sampleFixture org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.retry:spring-retry:2.0.13=sampleFixture -org.springframework.security:spring-security-config:6.5.11=sampleFixture org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:6.5.11=sampleFixture org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:6.5.11=sampleFixture org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:6.5.11=sampleFixture org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:6.5.11=sampleFixture org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-resource-server:6.5.11=sampleFixture org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:6.5.11=sampleFixture org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-aop:6.2.19=sampleFixture org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aspects:6.2.19=sampleFixture org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:6.2.19=sampleFixture org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:6.2.19=sampleFixture org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:6.2.19=sampleFixture org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:6.2.19=sampleFixture org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jcl:6.2.19=sampleFixture -org.springframework:spring-jdbc:6.2.19=sampleFixture org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:6.2.19=sampleFixture org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-orm:6.2.19=sampleFixture org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:6.2.19=sampleFixture org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:6.2.19=sampleFixture org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-webflux:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:6.2.19=sampleFixture org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-websocket:7.0.1=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -490,39 +368,8 @@ org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.4=sampleFixture org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -software.amazon.awssdk:annotations:2.30.0=testRuntimeClasspath -software.amazon.awssdk:apache-client:2.30.0=testRuntimeClasspath -software.amazon.awssdk:arns:2.30.0=testRuntimeClasspath -software.amazon.awssdk:auth:2.30.0=testRuntimeClasspath -software.amazon.awssdk:aws-core:2.30.0=testRuntimeClasspath -software.amazon.awssdk:aws-query-protocol:2.30.0=testRuntimeClasspath -software.amazon.awssdk:aws-xml-protocol:2.30.0=testRuntimeClasspath -software.amazon.awssdk:checksums-spi:2.30.0=testRuntimeClasspath -software.amazon.awssdk:checksums:2.30.0=testRuntimeClasspath -software.amazon.awssdk:crt-core:2.30.0=testRuntimeClasspath -software.amazon.awssdk:endpoints-spi:2.30.0=testRuntimeClasspath -software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=testRuntimeClasspath -software.amazon.awssdk:http-auth-aws:2.30.0=testRuntimeClasspath -software.amazon.awssdk:http-auth-spi:2.30.0=testRuntimeClasspath -software.amazon.awssdk:http-auth:2.30.0=testRuntimeClasspath -software.amazon.awssdk:http-client-spi:2.30.0=testRuntimeClasspath -software.amazon.awssdk:identity-spi:2.30.0=testRuntimeClasspath -software.amazon.awssdk:json-utils:2.30.0=testRuntimeClasspath -software.amazon.awssdk:metrics-spi:2.30.0=testRuntimeClasspath -software.amazon.awssdk:netty-nio-client:2.30.0=testRuntimeClasspath -software.amazon.awssdk:profiles:2.30.0=testRuntimeClasspath -software.amazon.awssdk:protocol-core:2.30.0=testRuntimeClasspath -software.amazon.awssdk:regions:2.30.0=testRuntimeClasspath -software.amazon.awssdk:retries-spi:2.30.0=testRuntimeClasspath -software.amazon.awssdk:retries:2.30.0=testRuntimeClasspath -software.amazon.awssdk:s3:2.30.0=testRuntimeClasspath -software.amazon.awssdk:sdk-core:2.30.0=testRuntimeClasspath -software.amazon.awssdk:third-party-jackson-core:2.30.0=testRuntimeClasspath -software.amazon.awssdk:utils:2.30.0=testRuntimeClasspath -software.amazon.eventstream:eventstream:1.0.1=testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index 9e080b0..39dbe93 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -502,11 +502,8 @@ ca-skeleton: # alternative). Keys are RAW IdP role names (no ROLE_ prefix — that prefix only # exists on Spring authorities, not on the principal's raw role set), looked up # case-insensitively. Values are explicitly enumerated `resource:action` permissions - # (no wildcards — least-privilege, OWASP-AUTHZ-C4). The values below are the - # sample-portfolio demonstration (§5): `user` may read/write, only `admin` may close. - role-permissions: - user: worklog:read,worklog:write - admin: worklog:read,worklog:write,worklog:close + # (no wildcards — least-privilege, OWASP-AUTHZ-C4). Product-specific roles belong here. + role-permissions: {} cors: # true | false enabled: ${APP_SECURITY_CORS_ENABLED} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java index 0106c10..74be600 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java @@ -233,38 +233,6 @@ class CleanArchitectureTest { + "outbound adapters") .allowEmptyShould(true); - @ArchTest - static final ArchRule SAMPLE_APPLICATION_HAS_NO_SLF4J = - noClasses() - .that() - .resideInAPackage("dev.caskeleton.sample.portfolio.application..") - .should() - .dependOnClassesThat() - .resideInAnyPackage("org.slf4j..") - .as( - "SAMPLE_APPLICATION_HAS_NO_SLF4J: sample application collaborators read " - + "correlation context through application-core CorrelationIdPort; inbound " - + "adapters own MDC") - .allowEmptyShould(true); - - @ArchTest - static final ArchRule SAMPLE_APPLICATION_LEGACY_OBJECT_STORAGE_IMPORT_IS_FROZEN = - noClasses() - .that() - .resideInAPackage("dev.caskeleton.sample.portfolio.application..") - .and() - .doNotHaveFullyQualifiedName( - "dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase") - .should() - .dependOnClassesThat() - .resideInAPackage("dev.caskeleton.application.storage") - .as( - "Only the characterized UploadPosterImageUseCase may temporarily import the legacy " - + "ObjectStoragePort/StoredObject contract. New sample business code must use " - + "the semantic objectstorage ports. Task 23 moves compatibility into the exact " - + "legacy slice; Task 24 separately reviews the administrative migration seam.") - .allowEmptyShould(true); - @ArchTest static final ArchRule APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION = noClasses() @@ -1844,7 +1812,7 @@ class CleanArchitectureTest { .haveRawType(assignableTo(ResourceId.class)) .as( "D17 NO_LONG_ID_PK: a domain entity 'id' field must be a ResourceId value object" - + " (e.g. WorkLogId), never Long/long/int/Integer" + + " (e.g. ArticleId), never Long/long/int/Integer" + " (feature-resource-identifier-contract D17). JPA @Id UUID columns in" + " ..adapter.outbound.persistence.. are out of scope — they store the UUID as" + " the PostgreSQL native uuid type per D10.") @@ -1863,7 +1831,7 @@ class CleanArchitectureTest { .as( "D5/D17 NO_UUID_RANDOM_IN_CONTROLLER: controllers and the application layer " + "(use cases / services) must not generate resource identifiers directly " - + "(UUID.randomUUID / UuidCreator) — inject the domain WorkLogIdFactory port " + + "(UUID.randomUUID / UuidCreator) — inject a domain identifier factory port " + "instead (feature-resource-identifier-contract D5/D17). Scope follows the " + "D17 decision text (controller / service / use case); request/trace-id " + "generation in web filters is a distinct concern owned by D18 / " diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java index 7618794..c9d8e35 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java @@ -24,9 +24,9 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; class ConditionalTransportQualificationContractTest { - // 29 pre-existing controls plus the eight release-blocking HTTP Client Platform gates + // 28 pre-existing controls plus the nine HTTP Client Platform gates // registered in .github/ci-gate-matrix.yml (design §38). - private static final int EXPECTED_GATE_COUNT = 38; + private static final int EXPECTED_GATE_COUNT = 37; /** * Filler gates a well-formed fixture needs beside its one target gate. @@ -74,7 +74,6 @@ class ConditionalTransportQualificationContractTest { "filesystem-vulnerability-scan", "documentation-links", "object-storage-minio-managed-contract", - "poster-image-migration", "object-storage-minio-managed-fault", "object-storage-aws-protected-qualification", "redis-sdk-support-matrix", @@ -474,16 +473,12 @@ class ConditionalTransportQualificationContractTest { } assertThat(ids).containsExactlyInAnyOrderElementsOf(EXPECTED_GATE_IDS); - Map posterGate = - gates.stream() - .map(Map.class::cast) - .filter(gate -> "poster-image-migration".equals(gate.get("id"))) - .findFirst() - .orElseThrow(() -> new AssertionError("missing Poster image migration gate")); - assertThat(requireString(posterGate, "ref")).isEqualTo("posterImageMigrationTest"); - assertThat(requireString(posterGate, "workflow")).isEqualTo("object-storage-qualification.yml"); - assertThat(requireString(posterGate, "job")).isEqualTo("poster-image-v7-migration"); - assertThat(requireString(posterGate, "execution")).isEqualTo("explicit"); + String objectStorageWorkflow = + Files.readString( + repositoryRoot().resolve(".github/workflows/object-storage-qualification.yml")); + assertThat(objectStorageWorkflow) + .doesNotContain("poster-image-v7-migration") + .doesNotContain(":sample-portfolio:posterImageMigrationTest"); } private static void assertRejectedAsNotExplicit(ScriptResult result) { diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java index 33d6dc0..228595e 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java @@ -20,10 +20,7 @@ import org.junit.jupiter.api.Test; * *

    *
  • Gates 1–9 are the base contracts verified by this test. - *
  • Gate #10 (OpenAPI drift) lives in {@code sample-portfolio} and is verified by its own - * module test. It is not loadable from {@code app-bootstrap}'s classpath and is therefore - * intentionally excluded here. - *
  • Gate #11 (sample removal smoke) is {@code + *
  • Gate #10 (sample removal smoke) is {@code * dev.caskeleton.bootstrap.contract.SampleRemovalSmokeContractTest}, which is included in the * 9-base enumeration below as the envelope/schema gate covers it (see concern mapping). *
diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java index b7e6be9..5d17dbe 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java @@ -81,21 +81,20 @@ class DeveloperExperienceContractTest { } @Test - void bootstrapIsOneOrderedFiveStageGradleEntrypoint() throws IOException { + void bootstrapIsOneOrderedFourStageGradleEntrypoint() throws IOException { String build = read("src/build.gradle"); assertThat(build) .contains("tasks.register('bootstrapCompile')") .contains("tasks.register('bootstrapDependencies'") .contains("tasks.register('bootstrapMigrateAndStart'") - .contains("tasks.register('bootstrapSampleContract'") .contains("tasks.register('bootstrapSmoke'") - .contains("tasks.register('bootstrap')"); + .contains("tasks.register('bootstrap')") + .doesNotContain("tasks.register('bootstrapSampleContract'"); assertThat(build) .contains("bootstrapDependencies.configure { dependsOn bootstrapCompile }") .contains("bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies }") - .contains("bootstrapSampleContract.configure { dependsOn bootstrapMigrateAndStart }") - .contains("bootstrapSmoke.configure { dependsOn bootstrapSampleContract }"); + .contains("bootstrapSmoke.configure { dependsOn bootstrapMigrateAndStart }"); } @Test @@ -807,17 +806,12 @@ class DeveloperExperienceContractTest { @Test void dockerDependencyCacheStagesDeclareTheCompleteConfigurationRegistry() throws IOException { - for (String dockerfile : new String[] {"src/Dockerfile", "src/Dockerfile.sample"}) { - assertDockerDependencyCacheContract(dockerfile, read(dockerfile)); - } + assertDockerDependencyCacheContract("src/Dockerfile", read("src/Dockerfile")); } @Test void dockerBuildsConsumeOnlyGradleStagedExecutableJars() throws IOException { - Map executableModules = - Map.of( - "src/Dockerfile", "app-bootstrap", - "src/Dockerfile.sample", "sample-portfolio"); + Map executableModules = Map.of("src/Dockerfile", "app-bootstrap"); executableModules.forEach( (dockerfile, module) -> { diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java index 371c318..89ff8df 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java @@ -10,26 +10,23 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; /** - * Gate #11 (sample-removal smoke) of the contract-verification suite. The skeleton ships a - * reference domain ({@code sample-portfolio}) that remains in the template as a fixture. Production - * code and the sample-off contract build must not depend on it. A downstream adopter may delete the - * module only after the sample-off build is green. + * Gate #10 (sample-removal smoke) of the contract-verification suite. The skeleton's concrete + * reference domain has been removed. Production code and the sample-off contract build must remain + * independent of it so deleted sample code cannot re-enter the build or runtime by accident. * *

The contract is a build/test classpath matrix, not a Spring runtime profile: * *

    - *
  1. production modules reference the sample only through the app-bootstrap sample fixture - * configuration; + *
  2. the concrete sample module, Dockerfile, registry leaf, and build dependency are absent; *
  3. app-bootstrap core test sources compile without importing sample types; - *
  4. the sample-off source set removes the fixture from its compile/runtime classpaths; - *
  5. sample-on and sample-off are both release-blocking CI jobs. + *
  6. the sample-off source set proves the minimal bootstrap classpath remains sample-free; + *
  7. sample-off remains a release-blocking CI job. *
* *

The build-graph scan is the no-datasource proxy for "core app/context boots green without the @@ -39,40 +36,49 @@ import org.yaml.snakeyaml.Yaml; */ class SampleRemovalSmokeContractTest { - /** A Gradle dependency line: {@code project(':sample-portfolio')}. */ private static final Pattern SAMPLE_DEP = Pattern.compile("^\\s*([A-Za-z]+)\\s+project\\(\\s*['\"]:sample-portfolio['\"]\\s*\\)"); @Test - void productionModulesReferenceSampleOnlyAsTestFixtureDependency() throws IOException { + void concreteSampleModuleRegistryBuildEvidenceAndDockerfileAreAbsent() throws IOException { RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty(); + Path root = resources.repositoryRoot(); + + assertThat(root.resolve("src/sample-portfolio/build.gradle")).doesNotExist(); + assertThat(root.resolve("src/sample-portfolio/src/main")).doesNotExist(); + assertThat(root.resolve("src/Dockerfile.sample")).doesNotExist(); List violations = new ArrayList<>(); - for (String sourcePath : registeredProductionSourcePaths(resources)) { + for (String sourcePath : registeredSourcePathsWithoutSample(resources)) { Path buildFile = resources.requireTrackedFile(sourcePath + "/build.gradle"); for (String line : Files.readAllLines(buildFile)) { - Matcher m = SAMPLE_DEP.matcher(line); - if (m.find() && !m.group(1).startsWith("test") && !m.group(1).equals("sampleFixture")) { - violations.add( - sourcePath - + ": '" - + line.trim() - + "' (configuration '" - + m.group(1) - + "' is not test-scoped)"); + if (SAMPLE_DEP.matcher(line).find()) { + violations.add(sourcePath + ": '" + line.trim() + "'"); } } } assertThat(violations) - .as( - "sample-portfolio must be a test-only dependency of every production module so " - + "the sample can be removed without breaking the production build/runtime") + .as("no registered module may retain a Gradle dependency on the deleted sample") .isEmpty(); + + String rootBuild = Files.readString(resources.requireTrackedFile("src/build.gradle")); + assertThat(rootBuild) + .doesNotContain(":sample-portfolio:messagingSampleContractQualificationTest") + .doesNotContain( + "sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/"); + + String application = + Files.readString( + resources.requireTrackedFile("src/app-bootstrap/src/main/resources/application.yml")); + assertThat(application) + .contains("role-permissions: {}") + .doesNotContain("user: worklog:read,worklog:write") + .doesNotContain("admin: worklog:read,worklog:write,worklog:close"); } - private static List registeredProductionSourcePaths(RepositoryContractResources resources) - throws IOException { + private static List registeredSourcePathsWithoutSample( + RepositoryContractResources resources) throws IOException { Path registry = resources.requireTrackedFile("src/config/architecture/modules.json"); final Map root; try (InputStream input = Files.newInputStream(registry)) { @@ -81,14 +87,17 @@ class SampleRemovalSmokeContractTest { @SuppressWarnings("unchecked") List> modules = (List>) root.get("modules"); assertThat(modules).as("architecture registry must declare its leaf modules").isNotEmpty(); - assertThat(modules.stream().filter(row -> "sample-portfolio".equals(row.get("id"))).count()) - .as("architecture registry must declare exactly one sample fixture leaf") - .isEqualTo(1); + assertThat(root.get("runtime_compositions")) + .as("only the production composition remains") + .isEqualTo(List.of("app-bootstrap")); + assertThat(modules).noneMatch(row -> "sample-portfolio".equals(row.get("id"))); + assertThat(modules) + .allMatch( + row -> + !((List) row.get("allowed_dependencies")).contains("sample-portfolio") + && !((List) row.get("runtime_memberships")).contains("sample-portfolio")); - return modules.stream() - .filter(row -> !"sample-portfolio".equals(row.get("id"))) - .map(row -> (String) row.get("source_path")) - .toList(); + return modules.stream().map(row -> (String) row.get("source_path")).toList(); } @Test @@ -117,9 +126,7 @@ class SampleRemovalSmokeContractTest { } assertThat(imports) - .as( - "app-bootstrap core tests must compile without sample-portfolio; sample-specific " - + "contract checks belong in the sample module") + .as("app-bootstrap core tests must compile without imports from the deleted sample module") .isEmpty(); } @@ -129,8 +136,8 @@ class SampleRemovalSmokeContractTest { Path buildFile = resources.requireTrackedFile("src/app-bootstrap/build.gradle"); String build = Files.readString(buildFile); - assertThat(build).contains("sampleFixture project(':sample-portfolio')"); assertThat(build) + .doesNotContain("sampleFixture") .contains("java.srcDirs = sourceSets.test.java.srcDirs") .contains("java.srcDir 'src/sampleOffTest/java'") .contains("tasks.register('sampleOffCompile')") diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java index a460e9c..882cd82 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/outbox/EventPayloadPiiContractTest.java @@ -31,9 +31,9 @@ import org.junit.jupiter.api.Test; *

  • Red test: loads only the violation fixture ({@code * PiiViolationEventFixture} — a {@code @DomainEvent} with an {@code email} field) and asserts * the rule fires. - *
  • Green test: loads all production + sample classes (excluding the - * violations package) and asserts the rule does NOT fire — i.e., no real event class carries - * a PII field name. + *
  • Green test: loads all production classes (excluding the violations + * package) and asserts the rule does NOT fire — i.e., no real event class carries a PII field + * name. * */ class EventPayloadPiiContractTest { @@ -99,11 +99,11 @@ class EventPayloadPiiContractTest { // ---- Green test: production classes must pass the rule ---- /** - * Production + sample-portfolio classes excluding the violations fixture package. + * Production classes excluding the violations fixture package. * - *

    Scans {@code dev.caskeleton} broadly (covers domain-core, application-core, - * adapter-outbound, sample-portfolio all on the test classpath) but excludes the {@code - * violations} package which intentionally contains broken fixtures. + *

    Scans {@code dev.caskeleton} broadly (covers domain-core, application-core, adapter-outbound + * modules on the test classpath) but excludes the {@code violations} package which intentionally + * contains broken fixtures. */ private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java index 4a6bf35..a83c5a7 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/DistributedLockProviderContractTest.java @@ -88,8 +88,7 @@ class DistributedLockProviderContractTest { sharedDataSource = hikari(pg); - // Flyway migrates V1 (idempotency), V2 (work_log from sample-portfolio on - // app-bootstrap test classpath), V3 (outbox), V4 (INT_LOCK). + // Flyway migrates the production schemas before lock table support is exercised. Flyway.configure() .dataSource(sharedDataSource) .locations("classpath:db/migration/postgresql") diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java index 8ab1a6e..2d4cd16 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java @@ -54,10 +54,7 @@ final class OutboxContainerTestSupport { "Docker not available — skipping Testcontainers-based outbox integration test"); } - /** - * Runs Flyway migrations against the given DataSource. All three migrations (V1 idempotency, V2 - * work_log from sample-portfolio on app-bootstrap test classpath, V3 outbox_event) are applied. - */ + /** Runs all production Flyway migrations against the given DataSource. */ static void migrate(DataSource dataSource) { Flyway.configure() .dataSource(dataSource) diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OperationalContractRuntimeTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OperationalContractRuntimeTest.java index be1d7ea..b893a1f 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OperationalContractRuntimeTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/OperationalContractRuntimeTest.java @@ -28,20 +28,15 @@ import org.springframework.web.bind.annotation.RestController; /** * Regression guard for the skeleton's operational envelope/error contract. * - *

    The audit motivation: this contract used to live only in the disposable {@code - * sample-portfolio} module, which {@code app-bootstrap} does NOT depend on at runtime ({@code - * bootJar} mainClass {@code CaSkeletonApplication}). The contract beans now live in {@code - * adapter-web} (a production dependency of {@code app-bootstrap}). This test pins that {@link - * CaSkeletonApplication}'s component scan ({@code scanBasePackages = "dev.caskeleton"}) actually - * wires them into the running application, so a future change cannot silently drop the contract - * from the real app again. + *

    The contract beans live in {@code adapter-web}, a production dependency of {@code + * app-bootstrap}. This test pins that {@link CaSkeletonApplication}'s component scan ({@code + * scanBasePackages = "dev.caskeleton"}) actually wires them into the running application, so a + * future change cannot silently drop the contract from the real app again. * *

    Why a {@code @WebMvcTest} slice rather than a full {@code @SpringBootTest}: a full boot of * {@code CaSkeletonApplication} requires the ~50 {@code ${...}} env vars wired in {@code - * application.yml} (datasource, OIDC issuer, etc.) from {@code src/.env}, and {@code - * sample-portfolio} on the test classpath drags in Spring Data JPA / Security / OAuth2 - * autoconfiguration plus JPA entities and repositories that need a live datasource and JWT issuer. - * The MVC slice gives a real, {@code CaSkeletonApplication}-anchored web context without that cost. + * application.yml} (datasource, OIDC issuer, etc.) from {@code src/.env}. The MVC slice gives a + * real, {@code CaSkeletonApplication}-anchored web context without that cost. * *

    The slice scans {@code CaSkeletonApplication}'s package for {@code @ControllerAdvice} beans, * so it picks up the advices via the same component-scan path the running app uses. The raw-{@code diff --git a/src/application-core/CLAUDE.md b/src/application-core/CLAUDE.md index 636cbca..835de00 100644 --- a/src/application-core/CLAUDE.md +++ b/src/application-core/CLAUDE.md @@ -212,15 +212,14 @@ The read side has two equally-valid shapes; pick per read, do not force one: | Shape | Returns | When | How | |---|---|---|---| -| **Through-aggregate** (default for simple reads) | domain aggregate via a `*Repository` port | read shape == write aggregate **and** the aggregate is the minimal invariant boundary (no lazy collections needed) | `QueryUseCase` → repository port → `WorkLog` | -| **Projection (CQRS-lite)** | application-layer projection DTO via a `*QueryPort` | read shape ≠ write, or to skip aggregate hydration / lazy-collection joins | `QueryUseCase` → `*QueryPort` → `WorkLogSummary` (record); query via JPQL `SELECT new` / JdbcTemplate | +| **Through-aggregate** (default for simple reads) | domain aggregate via a `*Repository` port | read shape == write aggregate **and** the aggregate is the minimal invariant boundary (no lazy collections needed) | `QueryUseCase` → repository port → aggregate | +| **Projection (CQRS-lite)** | application-layer projection DTO via a `*QueryPort` | read shape ≠ write, or to skip aggregate hydration / lazy-collection joins | `QueryUseCase` → `*QueryPort` → summary record; query via JPQL `SELECT new` / JdbcTemplate | - **D1 — purity guardrail (core, machine-enforced):** a read port whose simple name ends with `QueryPort` MUST return application-layer projection DTOs only — never a domain aggregate, JPA entity, or web type, **including through generic type arguments** (`List`). Enforced by ArchUnit `query_ports_do_not_leak_domain_jpa_or_web_types`. - Projection usage itself is **opt-in**, not a forced default; the demo lives in - `sample-portfolio` (`WorkLogSummaryQueryPort` / `WorkLogSummary`). + Projection usage itself is **opt-in**, not a forced default. - **D3 — Strict ceremony:** every read goes through a `QueryUseCase` bean. There is no thin web→read-port path — that would bypass the mandatory `@UseCaseCapability` fitness function. - **D4 — transaction:** reads default to `TransactionPort.inRead`. A no-tx (autocommit) read diff --git a/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java b/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java index 6ceff34..9a4a5b9 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/storage/ObjectStoragePort.java @@ -11,7 +11,7 @@ import java.util.Optional; * *

    Legacy compatibility only. This contract preserves caller-keyed overwrite and * whole-{@code byte[]} materialization semantics in a separate legacy namespace while stored data - * and the sample endpoint migrate. New code must use the semantic ports under {@code + * and external consumers migrate. New code must use the semantic ports under {@code * dev.caskeleton.application.objectstorage}. Production activation must be removed after the * additive API/data migration, dual-read observation, external-consumer approval, and zero-usage * gates pass; no calendar removal date is implied here. diff --git a/src/build.gradle b/src/build.gradle index e90a58c..263120b 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -708,8 +708,7 @@ def writeMessagingEvidence = { schema: messagingSha256FileSet( 'ca-skeleton.messaging.evidence.schema-set.v1', [ - file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json'), - file('sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json') + file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json') ] + fileTree( 'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12' ).files.toList()), @@ -788,7 +787,6 @@ def verifyMessagingContracts = tasks.register('verifyMessagingContracts') { dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema dependsOn ':application-core:messagingApplicationContractQualificationTest' dependsOn ':shared-contract:messagingSharedSchemaQualificationTest' - dependsOn ':sample-portfolio:messagingSampleContractQualificationTest' dependsOn ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest' dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest' dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph' @@ -797,11 +795,10 @@ def verifyMessagingContracts = tasks.register('verifyMessagingContracts') { doLast { writeMessagingEvidence( 'verifyMessagingContracts', - ['application', 'shared', 'sample', 'compiled', 'json-schema'], + ['application', 'shared', 'compiled', 'json-schema'], [ ':application-core:messagingApplicationContractQualificationTest', ':shared-contract:messagingSharedSchemaQualificationTest', - ':sample-portfolio:messagingSampleContractQualificationTest', ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest', ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest', 'verifyMessagingContracts' @@ -853,7 +850,7 @@ def composeCommand = ['docker', 'compose', '-f', baseComposeFile.absolutePath, def bootstrapCompile = tasks.register('bootstrapCompile') { group = 'developer experience' - description = 'Stage 1/5: compiles every main and test source set as a local sanity check.' + description = 'Stage 1/4: compiles every main and test source set as a local sanity check.' dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('compileTestJava') } } @@ -876,7 +873,7 @@ bootstrapDockerPreflight.configure { dependsOn bootstrapCompile } def bootstrapDependencies = tasks.register('bootstrapDependencies', Exec) { group = 'developer experience' - description = 'Stage 2/5: starts the local PostgreSQL dependency and waits for readiness.' + description = 'Stage 2/4: starts the local PostgreSQL dependency and waits for readiness.' commandLine composeCommand + ['up', '-d', '--wait', 'db'] } bootstrapDependencies.configure { dependsOn bootstrapCompile } @@ -884,21 +881,14 @@ bootstrapDependencies.configure { dependsOn bootstrapDockerPreflight } def bootstrapMigrateAndStart = tasks.register('bootstrapMigrateAndStart', Exec) { group = 'developer experience' - description = 'Stage 3/5: builds/starts the app; startup Flyway must finish before health is ready.' + description = 'Stage 3/4: builds/starts the app; startup Flyway must finish before health is ready.' commandLine composeCommand + ['up', '-d', '--build', '--wait', 'app'] } bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies } -def bootstrapSampleContract = tasks.register('bootstrapSampleContract') { - group = 'developer experience' - description = 'Stage 4/5: verifies the delegated sample production-isolation/build contract.' - dependsOn project(':app-bootstrap').tasks.named('bootstrapSampleContract') -} -bootstrapSampleContract.configure { dependsOn bootstrapMigrateAndStart } - def bootstrapSmoke = tasks.register('bootstrapSmoke') { group = 'developer experience' - description = 'Stage 5/5: requires HTTP 200 and status=UP from GET /api/healthcheck.' + description = 'Stage 4/4: requires HTTP 200 and status=UP from GET /api/healthcheck.' doLast { URI endpoint = URI.create('http://localhost:8080/api/healthcheck') long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) @@ -929,11 +919,11 @@ def bootstrapSmoke = tasks.register('bootstrapSmoke') { "bootstrapSmoke: /api/healthcheck did not become healthy within 60s; last result: ${lastFailure}") } } -bootstrapSmoke.configure { dependsOn bootstrapSampleContract } +bootstrapSmoke.configure { dependsOn bootstrapMigrateAndStart } tasks.register('bootstrap') { group = 'developer experience' - description = 'Runs the complete five-stage local bootstrap contract.' + description = 'Runs the complete four-stage local bootstrap contract.' dependsOn bootstrapSmoke } @@ -1124,8 +1114,7 @@ tasks.register('verifyCleanArchitectureDependencies') { if (moduleName != 'sample-portfolio' && actual.contains('sample-portfolio')) { throw new GradleException( "Module ':${moduleName}' has a forbidden production dependency on " + - "':sample-portfolio'. The sample module may only be consumed through " + - "non-production fixture configurations." + "deleted ':sample-portfolio' (negative re-entry guard)." ) } @@ -1134,7 +1123,6 @@ tasks.register('verifyCleanArchitectureDependencies') { throw new GradleException( "Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " + "Allowed dependencies are ${allowed}. " + - "Production modules must not depend on ':sample-portfolio'; " + "all project edges must be explicitly registered." ) } diff --git a/src/config/architecture/modules.json b/src/config/architecture/modules.json index 0b2d806..93f7e61 100644 --- a/src/config/architecture/modules.json +++ b/src/config/architecture/modules.json @@ -1,7 +1,6 @@ { "runtime_compositions": [ - "app-bootstrap", - "sample-portfolio" + "app-bootstrap" ], "modules": [ { @@ -10,8 +9,7 @@ "source_path": "src/domain-core", "allowed_dependencies": [], "runtime_memberships": [ - "app-bootstrap", - "sample-portfolio" + "app-bootstrap" ] }, { @@ -20,8 +18,7 @@ "source_path": "src/shared-contract", "allowed_dependencies": [], "runtime_memberships": [ - "app-bootstrap", - "sample-portfolio" + "app-bootstrap" ] }, { @@ -33,8 +30,7 @@ "shared-contract" ], "runtime_memberships": [ - "app-bootstrap", - "sample-portfolio" + "app-bootstrap" ] }, { @@ -60,8 +56,7 @@ "shared-contract" ], "runtime_memberships": [ - "app-bootstrap", - "sample-portfolio" + "app-bootstrap" ] }, { @@ -83,8 +78,7 @@ "application-core" ], "runtime_memberships": [ - "app-bootstrap", - "sample-portfolio" + "app-bootstrap" ] }, { @@ -107,9 +101,7 @@ "application-core", "shared-contract" ], - "runtime_memberships": [ - "sample-portfolio" - ] + "runtime_memberships": [] }, { "id": "adapter-outbound-cache-redis", @@ -177,8 +169,7 @@ "shared-contract" ], "runtime_memberships": [ - "app-bootstrap", - "sample-portfolio" + "app-bootstrap" ] }, { @@ -235,23 +226,6 @@ "runtime_memberships": [ "app-bootstrap" ] - }, - { - "id": "sample-portfolio", - "gradle_path": ":sample-portfolio", - "source_path": "src/sample-portfolio", - "allowed_dependencies": [ - "domain-core", - "application-core", - "adapter-outbound-persistence-jpa", - "adapter-outbound-identifier", - "adapter-outbound-objectstorage", - "adapter-inbound-web", - "shared-contract" - ], - "runtime_memberships": [ - "sample-portfolio" - ] } ] } diff --git a/src/domain-core/README.md b/src/domain-core/README.md index 497eb41..0699d0b 100644 --- a/src/domain-core/README.md +++ b/src/domain-core/README.md @@ -17,15 +17,12 @@ ### `ResourceId` — 모든 식별자가 구현하는 마커 인터페이스 -시스템의 모든 리소스 식별자 값 객체(예: `WorkLogId`)가 구현하는 공통 인터페이스다. +시스템의 모든 리소스 식별자 값 객체(예: `ArticleId`)가 구현하는 공통 인터페이스다. 식별자의 실제 문자열 값은 `value()` 하나로 노출한다. -- **왜 `sealed` 로 닫지 않았나.** 원래 레퍼런스 설계는 `permits WorkLogId` 처럼 허용 구현을 - 못 박는 닫힌 집합(`sealed`)으로 만들려 했다. 그런데 `WorkLogId` 같은 실제 식별자는 - `sample-portfolio`(샘플 코드)에 있고, 이 마커는 재사용 가능한 `domain-core`에 있다. - `permits` 절을 쓰는 순간 `domain-core`가 `sample-portfolio`를 의존하게 되는데, 이건 - 모듈 의존성 규칙과 `production_code_does_not_depend_on_sample_portfolio` ArchUnit 규칙이 - 둘 다 금지하는 방향이다. 그래서 `sealed`을 포기했다. +- **왜 `sealed` 로 닫지 않았나.** 실제 식별자 구현은 각 제품 도메인이 소유하고, 이 마커는 + 재사용 가능한 `domain-core`에 있다. `permits` 절로 구체 구현을 못 박으면 `domain-core`가 + 바깥 도메인 구현을 의존하게 되어 모듈 의존성 규칙을 거스른다. 그래서 `sealed`을 포기했다. - **그럼 "닫힌 집합" 보장은 어디서 하나.** `sealed`이 줬을 보장(아무 타입이나 식별자로 쓰지 못하게 하는 것)은 대신 빌드 타임의 `no_long_id_pk` ArchUnit 규칙(D17)이 강제한다 — 도메인의 모든 `id` 필드는 반드시 `ResourceId` 구현이어야 하고, 날것의 `Long` PK 는 금지된다. diff --git a/src/sample-portfolio/.jqwik-database b/src/sample-portfolio/.jqwik-database deleted file mode 100644 index 711006c3d3b5c6d50049e3f48311f3dbe372803d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4 LcmZ4UmVp%j1%Lsc diff --git a/src/sample-portfolio/CLAUDE.md b/src/sample-portfolio/CLAUDE.md deleted file mode 100644 index c3698c3..0000000 --- a/src/sample-portfolio/CLAUDE.md +++ /dev/null @@ -1,53 +0,0 @@ -# sample-portfolio — fixture / reference consumer - -## Registered identity - -- Module ID: `sample-portfolio` -- Gradle path: `:sample-portfolio` -- Focused test (derived from Gradle path): `./gradlew :sample-portfolio:test --console=plain` -- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. -- Registry SSOT: `src/config/architecture/modules.json`. - -Package root: `dev.caskeleton.sample.portfolio`. - -코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 의 "설계 결정 참조" 절이 모아둔다 (이 문서는 모듈 규칙 SSOT). - -## Responsibility - -- Reference implementation for template adopters: the WorkLog domain slice, layered - exactly like a consuming project — `domain/worklog` (`WorkLog`, `WorkLogId`, - `Period`, `WorkLogRepository`), `application/` (commands, queries, use cases, - ports such as `RepoStatsPort`), and reference adapters under `adapter/web`, - `adapter/persistence`, `adapter/outbound/repostats` (`RepoStatsPortClient` + - `RepoStatsAclMapper`), `adapter/identifier` (`UuidWorkLogIdFactory`). -- Contract-test fixtures the app-bootstrap verification suite analyses - (`DomainExceptionHandler`, `PortfolioErrorCode`, wire/contract tests). -- Disposable messaging contract fixture: the exact `WorkLogReservedPayload` record, its static - application-core contract contribution, sample-owned schema/digest and golden vectors. It is not - runtime-discovered, and validator compatibility remains unproven until messaging Task 6. -- Sample application collaborators consume invocation context through application-core ports and - must not import SLF4J/MDC. - -## Allowed - -- Runtime leaves explicitly allowed for this fixture consumer by the `sample-portfolio` entry in - `src/config/architecture/modules.json`; do not duplicate the 19-leaf list here. - -## Forbidden - -- Production code depending on this module — ArchUnit - `production_code_does_not_depend_on_sample_portfolio`. `app-bootstrap` references - it as `testImplementation` ONLY so the ArchUnit suite can analyse the reference - implementation ([app-bootstrap/build.gradle](../app-bootstrap/build.gradle) L21–24). -- Production-required behaviour living here: deleting this module must not break the - production build or runtime (the base `GlobalExceptionHandler`, envelope, and - error-code contract live in `adapter:inbound:web` / `shared-contract`). -- Treating a sample schema/contribution as an automatically discovered production registry entry, - or placing JSON mapper, physical topic, Kafka or bootstrap-server concerns in the contribution. - -## Test - -```bash -cd src -./gradlew :sample-portfolio:test -``` diff --git a/src/sample-portfolio/README.md b/src/sample-portfolio/README.md deleted file mode 100644 index 1b8a328..0000000 --- a/src/sample-portfolio/README.md +++ /dev/null @@ -1,477 +0,0 @@ -# sample-portfolio — WorkLog 포트폴리오 게시판 (예시 도메인) - -이 모듈은 ca-skeleton 템플릿이 유지하는 **fixture/reference 예시 도메인**입니다. production 모듈 -(`domain-core`/`application-core`/`adapter-*`/`shared-contract`/`app-bootstrap`)의 -경계와 운영 계약을 "어떻게 쓰는지" 보여줍니다. 새 프로젝트는 이 모듈을 import하지 않고 자신의 -도메인을 production 모듈에 추가합니다. 템플릿에서는 이 모듈을 유지하며, 다운스트림 fork는 -`./gradlew :app-bootstrap:sampleOffTest` 통과 후에만 선택적으로 정리할 수 있습니다. - -## 도메인: WorkLog (엔지니어링 작업 기록 게시판) - -메인 화면 = `GET /work-logs` 목록. 각 항목은 직접 수행한 작업입니다. - -| 필드 | 설명 | -| --- | --- | -| title | 작업 제목 | -| category | INFRASTRUCTURE / DATABASE / BACKEND / PLATFORM | -| summary / content | 요약 / 본문 | -| techStack[] | 사용 기술 | -| links[] | 관련 링크 (repo/blog) | -| period | 기간 (end=null → 진행 중) | - -## 엔드포인트 - -| Method | Path | 설명 | 시연 계약 | -| --- | --- | --- | --- | -| GET | /work-logs | 목록 (메인) | Envelope wrap | -| GET | /work-logs/{id} | 상세 | 도메인 예외→404 envelope | -| POST | /work-logs | 생성 | Bean Validation @GroupSequence (B4), unknown-field 거부 (B1), mapper 실패→MAPPING_FAILED (B3) | -| PATCH | /work-logs/{id} | 부분수정 | JsonNullable 3-state (B2) | -| DELETE | /work-logs/{id} | 삭제 | 204 | -| POST | /work-logs/import | 일괄 등록 | BulkEnvelope 부분실패 (B8) | - -## 샘플 데이터 (curl 예시) - -```bash -# 1) 인프라 인증·인가 위임 (Keycloak + k3s + Vault) -curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{ - "title": "Keycloak + k3s + Vault 기반 인증·인가 위임", - "category": "INFRASTRUCTURE", - "summary": "인증/인가를 Keycloak으로 위임, k3s + Vault로 시크릿·구성 관리", - "content": "...", - "techStack": ["keycloak", "k3s", "vault"], - "links": ["https://example.com/infra"], - "periodStart": "2025-01-01" -}' - -# 2) DB 쿼리 튜닝 -curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{ - "title": "DB 쿼리 튜닝", - "category": "DATABASE", - "summary": "slow query 분석 및 인덱스/실행계획 개선", - "content": "...", - "techStack": ["postgresql"], - "links": [], - "periodStart": "2025-02-01" -}' -``` - -## 계층 (adapter-mirrored) -- `domain/worklog` — WorkLog/WorkCategory/Period/RepoStats/WorkLogRepository (POJO) -- `application/worklog` — *UseCase (CommandUseCase/QueryUseCase + @UseCaseCapability) -- `adapter/web` — controller/dto/mapper/error -- `adapter/persistence` — entity/repository/mapper -- `adapter/outbound/repostats` — B7 ACL 예시 - ---- - -# 설계 결정 참조 (코드 주석에서 이전) - -여기 아래는 예전에 각 클래스의 주석/JavaDoc 에 흩어져 있던 **"왜 이렇게 짰는가"** 설명을 -한곳에 모은 것입니다. 코드에는 "무엇을 하는지"만 짧게 남기고, 그 배경·결정·트레이드오프는 -이 문서로 옮겼습니다. 코드를 읽다 "왜 이렇게 했지?"가 궁금할 때 보세요. - -읽는 법: - -- 모듈 규칙(허용/금지 의존, 테스트 명령)의 기준은 [CLAUDE.md](CLAUDE.md) 입니다. 이 문서는 - 규칙이 아니라 **결정의 근거**를 모은 참조 기록입니다. -- 본문은 코드가 어떤 약속을 지키려고 존재하는지 설명합니다. 추적용 꼬리표는 코드 주석에 - 남기지 않고, 필요한 배경은 여기서 문장으로 풀어 설명합니다. - -## domain — 도메인 (순수 POJO, 프레임워크 의존 없음) - -### WorkLog (애그리거트 루트) -- WorkLog 애그리거트의 루트입니다. **상태를 바꾸는 길은 의도가 드러나는 메서드뿐**입니다 - (`rename`, `recategorize`, `startProgress`, `close` …). 공개 `setXxx` 세터를 두지 않은 이유는, - 세터를 열어두면 불변식(invariant)을 건너뛴 채로 객체를 망가뜨릴 수 있기 때문입니다. 모든 - 변경 경로가 불변식 검사를 거치도록 강제합니다. -- 불변식을 어기면 `WorkLogInvariantException` 을 던집니다. 이 예외는 안전한 사유(reason)만 - 담고, 로그 문자열이나 운영 에러 코드/HTTP 상태는 전혀 모릅니다 — 그 변환은 상위 - (application/web)의 몫입니다. -- **id 는 도메인이 직접 만들지 않습니다.** `WorkLog.create(...)` 는 이미 만들어진 - `WorkLogId` 를 받기만 합니다. UUIDv7 생성은 유스케이스가 `WorkLogIdFactory` 포트를 통해 - 하고, 도메인 안에는 `UUID.randomUUID()` 나 id 생성 라이브러리가 들어오지 않습니다(서버가 id - 를 정해주는 계약). -- `version` 필드(낙관적 락 버전)의 역할: 새로 만든(아직 저장 안 된) 객체는 `null` 이고, - 저장소가 값을 채우고 증가시킵니다. 영속 계층은 이 `null` 여부로 "INSERT 인지 UPDATE 인지"를 - 구분하고, 웹 경계에서는 이 값이 HTTP `ETag` / `If-Match` 의 출처가 됩니다. 즉 DB 가 - 낙관적 락 충돌로 잡아내는 그 충돌을, 웹에서는 HTTP 412 로 표현합니다. -- `title` 규칙: null 이면 안 되고(`NullPointerException`), 공백만 있어도 안 됩니다 - (`TITLE_BLANK`). 공백 금지는 **도메인 불변식**이라, 웹 경계의 `@NotBlank`(형식 검사)와는 - 별개로 한 번 더 지킵니다 — 웹을 거치지 않는 호출자가 있어도 규칙이 새지 않도록. - -### 값 객체 — WorkLogId / Period / WorkLogOwner -- **WorkLogId**: 36자 canonical UUID(RFC 9562 UUIDv7). 순수 값 객체라 형식(정규형)만 검증하고, - id 생성 라이브러리에는 의존하지 않습니다. 대소문자 정규화는 이 타입을 만들기 전에 웹 경계에서 - 끝냅니다(canonical 형태는 소문자 hex). 형식은 `8-4-4-4-12` hex 그룹입니다. -- **Period**: 작업 기간. `end == null` 이면 "진행 중"이라는 뜻입니다. 생성자에서 `end < start` - 를 막습니다(기간 역전 불가). -- **WorkLogOwner**: 소유자 식별자. 공백/빈 값을 막고 trim 합니다. - -### 열거형 — WorkLogStatus / WorkCategory / WorkLogSortField -- **WorkLogStatus**: OPEN → IN_PROGRESS → CLOSED. 전이 규칙은 WorkLog 가 강제합니다. -- **WorkCategory**: 포트폴리오 보드에 노출하는 작업 분류(INFRASTRUCTURE/DATABASE/BACKEND/PLATFORM). -- **WorkLogSortField**: 목록 정렬에 **허용된 필드만** 담은 닫힌 열거형입니다. 정렬 키를 ORM 에 - 넘기는 열린 문자열이 아니라 열거형으로 막아둔 이유는, 알 수 없는 필드를 쿼리까지 보내지 않고 - 웹 경계에서 400 으로 거절하기 위해서입니다(`?sort=field,direction` 계약을 안전하게 만드는 핵심). - -### WorkLogInvariantException -- WorkLog 불변식 위반을 알리는 예외입니다. `Reason`(명사형 안전 enum)만 담고, **운영 에러 - 코드·HTTP 상태·로깅은 모릅니다.** 도메인은 로거를 갖지 않고 운영 코드로 매핑하지도 않는다는 - 규칙 때문입니다. `reason()` 을 로그 라인과 `error.category` 로 번역하는 일은 application/web 의 - 책임입니다. 사유가 거친 명사라서 민감 정보가 클라이언트로 새지 않습니다. - -### WorkLogReserved (도메인 이벤트) -- "WorkLog 기간이 예약됨"이라는 도메인 사실을 표현하는 이벤트입니다. **전송(transport)과 - 무관**합니다 — 도메인 데이터(`WorkLogId`/`WorkCategory`/`LocalDate`)만 담고 broker/wire/HTTP - 타입을 일절 참조하지 않습니다. 이것을 발행 가능한 integration 이벤트로 바꾸는 일은 application - 경계의 몫입니다(`application.event.WorkLogReservedIntegrationEvent` 와 그 매퍼 참고). - -### 포트 — WorkLogRepository / WorkLogIdFactory / OutboxEventIdFactory -- **WorkLogRepository**: WorkLog 영속 아웃바운드 포트(구현은 adapter-persistence). `findPage` 는 - 한 페이지 + 전체 개수를 함께 돌려주며, `sortField`/`category` 가 `null` 이면 정렬/필터 없음을 - 뜻합니다. 전체 개수는 같은 필터 기준으로 세어 `meta.page.total` 이 필터된 결과를 반영하게 합니다. -- **WorkLogIdFactory / OutboxEventIdFactory**: id 를 만들어 주는 도메인 포트. 둘을 **굳이 따로 둔 - 이유**는, 애그리거트 식별자 타입(`WorkLogId`)이 이벤트 id 모양을 묶어버리지 않게 하기 위해서입니다. - 실제로는 둘 다 UUIDv7 기반이지만(identifier 어댑터가 구현), 도메인은 생성 방식을 모릅니다. - outbox 이벤트 id 는 outbox 봉투의 `eventId` 이자 `idempotencyKey` 로 쓰입니다(같은 값 → 이벤트 - 단위 중복 제거). - -### RepoStats -- 외부 저장소 통계를 정규화한 결과 값입니다. 아웃바운드 ACL(B7)을 통과한 뒤에만 만들어집니다. - -## application — 응용 (유스케이스) - -### Command / Query — 스스로 검증하는 입력 모델 -- `CreateWorkLogCommand`, `UpdateWorkLogCommand`, `DeleteWorkLogCommand`, `GetWorkLogQuery`, - `GetRepoStatsQuery`, `ListWorkLogsQuery`, `BatchCreateWorkLogsCommand` 는 **생성자에서 스스로 - 필수값을 검증**합니다("self-validating input model"). 이렇게 하면 어떤 인바운드 어댑터가 - 부르든(오늘은 웹, 내일은 메시징 컨슈머) 웹 경계의 jakarta validation 에 기대지 않고도 같은 - 계약을 보장받습니다. id 나 patch 가 null 인 것은 프로그래밍 오류이므로 생성 시점에 즉시 - 실패시킵니다("없음"은 `Patch.absent()` 로 표현하고 null 을 쓰지 않습니다). -- `UpdateWorkLogCommand` 의 필드는 `Patch` 입니다. PATCH 의 3가지 상태 — 값 없음(변경 안 함) / - 명시적 null(필드 비움) / 값(교체) — 을 구분하기 위해서입니다. - -### CreateWorkLogUseCase — 생성 + outbox 동시 기록 (dual-write 금지) -- WorkLog 를 저장하면서 `WorkLogReserved` outbox 이벤트를 **같은 쓰기 트랜잭션 안에서** 함께 - 적재하는 예시입니다. 핵심은 `repository.save(...)` 와 `outboxAppendPort.append(...)` 가 같은 - `tx.inWrite { ... }` 블록 안에 있다는 점입니다. 저장이나 적재 중 하나라도 실패하면 트랜잭션 - 전체가 롤백됩니다 — "DB 한 번, 브로커 한 번" 식의 분리된 이중 쓰기(dual-write)를 막는 보장입니다. -- 순서: ① 저장된 애그리거트로 도메인 이벤트 생성 → ② 매퍼로 integration 이벤트로 변환(경계는 - application) → ③ 직접 만든 JSON 페이로드로 직렬화 → ④ 같은 트랜잭션에서 outbox 에 append. -- `eventId` 는 `OutboxEventIdFactory`(UUIDv7) 로 만들고, `idempotencyKey = eventId` 로 둡니다 - (이벤트 단위 중복 제거). -- `correlationId` 는 application-core의 `CorrelationIdPort`로 읽습니다. inbound web adapter가 - sanitized MDC 값을 포트 뒤에서 제공하므로 sample application 코드는 SLF4J/MDC를 알지 않습니다. - 요청 밖에서 실행되거나 값이 blank면 `eventId`를 그대로 `correlationId`로 사용해 이벤트가 - 최소한 자신을 가리키게(self-correlation) 폴백합니다. -- 쓰기 작업이라 `@RequiresPermission("worklog:write")` 로 권한을 요구합니다(이 권한은 `user`/`admin` - 역할 묶음에 부여). - -### BatchCreateWorkLogsUseCase — 원자적 일괄 생성 -- 동기 일괄 생성은 **전부 성공 아니면 전부 롤백**입니다. 모든 항목을 하나의 `tx.inWrite` 단위로 - 처리하므로 한 항목이라도(매핑/검증/영속) 실패하면 배치 전체가 되돌려지고, 엔드포인트는 부분 - 성공 대신 단일 4xx 를 냅니다. (`BATCH_PARTIAL_FAILURE` 같은 부분 실패 형태는 비동기 폴링 - 응답용으로 예약돼 있고, 동기 배치에는 쓰지 않습니다.) - -### UpdateWorkLogUseCase — Patch 3-state 적용 -- 각 필드의 `Patch` 상태에 따라 다르게 동작합니다: `hasValue()` 면 교체, `isExplicitNull()` 이면 - 비움, 둘 다 아니면(없음) 변경하지 않습니다. 상태 전이는 도메인 메서드(`startProgress`/`close`)에 - 위임하고, OPEN 으로 되돌리는 시도는 잘못된 전이로 막습니다. - -### DeleteWorkLogUseCase — 권한 3-tier 가시화 -- 삭제는 파괴적인 admin 등급 작업이라 `@RequiresPermission("worklog:close")` 로 막습니다. - `worklog:close` 는 **`admin` 역할 묶음에만** 부여되고 `user` 에는 주지 않습니다(샘플 모델에는 별도 - "close" 유스케이스가 없어 delete 가 그 민감 작업 역할을 합니다). 이 한 군데가 3-tier 권한 모델을 - 눈에 보이게 만듭니다 — 인증된 `user` 는 여기서 거절되고 `admin` 만 통과합니다. (참고로 생성·수정· - 일괄 생성은 `worklog:write` 로 막고, 이 권한은 `user`/`admin` 양쪽에 부여됩니다.) - -### 읽기 모델(projection) — ListRecentWorkLogSummariesUseCase / WorkLogSummaryQueryPort / WorkLogSummary -- 이 세 타입은 **CQRS-lite "쿼리 우회(query bypass)"** 예시입니다. 보드/목록 화면처럼 가벼운 읽기는 - WorkLog 애그리거트를 통째로 복원하지 않고, 필요한 컬럼만 뽑은 projection 으로 읽습니다 - (`content`/`summary`/`techStack`/`links` 같은 무거운 상태를 빼서, lazy 컬렉션 조인 없는 컬럼-부분 - SELECT 가 됩니다). -- **이것은 강제 기본값이 아니라 선택적 최적화**입니다. 애그리거트를 통해 읽는 길 - (`WorkLogRepository`)도 단순한 읽기에서는 똑같이 유효한 기본 경로입니다. -- projection DTO 의 순수성: projection 은 **application 타입**이어야 합니다 — 도메인 애그리거트도, - JPA 엔티티도, 웹 DTO 도 아니어야 합니다. 도메인 *값* 타입(예: `WorkCategory` enum)을 참조하는 것은 - 허용됩니다(application 은 domain 에 의존 가능). 다만 쿼리 포트의 반환 시그니처에 도메인 애그리거트/ - JPA 엔티티/웹 타입이 새면 안 됩니다(`List` 같은 제네릭 인자까지 포함, ArchUnit 규칙 - `query_ports_do_not_leak_domain_jpa_or_web_types` 가 강제). -- **명명 규칙**: projection 을 반환하는 읽기 포트 이름은 `…QueryPort` 로 끝냅니다. -- **id 표현**: projection 의 `id` 는 애플리케이션의 표준 id 어휘인 36자 UUID 문자열입니다. 저장소 - 고유의 `UUID` 는 어댑터 안에서 변환되어, 읽기 모델은 저장 형식이 아니라 애플리케이션과 같은 - 식별자 형식을 말합니다. -- 격식(ceremony)은 그대로 지킵니다: projection 읽기여도 `QueryUseCase` 빈을 거치고 - (`@UseCaseCapability` 계약을 모든 읽기에 기계적으로 적용), 기본은 `tx.inRead` 안에서 읽습니다. - projection 도 저장소 기반이면 여전히 `READ_REPOSITORY` 입니다 — "projection 이냐 애그리거트냐"는 - 반환 *모양*의 축이고, 저장소 접근 *수준*과는 별개라서 새 capability enum 이 필요 없습니다. - -### WorkLogReservedIntegrationEvent / …Mapper — integration(wire) 이벤트 -- **IntegrationEvent**: 도메인 이벤트와 달리 아웃바운드 어댑터가 실제로 발행할(예: Kafka) - 직렬화 가능한 경계 소유 형태입니다. 도메인 사실을 wire 계약으로 옮기는 일 — 값 객체를 원시 - 타입으로 평탄화, wire 표현 선택 — 은 application 의 관심사라서 application 계층에 둡니다. 도메인은 - wire 를 모릅니다. -- **Mapper**: 도메인 이벤트 → integration 이벤트 변환을 **application 경계에서** 수행하고, 트랜잭션 - outbox 용 JSON 페이로드 문자열로 직렬화합니다. - - JSON 직렬화(`toJson`)는 의존성을 늘리지 않으려고 RFC 8259 §7 이스케이프까지 직접 구현했습니다. - 실제 프로젝트라면 스키마 직렬화 계약(Avro/Protobuf, 스키마 레지스트리 붙은 Jackson)을 써야 - 합니다 — 샘플은 새 의존성 0을 유지합니다. 세 필드는 도메인 식별자와 enum 이름뿐이라 PII/토큰/ - 원문 본문이 없습니다. - - 이벤트 타입 이름 `"worklog.reserved"`: `도메인.동사` 패턴의 안정적인 점-구분 논리명입니다. - outbox relay 규약(`topic = eventType`)에 따라 그대로 브로커 토픽 이름이 됩니다. integration - 이벤트의 클래스 단순명을 쓰는 대안도 있지만, 클래스 이름을 바꾸면 토픽이 조용히 바뀌어 컨슈머가 - 깨질 수 있어, 안정적인 문자열 리터럴을 택했습니다(토픽 마이그레이션 계획 없이는 바꾸지 말 것). - -### WorkLogReservedPayload / …ContractContribution — canonical contract fixture - -- `WorkLogReservedPayload`는 새 closed contract SPI에 제공하는 exact final record다. v1은 - `workLogId` 하나만 소유하며, schema와 같은 canonical bounded identifier 규칙을 생성자에서 지킨다. -- `contracts/messaging/portfolio.worklog.reserved/v1.schema.json`과 golden vector는 - **sample fixture contract**다. 공통 envelope는 WorkLog 필드를 알지 못하며 이 모듈을 삭제해도 - production module build/runtime은 깨지지 않아야 한다. -- contribution은 contract ID, exact payload type/component order, classpath resource ID, checked-in - digest와 provider-neutral descriptor만 제공한다. JSON mapper, physical topic, Kafka 설정을 소유하지 - 않으며 매 호출 filesystem I/O도 하지 않는다. -- 기존 3-field `WorkLogReservedIntegrationEvent`/hand-rolled mapper는 현재 legacy outbox - characterization 경로에 남아 있다. 새 1-field canonical payload는 그 타입을 조용히 대체하지 않으며, - closed catalog와 encoder가 조립되기 전까지 양쪽은 서로 호출하거나 직렬화하지 않는다. -- 현재는 classpath scanning이나 runtime discovery가 없다. Task 5의 closed catalog 조립 전에는 - 자동 등록되지 않고, Task 6 validator qualification 전에는 Draft 2020-12 validator compatibility를 - 주장하지 않는다. - -### RepoStatsPort -- 외부 저장소 통계를 가져오는 아웃바운드 포트(구현은 adapter-outbound). - -## adapter/web — 인바운드 HTTP / 보안 / DTO - -### WorkLogController -- 포트폴리오 보드 엔드포인트. 리소스 이름은 AIP-122 를 따릅니다: `/worklogs`(복수·소문자), - `/worklogs/repoStats`(lowerCamelCase 하위 세그먼트), 콜론-동사 커스텀 메서드 - `/worklogs:batchCreate`. `/v1` 버전 접두사는 `PresentationWebConfig` 가 중앙에서 붙입니다. -- **GET /worklogs**: 페이지네이션 + (선택) 정렬 + (선택) 평면 동등 필터. 정렬 문자열은 - `SortParam.parse` 가 파싱해 native 가 아닌 구문은 400 으로 거절하고, 허용 목록에 없는 필드도 - 거절합니다. offset 이 너무 깊으면 커서 사용을 권하는 `Deprecation` 헤더를 답니다. -- **GET /worklogs/{id}**: `ETag` 를 내보내고, `If-None-Match` 가 맞으면 본문 없이 304 를 답니다. -- **PATCH /worklogs/{id}**: `If-Match` 가 오면 현재 ETag 와 같아야 하고, 다르면 412(낙관적 동시성). - `If-Match` 가 없으면 검사를 건너뜁니다 — 스켈레톤은 권장 패턴을 보여주되 강제하지는 않습니다. - `ETag`/`If-Match` 는 HTTP 전송 관심사(RFC 7232)라, 읽고-비교하는 로직을 일부러 웹 어댑터에만 - 두고 application/domain 은 ETag 를 전혀 보지 않게 합니다. -- **POST /worklogs**: `Idempotency-Key` 헤더는 POST 요청 표면의 일부로 받아만 둡니다. 키의 - 모양·범위·재생(replay) 의미는 별도 계약(rate-limit-idempotency)의 몫이라, 그 브랜치가 replay 를 - 배선하기 전까지는 서버가 헤더를 그냥 허용(무시)합니다. -- **POST /worklogs:batchCreate**: AIP-136 콜론-동사 일괄 생성. **동기 = 원자적**이라 한 항목이라도 - 실패하면 배치 전체가 롤백되고 단일 4xx 가 납니다. 한 번에 보낼 수 있는 항목 수는 - `MAX_BATCH_SIZE`(1000)로 제한하고, 넘으면 400 입니다. 요청 봉투는 `{ "requests": [ ... ] }` 형태입니다. -- **소유자(owner) 스탬프**: 생성되는 WorkLog 에는 인증된 호출자의 IdP subject 를 소유자로 찍습니다. - 보안 컨텍스트에서 null 안전하게 읽으며, 이 값은 **가공 전 원시 principal id** 입니다 — - 가명화(pseudonymization)는 별도 프라이버시 계약의 몫이라 여기서 적용하지 않고, 소유자 범위 - 인가(ABAC)는 의도적으로 뒤로 미룬 확장입니다. 유스케이스에 건 `worklog:write` 권한이 생성이 실제 - 실행될 시점엔 인증된 principal 이 있음을 보장합니다. -- **id 정규화(`toId`)**: 대소문자 무관 canonical UUID 경로 입력을 받아, 도메인 id 를 만들기 전에 - 표준 36자 소문자 UUID 로 정규화합니다. 여기서 쓰는 `UUID.fromString(...)` 은 (생성기가 아니라) 파서라, - 잘못된 id 면 `IllegalArgumentException` 을 던지고 전역 핸들러가 이를 HTTP 400 으로 매핑합니다. - -### OperationsController / SampleOperationStore / WorkLogExportResult — 장기 실행 작업(LRO) 예시 -- `POST /worklogs:export` 는 `202 Accepted` + 폴링 경로를 가리키는 `Location` 헤더 + `operationId`/ - `statusUrl` 을 담은 봉투를 돌려주고, `GET /operations/{id}` 로 상태를 폴링합니다. 상태는 5값 - `OperationStatus` enum 에서 옵니다. -- **operation id 를 컨트롤러가 아니라 `SampleOperationStore` 에서 만드는 이유**: 컨트롤러/유스케이스는 - id 를 직접 생성하면 안 된다는 규칙(`no_uuid_random_in_controller`) 때문입니다. `SampleOperationStore` - 는 컨트롤러도 application 서비스도 아닌 웹 인프라 컴포넌트라 여기서 id 를 발급해도 됩니다. -- **단순화**: 이 "export" 는 동기로 끝나므로, 클라이언트가 폴링할 때 저장된 operation 은 이미 - `SUCCEEDED` 입니다. 진짜 비동기 잡이라면 PENDING → RUNNING → 종료 로 전이하겠지만, wire 모양 - (202 + Location + 폴링 enum)은 동일합니다. - -### SamplePolymorphicRequest — 안전한 다형 역직렬화(B5) -- 다형 JSON 을 안전하게 받는 표준 예시입니다. `@JsonTypeInfo(use = NAME, property = "kind")` + - 명시적 `@JsonSubTypes` 허용 목록은, RCE 취약점(CVE-2019-14379)의 입구인 - `ObjectMapper.enableDefaultTyping()` 대신 Jackson 이 권장하는 방식입니다. 목록에 없는 하위 타입은 - `InvalidTypeIdException` 으로 거절되고, 계약 핸들러가 이를 `MAPPING_FAILED` 로 보냅니다. -- `sealed` 키워드를 쓴 이유: 허용 목록과 타입 계층을 기계적으로 일치시키기 위해서입니다. `permits` - 만 추가하고 `@JsonSubTypes.Type` 을 안 넣거나(혹은 반대로) 하면 조용히 통과되는 게 아니라 - 컴파일/테스트에서 잡힙니다. - -### WorkLogWebMapper -- 요청 DTO ↔ application command 변환을 **웹 어댑터가 전부 책임**지게 모은 매퍼입니다. PATCH 도 - 컨트롤러에서 인라인으로 만들지 않고 매퍼를 거치게 해서 일관성을 지킵니다. `WorkLogId` 는 웹 - edge 에서 파싱(UUID 경로 정규화)해 넘겨받아, id 구문 관심사를 컨트롤러에 둡니다. -- 응답에는 **소유자(owner)를 일부러 넣지 않습니다** — 원시 principal id 노출은 프라이버시 문제라, - 가명화는 별도 프라이버시 계약의 몫입니다. -- 링크 URI 가 잘못되면 `MappingException` 을 던져 전역 핸들러가 `MAPPING_FAILED`(400)로 보냅니다. - -### WorkLogIdSerializer -- `WorkLogId` 를 record 기본 모양(`{"value":"..."}`)이 아니라 **맨 36자 소문자 UUID 문자열**로 - 직렬화합니다. `@JsonComponent` 로 Spring Boot 가 자동 등록합니다. - -### DomainExceptionHandler / PortfolioErrorCode -- **DomainExceptionHandler**: 이 샘플의 도메인 예외를 `Envelope` 로 매핑합니다. 운영/전송/보안 - 예외는 스켈레톤의 기본 `GlobalExceptionHandler` 가 처리하고, Spring 이 두 advice 를 함께 적용합니다. - 이 핸들러를 `@Order(HIGHEST_PRECEDENCE)` 로 **기본 핸들러보다 앞**에 두는 이유는, 그렇지 않으면 - 기본 advice 의 포괄 핸들러(`@ExceptionHandler(Exception.class)`)가 도메인 예외를 먼저 잡아 - INTERNAL_ERROR 로 만들어 버리기 때문입니다. -- **PortfolioErrorCode**: 이 샘플 전용 도메인 에러 코드. 운영/전송/보안 코드는 공용 - `OperationalError` 에 있습니다. - -### WorkLogValidationGroups -- `@GroupSequence` 용 검증 그룹 순서(형식 → 불변식)를 정의합니다 — 형식 검사가 실패하면 불변식 - 평가를 건너뛰게 합니다. - -## adapter/persistence — RDBMS / JPA 영속 - -### WorkLogEntity -- WorkLog 애그리거트의 JPA 엔티티. `AuditableEntity` 를 상속해 `created_at/updated_at/created_by/ - updated_by` 감사 컬럼을 물려받습니다. **감사 필드는 엔티티(영속 계층)에만 있고 도메인 WorkLog 에는 - 없습니다** — 값은 `WorkLogRepositoryAdapter` 가 Clock + 감사 컨텍스트로 찍어줍니다. -- **id 저장 형식**: UUIDv7 을 varchar(36)가 아니라 PostgreSQL 16 의 native `uuid` 타입(16바이트 - 바이너리)으로 저장합니다. UUID 문자열↔native uuid 변환은 `WorkLogPersistenceMapper` 가 합니다. - (tenant 범위 컬럼/복합 인덱스는 tenant 정책 계약으로 미뤄둠.) -- `@Version version`: 낙관적 락 버전이자 웹 경계 ETag/If-Match 의 출처. Hibernate 가 증가를 - 관리하고, `null` 이면 새 행이라 `save()` 가 INSERT 합니다. - -### WorkLogPersistenceMapper — UUID 문자열 ↔ native uuid 변환 -- 36자 canonical UUID 와 PostgreSQL native `uuid`(128비트)를 서로 변환합니다. `UuidCodec` 같은 공용 - 코덱이 아니라 JDK `java.util.UUID` 를 **직접** 쓰는 이유: 영속 어댑터는 경계 규칙상 - `adapter-outbound`(코덱이 있는 곳)에 의존하면 안 되기 때문입니다(stdlib 이라 의존 문제 자체가 없음). -- 엔티티로 변환할 때 도메인 `version` 을 그대로 실어, JPA 가 새 행(`null` → INSERT)과 추적 중인 행 - (non-null → 낙관적 merge)을 구분할 수 있게 합니다. - -### WorkLogRepositoryAdapter — 저장 + 감사 스탬프 -- `WorkLogRepository` 포트 구현체. 감사 메타데이터(생성/수정 시각·주체)를 **여기 영속 어댑터에서** - 찍습니다(생성자 주입 Clock + 감사 컨텍스트). 도메인 WorkLog 는 감사 필드를 들고 있지 않습니다. -- INSERT(버전 null): 새 애그리거트라 created/updated 둘 다 지금 시각·주체로 초기화합니다. -- UPDATE(버전 non-null): 기존 행의 created 값을 읽어 그대로 이어가고(같은 트랜잭션에서 유스케이스가 - 이미 로드해둔 JPA 1차 캐시에서 served), updated 만 갱신합니다. - -### WorkLogJpaRepository -- `findByCategory`: 계약이 허용하는 유일한 필터 구문인 **평면 동등 필터**를 데이터 계층 안에 둔 - 파생 쿼리입니다. -- `findRecentSummaryRows`: CQRS-lite projection 읽기. JPQL `SELECT new` 생성자 표현식으로 요약 - 컬럼만 골라(`content/summary/techStack/links` 제외) `WorkLogSummaryRow` 로 담는 컬럼-부분 읽기라, - 애그리거트 복원과 lazy `@ElementCollection` 조인을 건너뜁니다. 닫힌 인터페이스 projection 대신 - `SELECT new`/`JdbcTemplate` 을 택한 이유: 닫힌-projection 의 컬럼 가지치기는 구현체 의존적이고 - Hibernate 6 에서 검증되지 않았기 때문입니다. - -### WorkLogSummaryQueryAdapter / WorkLogSummaryRow -- **WorkLogSummaryQueryAdapter**: application `WorkLogSummaryQueryPort` 구현체. JPQL projection 쿼리에 - 위임하고, 저장소 native `WorkLogSummaryRow`(UUID id)를 application `WorkLogSummary`(UUID 문자열 id)로 - 매핑합니다. **저장소 native `UUID` 는 이 어댑터를 벗어나지 않습니다** — application 에 노출되는 읽기 - 모델은 canonical UUID 문자열 형식으로 말합니다. -- **WorkLogSummaryRow**: projection 의 영속-쪽 읽기 행. JPQL `SELECT new` 의 대상이며 저장 모양 - (id 가 native `UUID`)을 그대로 반영합니다. 이 행을 persistence 패키지 안에 두는 것 자체가 native - `UUID` 가 어댑터를 벗어나지 못하게 막는 장치입니다. - -### JpaConfig -- Spring Boot 메인 클래스가 `dev.caskeleton.bootstrap` 에 있어, 패키지 기준 기본 스캔이 - `…sample.portfolio.adapter.persistence.*` 를 놓칩니다. 그래서 엔티티/리포지토리 스캔 위치를 여기서 - 명시해, 그 배선을 실제로 소유하는 모듈 안에 둡니다. - -## adapter/outbound — 아웃바운드 통합 (저장소 통계 ACL) - -### RepoStatsPortClient / RepoStatsAclMapper / RawRepoStatsResponse -- **ACL(Anti-Corruption Layer) 패턴(B7) 예시**입니다. 외부 응답이 도메인으로 들어오기 전에 반드시 - ACL 매퍼를 거치게 해서, 원시 외부 타입이 도메인을 오염시키지 못하게 합니다. -- **RepoStatsPortClient**: 실제 HTTP 호출(`fetchRaw`)을 추상화해 템플릿을 가볍게 유지합니다 — 실제 - 프로젝트라면 여기에 WebClient/RestClient 를 끼웁니다. 원시 응답은 ACL 을 통과한 뒤에야 도메인 타입이 - 됩니다. -- **RepoStatsAclMapper**: ACL 의 세 가지 일 — 정규화(full name 소문자화), 마스킹(echoedToken 은 버려서 - 도메인에 닿지 않음), 공개 필드 선택(`fullName/stargazers/pushedAt` 만 `RepoStats` 로). 필수 필드가 - 없으면 `MappingException`. -- **RawRepoStatsResponse**: 외부 제공자 원시 모양. 도메인으로 넘어가지 않으며 package-private 입니다. - -## adapter/identifier — ID 생성 (UUIDv7) - -### UuidWorkLogIdFactory / UuidOutboxEventIdFactory -- 도메인 id 포트(`WorkLogIdFactory`, `OutboxEventIdFactory`)의 인프라 구현체입니다. -- 둘 다 `UuidCreator.getTimeOrderedEpochPlus1()` 을 씁니다 — RFC 9562 UUIDv7(time-ordered)이며 같은 - 밀리초 안에서도 단조 증가(monotonic)하고 secure random 으로 뒷받침됩니다. -- **여기가 샘플에서 `UuidCreator` 호출이 허용되는 유일한 곳**입니다. `no_uuid_random_in_controller` - ArchUnit 규칙이 웹/application 계층의 직접 id 생성을 금지하기 때문에, id 생성은 이 인프라 어댑터에만 - 둡니다. - -## bootstrap — 샘플 전용 구성 루트 - -> **이 패키지가 왜 존재하는가 (공통 배경):** 프로덕션 조립은 `app-bootstrap` 모듈이 합니다. 그런데 -> `sample-portfolio` 는 일부러 `app-bootstrap` 에 의존하지 않습니다(샘플이 부트스트랩을 끌어오면 -> 안 됨). 그래서 스캔으로 자동 발견되지 않고 오직 `app-bootstrap` 에만 있는 구성 루트 빈들 -> (tracing/metrics/idempotency/privacy/management/domain-context 등)을, 여기 `bootstrap.*` 패키지가 -> **샘플 전용으로 동등하게 복제**해 제공합니다. 모두 일회용(disposable)입니다 — `sample-portfolio` -> 모듈을 지우면 함께 사라지고 프로덕션에는 영향이 없습니다. 프로덕션(`CaSkeletonApplication`)은 원래 -> `app-bootstrap` 빈들을 그대로 씁니다. - -### SamplePortfolioApplication — 독립 실행 구성 루트 -- `dev.caskeleton` 하위 전부를 스캔해 프로덕션 모듈의 Spring 컴포넌트(adapter-web `SecurityConfig`, - adapter-persistence `PersistenceJpaConfig` 등)를 자동으로 발견·배선합니다. `app-bootstrap` 에만 있어 - 자동 발견되지 않는 구성 루트만 위 `bootstrap.*` 가 채웁니다. -- **`@SpringBootApplication` 대신 `@Configuration` + `@EnableAutoConfiguration` + `@ComponentScan` 을 - 쓴 이유**: `@SpringBootApplication` 은 `@SpringBootConfiguration` 을 포함하는데, 그러면 이 클래스가 - Spring Boot 의 `AnnotatedClassFinder` 에 보입니다. 그 finder 가 이 클래스와 (같은 패키지·테스트 - classpath 의) `SamplePortfolioTestApplication` **둘 다** 찾으면 "multiple @SpringBootConfiguration" - 오류를 냅니다. `@Configuration`(= `@SpringBootConfiguration` 아님)을 쓰면 finder 에 숨으면서도, - `@SpringBootTest(classes = SamplePortfolioApplication.class)` 로 부팅하는 데는 문제가 없습니다. -- **`@ComponentScan` 에 exclude 두 개를 둔 이유**: - - `PostgreSqlPersistenceConfig` 제외 — 이 클래스는 `@PersistenceContext EntityManager` 필드와 - `FlywayConfigurationCustomizer @Bean` 을 함께 가져, 깨지지 않는 초기화 순환을 만듭니다 - (flyway → customizer 수집 → PostgreSqlPersistenceConfig 인스턴스화 → `@PersistenceContext` 가 - entityManagerFactory 를 당김 → 그건 flywayInitializer 에 의존 → 순환). 대신 - `SamplePostgreSqlPersistenceConfig` 가 static `@Bean` 으로 동일 빈을 제공해 순환을 끊습니다(아래 참고). - - `TestEnclosedConfigurationFilter` 제외 — 테스트 클래스 안에 중첩된 `@Configuration` 들이 넓은 - 컴포넌트 스캔에 잡혀, 두 테스트 픽스처가 같은 이름의 빈(예: `clock`)을 정의하면 - `BeanDefinitionOverrideException` 이 나기 때문입니다. - -### SamplePostgreSqlPersistenceConfig — Flyway ↔ EntityManager 초기화 순환 끊기 -- `adapter-persistence-postgresql` 의 `PostgreSqlPersistenceConfig` 를 샘플 전용으로 대체한 것입니다. -- **왜 필요한가**: 원본은 `@PersistenceContext EntityManager` 필드와 `FlywayConfigurationCustomizer` - 빈을 함께 등록합니다. Spring Boot 의 Flyway 자동 구성은 `entityManagerFactory` 가 준비되기 전에 모든 - customizer 빈을 모읍니다(Flyway 가 먼저 마이그레이션을 돌려야 JPA 가 스키마를 검증할 수 있으니까). - 그 customizer 를 모으려고 `PostgreSqlPersistenceConfig` 를 인스턴스화하면 `@PersistenceContext` 가 - `entityManagerFactory` 를 당기는데, 그 빈은 `flywayInitializer` 완료에 의존합니다 → - `flyway → PostgreSqlPersistenceConfig → entityManagerFactory → flywayInitializer → flyway` 의 끊을 수 - 없는 순환이 생깁니다. -- **어떻게 끊는가**: customizer 를 **static `@Bean`** 으로 등록합니다. Spring 은 static `@Bean` 을 - 소유 클래스 인스턴스화 없이 호출하므로, Flyway 초기화 동안 `@PersistenceContext` 가 처리되지 - 않습니다. `EntityManager` 는 (런타임에 실제로 필요해질 때까지 미루도록) 빈 경로에서 늦게 해소됩니다. -- **마이그레이션 위치 주의**: 이 customizer 가 두 위치(프로덕션 `db/migration/postgresql` + 샘플 전용 - `db/sample-migration`)를 모두 설정합니다. `configuration.locations(...)` 는 기존 설정을 **덮어쓰므로** - (append 아님), `application.yml` 의 `spring.flyway.locations` 는 런타임 효과 없는 문서용일 뿐이고 - 실제 기준은 이 customizer 입니다. - -### TestEnclosedConfigurationFilter — 테스트 중첩 @Configuration 제외 -- 테스트 클래스 안에 중첩된 `@Configuration` 을 컴포넌트 스캔에서 빼는 필터입니다. -- **문제**: `:sample-portfolio:test` 를 돌리면 테스트 클래스가 classpath 에 올라옵니다. 테스트 안 - static 중첩 `@Configuration`(예: `WorkLogAuthorizationContractTest$AuthzTestConfig`)이 평범한 - `@Configuration` 이라, 전체 컨텍스트로 부팅할 때 스캔에 발견·등록됩니다. 두 테스트 설정이 같은 이름의 - 빈(예: `clock`)을 정의하면 `BeanDefinitionOverrideException` 이 납니다(override 기본 false). -- **휴리스틱**: 바이너리 이름에 `$` 가 있고 그 바깥(enclosing) 클래스 이름이 `Test` 로 끝나면(자바 - 테스트 클래스 관례) 제외합니다. 테스트 프레임워크 의존성을 프로덕션 소스에 들이지 않고도 중첩 - 테스트 픽스처를 전부 거릅니다. - -### 분산 추적 복제본 — SampleTracingConfig / …Settings / …SampleRateResolver / …EnvironmentPostProcessor / SampleMicrometerSpanErrorRecorder -- `app-bootstrap` 의 tracing 구성 루트를 샘플 전용으로 옮긴 것들입니다(위 "공통 배경" 참고). tracing - 런타임(OTel/Micrometer 브리지)이 샘플 build.gradle 에 켜져 있지만, 그 빈들을 등록하는 조립 코드는 - `app-bootstrap` 에만 있어서 복제했습니다. -- **per-profile 기본 샘플 레이트(SampleRateResolver)**: `prod=0.01`, `staging=0.10`, `dev/local=1.0` - (그 외 프로파일도 1.0). 설정값(`APP_TRACING_SAMPLE_RATE`)이 `[0.0, 1.0]` 범위로 들어오면 그 값이 - per-profile 기본을 이깁니다. -- **EnvironmentPostProcessor**: 해소된 effective 레이트를 native 키 - `management.tracing.sampling.probability` 로 이어줘, 실제 OTel sampler 가 gauge 값과 일치하게 합니다. - 사용자가 native 키를 직접 지정했으면 그 값을 우선합니다. (이 PostProcessor 는 샘플 자신의 - `META-INF/spring.factories` 로 등록됩니다.) -- **SampleMicrometerSpanErrorRecorder**: 현재 span 에 예외를 `error.code` 태그와 함께 기록(D12). - Tracer 가 있으면 실제 recorder, 없으면 NOOP 로 동작합니다(adapter-web `GlobalExceptionHandler` 가 - `ObjectProvider` 로 집어 씀). - -### 그 밖의 부트스트랩 복제본 -- **SampleMetricsContractConfig**: Micrometer `MeterFilter` 두 개를 meter 등록 전에 설치합니다 — - 고-cardinality 태그 키를 막는 필터(D8)와 SLO 기반 히스토그램 분포 필터(D9, 소유한 timer 이름에만 적용). -- **SampleIdempotencyConfig / …Settings**: 스캔된 `IdempotencyStoreAdapter`/`IdempotencyReaper` 와 - `IdempotencyExecutor` 가 모두 `Clock` 을 필요로 하고, `@EnableScheduling` 이 reaper 의 `@Scheduled` - 정리를 켭니다. TTL 은 양수이며 ≤ 72h 여야 합니다(아니면 시작 실패), reaper 주기 기본 10분. -- **SamplePseudonymizationConfig / SamplePrivacySettings**: adapter-web `RequestLoggingFilter` 가 - `UserPrincipalPseudonymizerPort` 를 주입받으므로 그 포트를 채웁니다. salt 는 - `APP_PRIVACY_PSEUDONYMIZATION_SALT` 에서 오며, 비어 있으면 로컬/테스트가 시작은 되도록 dev 센티넬 - salt 로 폴백합니다(프로덕션 전엔 실제 시크릿 값을 넣을 것). -- **SampleDomainContextConfig**: `adapter-persistence-rdbms` 의 `DomainContextAuditContextPort` 가 - `DomainContextPropagator` 빈을 무조건 주입받으므로, 기본 `THREAD_LOCAL` 전략 구현 - (`ThreadLocalDomainContextPropagator`)을 제공합니다 — async 데코레이터를 안 켜는 WorkLog 데모에 - 적합합니다. diff --git a/src/sample-portfolio/build.gradle b/src/sample-portfolio/build.gradle deleted file mode 100644 index f543dea..0000000 --- a/src/sample-portfolio/build.gradle +++ /dev/null @@ -1,159 +0,0 @@ -// Fixture/sample module. Production modules must not depend on this module. -// Lean standalone boot: apply the Spring Boot plugin so bootJar / bootRun are available. -apply plugin: 'org.springframework.boot' - -apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle" - -def repositoryRootForContractTests = rootProject.projectDir.parentFile -def errorCodeRegistryForContractTests = new File( - repositoryRootForContractTests, - 'docs/registries/error-codes.yaml') - -tasks.withType(Test).configureEach { - systemProperty 'ca.repository.root', repositoryRootForContractTests.absolutePath -} - -tasks.named('test') { - inputs.file(errorCodeRegistryForContractTests) - .withPathSensitivity(PathSensitivity.RELATIVE) -} - -sourceSets { - posterImageMigrationTest { - java.srcDir 'src/posterImageMigrationTest/java' - resources.srcDir 'src/posterImageMigrationTest/resources' - compileClasspath += sourceSets.main.output + sourceSets.test.output - runtimeClasspath += output + compileClasspath - } -} - -configurations { - posterImageMigrationTestImplementation.extendsFrom testImplementation - posterImageMigrationTestRuntimeOnly.extendsFrom testRuntimeOnly -} - -// The objectstorage adapter (runtimeOnly) pulls in software.amazon.awssdk:s3, whose version is -// managed by the AWS SDK v2 BOM (NOT the Spring Boot BOM). Import that BOM at this consumer's scope -// — mirroring the objectstorage module — so the transitive s3 dependency resolves on this module's -// runtime/test classpath. The AWS-SDK version SSOT stays ext.awsSdkVersion. -dependencyManagement { - imports { - mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}" - } -} - -dependencies { - implementation project(':domain-core') - implementation project(':application-core') - implementation project(':adapter:outbound:persistence-jpa') - implementation project(':adapter:outbound:identifier') - // Object storage adapter (filesystem default / S3-MinIO opt-in) provides the ObjectStoragePort - // bean at runtime for the Poster image-upload use case; the port itself lives in application-core. - runtimeOnly project(':adapter:outbound:objectstorage') - implementation project(':adapter:inbound:web') - implementation project(':shared-contract') - implementation 'org.springframework.boot:spring-boot-starter' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' - // UUIDv7 generation (id factory) + UUID/String conversion (persistence mapper, web path). - implementation 'com.github.f4b6a3:uuid-creator:6.1.1' - // PATCH 3-state (absent / explicit-null / value) via JsonNullable. See README. - implementation 'org.openapitools:jackson-databind-nullable:0.2.6' - // Lean standalone boot: actuator + prometheus + tracing runtime + dotenv (mirrors app-bootstrap). - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'io.micrometer:micrometer-registry-prometheus' - implementation 'io.micrometer:micrometer-tracing-bridge-otel' - implementation 'io.opentelemetry:opentelemetry-exporter-otlp' - // Spring dotenv: picks up src/.env when bootRun workingDir = src/ (same as app-bootstrap). - implementation 'me.paulschwarz:spring-dotenv:4.0.0' - // Boot 4 Flyway API/autoconfiguration needed by sample-local persistence configuration. - implementation 'org.springframework.boot:spring-boot-flyway' - // Flyway core API needed by MigrationStartupConfig (scanned from adapter-persistence). - implementation 'org.flywaydb:flyway-core' - // Micrometer core for OutboxMetrics / BackgroundJobMetrics. - implementation 'io.micrometer:micrometer-core' - // (spring-boot-starter-security is already declared above; it also backs ManagementSecurityConfig.) - // Property-based testing for @ValueObject invariants (registers its own JUnit Platform engine). - testImplementation 'net.jqwik:jqwik:1.9.1' - // Real-DB integration test for the persistence adapter (disposable Postgres, real Flyway + JPA). - testImplementation 'org.testcontainers:testcontainers-postgresql' - testImplementation 'org.testcontainers:testcontainers-junit-jupiter' - // @ServiceConnection supplies datasource connection details to Spring context/slice tests. - testImplementation 'org.springframework.boot:spring-boot-testcontainers' - // Boot 4 split JPA/JDBC slice annotations and TestEntityManager into dedicated test modules. - testImplementation 'org.springframework.boot:spring-boot-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-jdbc-test' - testRuntimeOnly 'org.postgresql:postgresql' - // Spring Security test support (MockMvc + @WithMockUser) for any slice tests. - testImplementation 'org.springframework.security:spring-security-test' -} - -// feature-contract-verification-test-suite §5 — OpenAPI drift gate. -// OpenApiDriftContractTest runs as a normal test (so `check` is release-blocking on drift) AND -// can be invoked through this task to (re)generate the committed snapshot after an intentional -// API change: ./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange -tasks.register('openapiCheckSnapshot', Test) { - group = 'verification' - description = 'OpenAPI drift gate: runtime springdoc /v3/api-docs vs the committed snapshot. ' + - '-PapproveOpenApiChange regenerates the committed baseline.' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - useJUnitPlatform() - filter { - includeTestsMatching 'dev.caskeleton.sample.portfolio.adapter.inbound.web.contract.OpenApiDriftContractTest' - } - systemProperty 'openapi.snapshot.write', project.hasProperty('approveOpenApiChange') ? 'true' : 'false' - // Always re-check drift; never serve a stale UP-TO-DATE result. - outputs.upToDateWhen { false } - // Pin UTC like the main test task for host-locale independence. - jvmArgs '-Duser.timezone=UTC' -} - -def posterImageMigrationQualification = registerStrictQualificationTest( - name: 'posterImageMigrationTest', - sourceSet: sourceSets.posterImageMigrationTest, - requiredClasses: [ - 'dev.caskeleton.sample.portfolio.qualification.PosterImageIdempotencyRotationQualificationTest', - 'dev.caskeleton.sample.portfolio.qualification.PosterImageRetirementQualificationTest', - 'dev.caskeleton.sample.portfolio.qualification.PosterImageV8MigrationQualificationTest' - ], - description: 'Runs the non-skipping PostgreSQL Poster image migration/rotation lane.') -posterImageMigrationQualification.configure { - shouldRunAfter tasks.named('test') -} - -def messagingSampleContractQualification = registerStrictQualificationTest( - name: 'messagingSampleContractQualificationTest', - sourceSet: sourceSets.test, - requiredClasses: [ - 'dev.caskeleton.sample.portfolio.application.event.WorkLogReservedContractContributionTest' - ], - junitXmlOutput: rootProject.layout.buildDirectory.dir( - 'test-results/messaging-evidence/sample'), - binaryResultsOutput: rootProject.layout.buildDirectory.dir( - 'test-results/messaging-evidence-binary/sample'), - description: 'Runs exact Messaging sample contract qualification tests.') -messagingSampleContractQualification.configure { - dependsOn ':prepareMessagingContractEvidence' -} - -bootJar { - mainClass = 'dev.caskeleton.sample.portfolio.SamplePortfolioApplication' -} - -tasks.register('stageDockerJar', Sync) { - dependsOn tasks.named('bootJar') - from(tasks.named('bootJar').flatMap { it.archiveFile }) - into(layout.buildDirectory.dir('docker')) - rename { 'application.jar' } -} - -// Run from the repo's src/ root so src/.env is picked up (same as app-bootstrap). -bootRun { - workingDir = rootProject.projectDir -} diff --git a/src/sample-portfolio/gradle.lockfile b/src/sample-portfolio/gradle.lockfile deleted file mode 100644 index 0645f37..0000000 --- a/src/sample-portfolio/gradle.lockfile +++ /dev/null @@ -1,338 +0,0 @@ -# This is a Gradle generated file for dependency locking. -# Manual edits can break the build and are not advised. -# This file is expected to be part of source control. -aopalliance:aopalliance:1.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.f4b6a3:uuid-creator:6.1.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath -com.github.spotbugs:spotbugs:4.10.2=spotbugs -com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,posterImageMigrationTestCompileClasspath,spotbugs,testCompileClasspath -com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath -com.google.errorprone:error_prone_annotations:2.41.0=spotbugs -com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.h2database:h2:2.4.240=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.squareup.okhttp3:okhttp-jvm:5.2.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okhttp3:okhttp:5.2.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okio:okio-jvm:3.16.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.squareup.okio:okio:3.16.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.sun.istack:istack-commons-runtime:4.1.2=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.zaxxer:HikariCP:7.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -commons-beanutils:commons-beanutils:1.11.0=checkstyle -commons-codec:commons-codec:1.19.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -commons-collections:commons-collections:3.2.2=checkstyle -commons-io:commons-io:2.20.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -info.picocli:picocli:4.7.7=checkstyle -io.github.cdimascio:dotenv-java:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -io.micrometer:context-propagation:1.2.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing:1.6.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-compression:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http2:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-marshalling:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-protobuf:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-common:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport-classes-epoll:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-common:1.55.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-exposition-formats:1.4.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.38=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.38=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.inject:jakarta.inject-api:2.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs -me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.17.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.18.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.jqwik:jqwik-api:1.9.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.jqwik:jqwik-engine:1.9.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath -net.jqwik:jqwik-time:1.9.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.jqwik:jqwik-web:1.9.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.jqwik:jqwik:1.9.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs -org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-compress:1.28.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=checkstyle,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-text:1.15.0=spotbugs -org.apache.commons:commons-text:1.3=checkstyle -org.apache.httpcomponents:httpclient:4.5.13=checkstyle,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.httpcomponents:httpcore:4.4.16=checkstyle,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.maven.doxia:doxia-core:1.12.0=checkstyle -org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle -org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle -org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.aspectj:aspectjweaver:1.9.25=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.assertj:assertj-core:3.27.6=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle -org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle -org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle -org.codehaus.plexus:plexus-utils:3.3.0=checkstyle -org.dom4j:dom4j:2.2.0=spotbugs -org.eclipse.angus:angus-activation:2.0.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.flywaydb:flyway-core:11.14.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.flywaydb:flyway-database-postgresql:11.14.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hamcrest:hamcrest:3.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hdrhistogram:HdrHistogram:2.2.2=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hibernate.models:hibernate-models:1.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.2.21=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath -org.jetbrains:annotations:17.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,posterImageMigrationTestAnnotationProcessor,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit:junit-bom:6.1.0=spotbugs -org.latencyutils:LatencyUtils:2.0.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.mockito:mockito-core:5.20.0=mockitoAgent,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath -org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath -org.ow2.asm:asm-analysis:9.10.1=spotbugs -org.ow2.asm:asm-commons:9.10.1=spotbugs -org.ow2.asm:asm-tree:9.10.1=spotbugs -org.ow2.asm:asm-util:9.10.1=spotbugs -org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -org.postgresql:postgresql:42.7.8=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.reflections:reflections:0.10.2=checkstyle -org.rnorth.duct-tape:duct-tape:1.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.skyscreamer:jsonassert:1.5.3=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-testcontainers:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-commons:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-core:7.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-jdbc:7.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-config:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-test:7.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aspects:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-orm:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -software.amazon.awssdk:annotations:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:apache-client:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:arns:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:auth:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:aws-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:aws-query-protocol:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:aws-xml-protocol:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:checksums-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:checksums:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:crt-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:endpoints-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-auth-aws:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-auth-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-auth:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:http-client-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:identity-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:json-utils:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:metrics-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:netty-nio-client:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:profiles:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:protocol-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:regions:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:retries-spi:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:retries:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:s3:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:sdk-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:third-party-jackson-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.awssdk:utils:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -software.amazon.eventstream:eventstream:1.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/SamplePortfolioApplication.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/SamplePortfolioApplication.java deleted file mode 100644 index c043021..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/SamplePortfolioApplication.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.caskeleton.sample.portfolio; - -import dev.caskeleton.adapter.inbound.web.auth.SecurityConfig; -import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; -import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig; -import dev.caskeleton.sample.portfolio.bootstrap.scan.TestEnclosedConfigurationFilter; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.FilterType; - -/** - * Standalone composition root for the WorkLog sample/reference application. Scans all {@code - * dev.caskeleton} sub-packages so production-module components are auto-discovered; - * composition-root beans that live only in {@code app-bootstrap} are provided by the sample-local - * {@code bootstrap.*} package. Disposable: deleting the {@code sample-portfolio} module removes it - * without affecting production. - * - *

    See README for the wiring rationale: why {@code @Configuration} is used instead of - * {@code @SpringBootApplication}, why the two component-scan exclusions exist, and the - * lean-bootstrap omissions a full clone would add. - */ -@Configuration -@EnableAutoConfiguration -@ComponentScan( - basePackages = "dev.caskeleton", - excludeFilters = { - // Replaced by SamplePostgreSqlPersistenceConfig to break a Flyway/EntityManager - // init cycle (see README). - @ComponentScan.Filter( - type = FilterType.ASSIGNABLE_TYPE, - classes = { - PostgreSqlPersistenceConfig.class, - // The standalone sample is a public demo app: URL access should not require a - // configured IdP or method-security principal. Production modules keep these guards. - SecurityConfig.class, - MethodSecurityConfig.class - }), - // Excludes @Configuration nested in test classes (see README). - @ComponentScan.Filter( - type = FilterType.CUSTOM, - classes = TestEnclosedConfigurationFilter.class) - }) -@ConfigurationPropertiesScan(basePackages = "dev.caskeleton") -public class SamplePortfolioApplication { - - public static void main(String[] args) { - SpringApplication.run(SamplePortfolioApplication.class, args); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java deleted file mode 100644 index 903f6c5..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/LegacyPosterImageController.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import dev.caskeleton.application.storage.StoredObject; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.StoredObjectResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.PosterWebMapper; -import dev.caskeleton.sample.portfolio.application.command.UploadPosterImageCommand; -import dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.shared.error.MappingException; -import java.io.IOException; -import java.util.Locale; -import java.util.UUID; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -/** Explicit compatibility-profile whole-byte endpoint; absent from canonical publication mode. */ -@RestController -@ConditionalOnProperty(prefix = "app.poster-image.api", name = "mode", havingValue = "legacy") -// This exact endpoint remains active until data/API migration is complete. -@SuppressWarnings("removal") -public final class LegacyPosterImageController { - - private final UploadPosterImageUseCase upload; - - public LegacyPosterImageController(UploadPosterImageUseCase upload) { - this.upload = upload; - } - - @PostMapping(value = "/posters/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public StoredObjectResponse uploadImage( - @PathVariable String id, @RequestParam("file") MultipartFile file) { - byte[] bytes; - try { - bytes = file.getBytes(); - } catch (IOException exception) { - throw new MappingException("failed to read uploaded file", exception); - } - String contentType = - file.getContentType() == null ? "application/octet-stream" : file.getContentType(); - StoredObject stored = upload.handle(new UploadPosterImageCommand(toId(id), bytes, contentType)); - return PosterWebMapper.toStoredObjectResponse(stored); - } - - private static PosterId toId(String raw) { - return PosterId.of(UUID.fromString(raw.toLowerCase(Locale.ROOT)).toString()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsController.java deleted file mode 100644 index b52dceb..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsController.java +++ /dev/null @@ -1,45 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.operation.SampleOperationStore; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.operation.WorkLogExportResult; -import dev.caskeleton.shared.operation.Operation; -import java.net.URI; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * Long-running-operation fixture. {@code POST /worklogs:export} returns {@code 202 Accepted} + a - * {@code Location} header + a polling envelope; {@code GET /operations/{id}} returns the {@link - * Operation}. The id is minted by {@link SampleOperationStore}, never by this controller. See - * README. - */ -@RestController -public class OperationsController { - - private final SampleOperationStore operations; - private final PresentationSettings presentationSettings; - - public OperationsController( - SampleOperationStore operations, PresentationSettings presentationSettings) { - this.operations = operations; - this.presentationSettings = presentationSettings; - } - - @PostMapping("/worklogs:export") - public ResponseEntity> export() { - Operation accepted = - operations.startExport(presentationSettings.apiBasePath(), 0L); - return ResponseEntity.accepted().location(URI.create(accepted.statusUrl())).body(accepted); - } - - @GetMapping("/operations/{id}") - public Operation poll(@PathVariable String id) { - return operations - .find(id) - .orElseThrow(() -> new IllegalArgumentException("unknown operation id: " + id)); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java deleted file mode 100644 index 8cdf94f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterController.java +++ /dev/null @@ -1,164 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; -import dev.caskeleton.adapter.inbound.web.pagination.PageParams; -import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException; -import dev.caskeleton.adapter.inbound.web.pagination.SortParam; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreatePosterRequest; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdatePosterRequest; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.PosterWebMapper; -import dev.caskeleton.sample.portfolio.application.command.ArchivePosterCommand; -import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; -import dev.caskeleton.sample.portfolio.application.command.PublishPosterCommand; -import dev.caskeleton.sample.portfolio.application.poster.ArchivePosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.CreatePosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.DeletePosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.GetPosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.ListPostersUseCase; -import dev.caskeleton.sample.portfolio.application.poster.PublishPosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.UpdatePosterUseCase; -import dev.caskeleton.sample.portfolio.application.query.GetPosterQuery; -import dev.caskeleton.sample.portfolio.application.query.ListPostersQuery; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; -import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; -import dev.caskeleton.shared.response.Envelope; -import dev.caskeleton.shared.response.PageMeta; -import dev.caskeleton.shared.response.ResponseMeta; -import jakarta.validation.Valid; -import java.util.List; -import java.util.Locale; -import java.util.UUID; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -/** - * Promotional-poster endpoints. Resource naming follows AIP-122 ({@code /posters}); lifecycle - * actions are modelled as sub-resource paths ({@code /posters/{id}/image}, {@code /publish}, {@code - * /archive}) — a resource-level colon-verb ({@code {id}:publish}) is rejected by the AIP-122 - * guardrail, which only permits collection-level colon verbs. The {@code /v1} prefix is applied - * centrally. The upload → publish flow is what ties object storage to a domain invariant (a poster - * cannot be published without an image). Mirrors {@code WorkLogController} (conditional-request / - * owner / batch machinery deliberately omitted — WorkLog demonstrates those). - */ -@RestController -public class PosterController { - - private final CreatePosterUseCase create; - private final UpdatePosterUseCase update; - private final PublishPosterUseCase publish; - private final ArchivePosterUseCase archive; - private final DeletePosterUseCase delete; - private final GetPosterUseCase get; - private final ListPostersUseCase list; - - public PosterController( - CreatePosterUseCase create, - UpdatePosterUseCase update, - PublishPosterUseCase publish, - ArchivePosterUseCase archive, - DeletePosterUseCase delete, - GetPosterUseCase get, - ListPostersUseCase list) { - this.create = create; - this.update = update; - this.publish = publish; - this.archive = archive; - this.delete = delete; - this.get = get; - this.list = list; - } - - /** Paginated, optionally sorted + status-filtered listing with {@code meta.page}. */ - @GetMapping("/posters") - public Envelope> list( - @RequestParam(required = false) Integer page, - @RequestParam(required = false) Integer size, - @RequestParam(required = false) String sort, - @RequestParam(required = false) PosterStatus status) { - - PageParams pageParams = PageParams.of(page, size); - PosterSortField sortField = null; - boolean ascending = true; - String appliedSort = null; - if (sort != null && !sort.isBlank()) { - SortParam parsed = SortParam.parse(sort); // rejects non-native syntax → 400 - sortField = PosterSortField.fromRequest(parsed.field()); - if (sortField == null) { - throw new PageValidationException( - "sort", - "SORT_FIELD_NOT_ALLOWED", - "sort field '" + parsed.field() + "' is not sortable"); - } - ascending = parsed.ascending(); - appliedSort = parsed.canonical(); - } - - PosterPage result = - list.handle( - new ListPostersQuery( - pageParams.page(), pageParams.size(), sortField, ascending, status)); - List body = result.items().stream().map(PosterWebMapper::toResponse).toList(); - - PageMeta pageMeta = pageParams.toPageMeta(result.total(), appliedSort); - ResponseMeta meta = ResponseMetaFactory.fromMdc().withPage(pageMeta); - return Envelope.ok(body, meta); - } - - @GetMapping("/posters/{id}") - public PosterResponse getOne(@PathVariable String id) { - return PosterWebMapper.toResponse(get.handle(new GetPosterQuery(toId(id)))); - } - - @PostMapping("/posters") - public ResponseEntity create(@Valid @RequestBody CreatePosterRequest body) { - return ResponseEntity.status(HttpStatus.CREATED) - .body(PosterWebMapper.toResponse(create.handle(PosterWebMapper.toCommand(body)))); - } - - @PatchMapping("/posters/{id}") - public PosterResponse patch( - @PathVariable String id, @Valid @RequestBody UpdatePosterRequest body) { - return PosterWebMapper.toResponse( - update.handle(PosterWebMapper.toUpdateCommand(toId(id), body))); - } - - /** - * {@code DRAFT → PUBLISHED}. Fails with 400 {@code POSTER_IMAGE_REQUIRED} if no image is - * attached. - */ - @PostMapping("/posters/{id}/publish") - public PosterResponse publish(@PathVariable String id) { - return PosterWebMapper.toResponse(publish.handle(new PublishPosterCommand(toId(id)))); - } - - @PostMapping("/posters/{id}/archive") - public PosterResponse archive(@PathVariable String id) { - return PosterWebMapper.toResponse(archive.handle(new ArchivePosterCommand(toId(id)))); - } - - @DeleteMapping("/posters/{id}") - public ResponseEntity delete(@PathVariable String id) { - delete.handle(new DeletePosterCommand(toId(id))); - return ResponseEntity.noContent().build(); - } - - /** - * Accepts case-insensitive canonical UUID path input and normalises it to the canonical 36-char - * lowercase UUID. {@code UUID.fromString(...)} is a parser (throws {@link - * IllegalArgumentException} on a malformed id → HTTP 400). - */ - private static PosterId toId(String raw) { - return PosterId.of(UUID.fromString(raw.toLowerCase(Locale.ROOT)).toString()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationController.java deleted file mode 100644 index ee0ea3e..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationController.java +++ /dev/null @@ -1,113 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectDigest; -import dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.application.outbound.CallBudget; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationStatusResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.PosterImagePublicationWebMapper; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.objectstorage.MultipartObjectContentProducer; -import dev.caskeleton.sample.portfolio.application.command.PublishPosterImageCommand; -import dev.caskeleton.sample.portfolio.application.poster.GetPosterImagePublicationStatusUseCase; -import dev.caskeleton.sample.portfolio.application.poster.PublishPosterImageUseCase; -import dev.caskeleton.sample.portfolio.application.query.GetPosterImagePublicationStatusQuery; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.time.Duration; -import java.util.Locale; -import java.util.UUID; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -/** Additive streaming publication/status API; no raw locator or signing surface. */ -@RestController -@ConditionalOnProperty(prefix = "app.poster-image.api", name = "mode", havingValue = "publication") -public final class PosterImagePublicationController { - - private static final Duration CALL_TIMEOUT = Duration.ofMinutes(2); - - private final PublishPosterImageUseCase publish; - private final GetPosterImagePublicationStatusUseCase status; - - public PosterImagePublicationController( - PublishPosterImageUseCase publish, GetPosterImagePublicationStatusUseCase status) { - this.publish = publish; - this.status = status; - } - - @PostMapping( - value = "/posters/{id}/imagePublications", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity publish( - @PathVariable String id, - @RequestHeader(ApiHeaders.IDEMPOTENCY_KEY) String idempotencyKey, - @RequestParam("expectedVersion") long expectedVersion, - @RequestParam("sha256") String sha256, - @RequestParam("file") MultipartFile file, - @AuthenticationPrincipal AuthenticatedPrincipal principal) { - if (idempotencyKey == null - || idempotencyKey.isBlank() - || idempotencyKey.length() > 256 - || principal == null - || principal.idpUserId() == null - || principal.idpUserId().isBlank()) { - throw new IllegalArgumentException( - "authenticated principal and Idempotency-Key are required"); - } - ObjectContentIdentity identity = - new ObjectContentIdentity( - file.getSize(), ObjectDigest.of(ObjectDigestAlgorithm.SHA_256, sha256)); - String contentType = - file.getContentType() == null ? "application/octet-stream" : file.getContentType(); - var reservation = - publish.handle( - new PublishPosterImageCommand( - toId(id), - expectedVersion, - null, - principal.idpUserId(), - idempotencyKey.trim(), - identity, - ObjectMediaType.of(contentType), - new MultipartObjectContentProducer(file, identity), - CallBudget.fromNow(CALL_TIMEOUT), - CancellationView.never())); - PosterImagePublicationResponse response = - PosterImagePublicationWebMapper.toResponse(reservation); - return ResponseEntity.status(HttpStatus.ACCEPTED) - .header(ApiHeaders.LOCATION, response.statusPath()) - .body(response); - } - - @GetMapping("/posters/{id}/imagePublications/{operationId}") - public PosterImagePublicationStatusResponse status( - @PathVariable String id, - @PathVariable String operationId, - @AuthenticationPrincipal AuthenticatedPrincipal principal) { - if (principal == null) { - throw new IllegalArgumentException("authenticated principal is required"); - } - return PosterImagePublicationWebMapper.toResponse( - status.handle( - new GetPosterImagePublicationStatusQuery(toId(id), ObjectOperationId.of(operationId)))); - } - - private static PosterId toId(String raw) { - return PosterId.of(UUID.fromString(raw.toLowerCase(Locale.ROOT)).toString()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogController.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogController.java deleted file mode 100644 index 7bad8fe..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogController.java +++ /dev/null @@ -1,254 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import dev.caskeleton.adapter.inbound.web.conditional.ETags; -import dev.caskeleton.adapter.inbound.web.conditional.PreconditionFailedException; -import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; -import dev.caskeleton.adapter.inbound.web.observability.ResponseMetaFactory; -import dev.caskeleton.adapter.inbound.web.pagination.PageParams; -import dev.caskeleton.adapter.inbound.web.pagination.PageValidationException; -import dev.caskeleton.adapter.inbound.web.pagination.SortParam; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreateWorkLogRequest; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdateWorkLogRequest; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.RepoStatsResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.WorkLogResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.WorkLogSummaryResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper.WorkLogWebMapper; -import dev.caskeleton.sample.portfolio.application.command.BatchCreateWorkLogsCommand; -import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.query.GetRepoStatsQuery; -import dev.caskeleton.sample.portfolio.application.query.GetWorkLogQuery; -import dev.caskeleton.sample.portfolio.application.query.ListWorkLogsQuery; -import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; -import dev.caskeleton.shared.response.Envelope; -import dev.caskeleton.shared.response.PageMeta; -import dev.caskeleton.shared.response.ResponseMeta; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Size; -import java.util.List; -import java.util.Locale; -import java.util.UUID; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -/** - * Portfolio board endpoints. Resource naming follows AIP-122 ({@code /worklogs}, {@code - * /worklogs/repoStats}, colon-verb {@code /worklogs:batchCreate}); the {@code /v1} prefix is - * applied centrally. Pagination/sort/filter drive the list, and conditional requests ({@code - * ETag}/{@code If-Match}/{@code If-None-Match}) drive GET/PATCH. See README. - */ -@RestController -public class WorkLogController { - - private final CreateWorkLogUseCase create; - private final BatchCreateWorkLogsUseCase batchCreate; - private final UpdateWorkLogUseCase update; - private final DeleteWorkLogUseCase delete; - private final GetWorkLogUseCase get; - private final ListWorkLogsUseCase list; - private final GetRepoStatsUseCase repoStats; - - public WorkLogController( - CreateWorkLogUseCase create, - BatchCreateWorkLogsUseCase batchCreate, - UpdateWorkLogUseCase update, - DeleteWorkLogUseCase delete, - GetWorkLogUseCase get, - ListWorkLogsUseCase list, - GetRepoStatsUseCase repoStats) { - this.create = create; - this.batchCreate = batchCreate; - this.update = update; - this.delete = delete; - this.get = get; - this.list = list; - this.repoStats = repoStats; - } - - /** Paginated, optionally sorted + flat-filtered listing with {@code meta.page}. */ - @GetMapping("/worklogs") - public ResponseEntity>> list( - @RequestParam(required = false) Integer page, - @RequestParam(required = false) Integer size, - @RequestParam(required = false) String sort, - @RequestParam(required = false) WorkCategory category) { - - PageParams pageParams = PageParams.of(page, size); - WorkLogSortField sortField = null; - boolean ascending = true; - String appliedSort = null; - if (sort != null && !sort.isBlank()) { - SortParam parsed = SortParam.parse(sort); // rejects non-native syntax → 400 - sortField = WorkLogSortField.fromRequest(parsed.field()); - if (sortField == null) { - throw new PageValidationException( - "sort", - "SORT_FIELD_NOT_ALLOWED", - "sort field '" + parsed.field() + "' is not sortable"); - } - ascending = parsed.ascending(); - appliedSort = parsed.canonical(); - } - - WorkLogPage result = - list.handle( - new ListWorkLogsQuery( - pageParams.page(), pageParams.size(), sortField, ascending, category)); - List body = - result.items().stream().map(WorkLogWebMapper::toSummary).toList(); - - PageMeta pageMeta = pageParams.toPageMeta(result.total(), appliedSort); - ResponseMeta meta = ResponseMetaFactory.fromMdc().withPage(pageMeta); - - ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); - if (pageParams.isDeepOffset()) { // deep offset → recommend cursor - builder.header(ApiHeaders.DEPRECATION, "deep-offset-pagination"); - } - return builder.body(Envelope.ok(body, meta)); - } - - @GetMapping("/worklogs/repoStats") - public RepoStatsResponse repoStats(@RequestParam String repoUrl) { - return WorkLogWebMapper.toRepoStatsResponse(repoStats.handle(new GetRepoStatsQuery(repoUrl))); - } - - /** Emits an {@code ETag}; honours {@code If-None-Match} with a 304 (no body). */ - @GetMapping("/worklogs/{id}") - public ResponseEntity getOne( - @PathVariable String id, - @RequestHeader(value = ApiHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { - WorkLog workLog = get.handle(new GetWorkLogQuery(toId(id))); - String etag = etagOf(workLog); - if (ETags.matches(ifNoneMatch, etag)) { - return ResponseEntity.status(HttpStatus.NOT_MODIFIED).eTag(etag).build(); - } - return ResponseEntity.ok().eTag(etag).body(WorkLogWebMapper.toResponse(workLog)); - } - - /** - * {@code Idempotency-Key} is accepted on the POST surface but only tolerated (ignored) until the - * rate-limit/idempotency contract wires replay. See README. - */ - @PostMapping("/worklogs") - public ResponseEntity create( - @Valid @RequestBody CreateWorkLogRequest body, - @RequestHeader(value = ApiHeaders.IDEMPOTENCY_KEY, required = false) String idempotencyKey) { - WorkLog created = create.handle(WorkLogWebMapper.toCommand(body, currentOwnerSubject())); - return ResponseEntity.ok().eTag(etagOf(created)).body(WorkLogWebMapper.toResponse(created)); - } - - /** - * When {@code If-Match} is supplied it must match the current ETag, else 412 (optimistic - * concurrency); an absent {@code If-Match} is allowed. See README. - */ - @PatchMapping("/worklogs/{id}") - public ResponseEntity patch( - @PathVariable String id, - @RequestHeader(value = ApiHeaders.IF_MATCH, required = false) String ifMatch, - @Valid @RequestBody UpdateWorkLogRequest body) { - WorkLogId workLogId = toId(id); - requireIfMatch(ifMatch, workLogId); - WorkLog updated = update.handle(WorkLogWebMapper.toUpdateCommand(workLogId, body)); - return ResponseEntity.ok().eTag(etagOf(updated)).body(WorkLogWebMapper.toResponse(updated)); - } - - @DeleteMapping("/worklogs/{id}") - public ResponseEntity delete(@PathVariable String id) { - delete.handle(new DeleteWorkLogCommand(toId(id))); - return ResponseEntity.noContent().build(); - } - - /** - * AIP-136 colon-verb batch create. Sync = atomic: {@link BatchCreateWorkLogsUseCase} runs every - * item in one transaction, so any failure rolls back the whole batch (single 4xx, never partial - * success). See README. - */ - @PostMapping("/worklogs:batchCreate") - public List batchCreate( - @Valid @RequestBody BatchCreateRequest request, - @RequestHeader(value = ApiHeaders.IDEMPOTENCY_KEY, required = false) String idempotencyKey) { - String owner = currentOwnerSubject(); - List created = - batchCreate.handle( - new BatchCreateWorkLogsCommand( - request.requests().stream() - .map(r -> WorkLogWebMapper.toCommand(r, owner)) - .toList())); - return created.stream().map(WorkLogWebMapper::toResponse).toList(); - } - - /** Batch size cap: a single sync batch may not exceed this many items (else 400). */ - public static final int MAX_BATCH_SIZE = 1000; - - /** AIP-233 batch request envelope: {@code { "requests": [ ... ] }}. */ - public record BatchCreateRequest( - @Valid - @Size(max = MAX_BATCH_SIZE, message = "batch may not exceed " + MAX_BATCH_SIZE + " items") - List requests) {} - - private static String etagOf(WorkLog workLog) { - long version = workLog.version() == null ? 0L : workLog.version(); - return ETags.weakFromVersion(version); - } - - /** - * Optimistic-concurrency precondition. {@code ETag}/{@code If-Match} is an HTTP transport - * concern, so the read-then-compare stays in the web adapter; an absent/blank {@code If-Match} - * skips the check. See README. - */ - private void requireIfMatch(String ifMatch, WorkLogId id) { - if (ifMatch == null || ifMatch.isBlank()) { - return; - } - WorkLog current = get.handle(new GetWorkLogQuery(id)); - if (!ETags.matches(ifMatch, etagOf(current))) { - throw new PreconditionFailedException("If-Match does not match the current resource version"); - } - } - - /** - * The owner stamped on a created work log is the authenticated caller's IdP subject, read - * null-safely from the security context. This is the raw principal id (no pseudonymization here). - * See README. - */ - private static String currentOwnerSubject() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth != null - && auth.isAuthenticated() - && auth.getPrincipal() instanceof AuthenticatedPrincipal user) { - return user.idpUserId(); - } - return null; - } - - /** - * Accepts case-insensitive canonical UUID path input and normalises it to the canonical 36-char - * lowercase UUID. {@code UUID.fromString(...)} is a parser (not a generator) and throws {@link - * IllegalArgumentException} on a malformed id → HTTP 400. See README. - */ - private static WorkLogId toId(String raw) { - return WorkLogId.of(UUID.fromString(raw.toLowerCase(Locale.ROOT)).toString()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreatePosterRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreatePosterRequest.java deleted file mode 100644 index e28a03f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreatePosterRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -/** Create a new poster (starts in DRAFT with no image). Bean Validation at the edge. */ -public record CreatePosterRequest( - @NotBlank(message = "title is required") - @Size(max = 200, message = "title must be at most 200 characters") - String title, - @Size(max = 2000, message = "caption must be at most 2000 characters") String caption) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreateWorkLogRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreateWorkLogRequest.java deleted file mode 100644 index d2c5f1c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/CreateWorkLogRequest.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; - -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.WorkLogValidationGroups.Invariant; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.WorkLogValidationGroups.Syntax; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import jakarta.validation.GroupSequence; -import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import java.time.LocalDate; -import java.util.List; - -@GroupSequence({Syntax.class, Invariant.class, CreateWorkLogRequest.class}) -public record CreateWorkLogRequest( - @NotBlank(groups = Syntax.class) @Size(max = 200, groups = Syntax.class) String title, - @NotNull(groups = Syntax.class) WorkCategory category, - String summary, - String content, - List techStack, - List links, - @NotNull(groups = Syntax.class) LocalDate periodStart, - LocalDate periodEnd) { - - @AssertTrue(message = "periodEnd must not be before periodStart", groups = Invariant.class) - public boolean isPeriodOrdered() { - if (periodStart == null || periodEnd == null) { - return true; - } - return !periodEnd.isBefore(periodStart); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequest.java deleted file mode 100644 index 4a369f9..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; - -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; - -/** - * Reference for safe polymorphic deserialization. {@code @JsonTypeInfo(use = NAME)} plus an - * explicit {@code @JsonSubTypes} allowlist replaces Jackson's unsafe default-typing entry point - * (CVE-2019-14379); the {@code sealed} hierarchy keeps the allowlist mechanically aligned. See - * README. - */ -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind") -@JsonSubTypes({ - @JsonSubTypes.Type(value = SamplePolymorphicRequest.Text.class, name = "text"), - @JsonSubTypes.Type(value = SamplePolymorphicRequest.Image.class, name = "image") -}) -public sealed interface SamplePolymorphicRequest - permits SamplePolymorphicRequest.Text, SamplePolymorphicRequest.Image { - - record Text(String title, String body) implements SamplePolymorphicRequest {} - - record Image(String title, String imageUrl) implements SamplePolymorphicRequest {} -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdatePosterRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdatePosterRequest.java deleted file mode 100644 index 770d01b..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdatePosterRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; - -import dev.caskeleton.shared.request.Patch; -import org.openapitools.jackson.nullable.JsonNullable; - -/** - * Partial update of a poster. {@code JsonNullable} captures the JSON merge-patch 3-state (field - * absent / explicit null / value) at the web edge, converted to {@link Patch} for the command. - * Mirrors {@code UpdateWorkLogRequest}. - */ -public record UpdatePosterRequest(JsonNullable title, JsonNullable caption) { - - public Patch titlePatch() { - return toPatch(title); - } - - public Patch captionPatch() { - return toPatch(caption); - } - - private static Patch toPatch(JsonNullable field) { - if (field == null || !field.isPresent()) { - return Patch.absent(); - } - return field.get() == null ? Patch.ofNull() : Patch.of(field.get()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdateWorkLogRequest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdateWorkLogRequest.java deleted file mode 100644 index eed5904..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/UpdateWorkLogRequest.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; -import dev.caskeleton.shared.request.Patch; -import org.openapitools.jackson.nullable.JsonNullable; - -public record UpdateWorkLogRequest( - JsonNullable title, - JsonNullable summary, - JsonNullable content, - JsonNullable category, - JsonNullable status) { - public Patch titlePatch() { - return toPatch(title); - } - - public Patch summaryPatch() { - return toPatch(summary); - } - - public Patch contentPatch() { - return toPatch(content); - } - - public Patch categoryPatch() { - return toPatch(category); - } - - public Patch statusPatch() { - return toPatch(status); - } - - private static Patch toPatch(JsonNullable field) { - if (field == null || !field.isPresent()) { - return Patch.absent(); - } - return field.get() == null ? Patch.ofNull() : Patch.of(field.get()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/WorkLogValidationGroups.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/WorkLogValidationGroups.java deleted file mode 100644 index 0a380ed..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/WorkLogValidationGroups.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; - -/** Validation group ordering for @GroupSequence (syntax → invariant). */ -public final class WorkLogValidationGroups { - private WorkLogValidationGroups() {} - - public interface Syntax {} - - public interface Invariant {} -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationResponse.java deleted file mode 100644 index 7f0a034..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; - -/** Stable accepted response containing only an opaque operation and relative status path. */ -public record PosterImagePublicationResponse(String operationId, String statusPath) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationStatusResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationStatusResponse.java deleted file mode 100644 index 628d828..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterImagePublicationStatusResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; - -/** Locator-free publication progress/result. Reference appears only for READY. */ -public record PosterImagePublicationStatusResponse( - String operationId, - String state, - String reference, - long exactLength, - String mediaType, - String sha256) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterResponse.java deleted file mode 100644 index 89d980b..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/PosterResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; - -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; - -/** A poster with its current state. {@code imageKey} is null until an image is uploaded. */ -public record PosterResponse( - PosterId id, String title, String caption, String imageKey, PosterStatus status) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/RepoStatsResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/RepoStatsResponse.java deleted file mode 100644 index 40b795f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/RepoStatsResponse.java +++ /dev/null @@ -1,3 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; - -public record RepoStatsResponse(String fullName, int stars, String lastPushedIso) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/StoredObjectResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/StoredObjectResponse.java deleted file mode 100644 index 97868cd..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/StoredObjectResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; - -/** Receipt for an uploaded poster image stored in object storage. */ -public record StoredObjectResponse(String key, long size, String contentType, String location) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogResponse.java deleted file mode 100644 index d3ad24b..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; -import java.time.LocalDate; -import java.util.List; - -public record WorkLogResponse( - WorkLogId id, - String title, - WorkCategory category, - WorkLogStatus status, - String summary, - String content, - List techStack, - List links, - LocalDate periodStart, - LocalDate periodEnd) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogSummaryResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogSummaryResponse.java deleted file mode 100644 index 7f9104a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/WorkLogSummaryResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; -import java.time.LocalDate; - -public record WorkLogSummaryResponse( - WorkLogId id, - String title, - WorkCategory category, - WorkLogStatus status, - LocalDate periodStart, - LocalDate periodEnd) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java deleted file mode 100644 index 8d2ba0f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandler.java +++ /dev/null @@ -1,70 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; - -import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.application.exception.PosterTitleAlreadyExistsException; -import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; -import dev.caskeleton.sample.portfolio.domain.poster.PosterInvariantException; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; -import dev.caskeleton.shared.response.Envelope; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -/** - * Domain-specific exception → {@link Envelope} mapping for the portfolio sample. Operational/ - * transport/security exceptions are handled by the base {@code GlobalExceptionHandler}. This advice - * is ordered ahead of the base so domain exceptions resolve to their specific code instead of the - * base catch-all's INTERNAL_ERROR. See README. - */ -@Order(Ordered.HIGHEST_PRECEDENCE) -@RestControllerAdvice -public class DomainExceptionHandler { - - @ExceptionHandler(WorkLogNotFoundException.class) - public ResponseEntity> handleWorkLogNotFound(WorkLogNotFoundException ex) { - return ErrorResponseFactory.envelope( - PortfolioErrorCode.WORKLOG_NOT_FOUND, - PortfolioClientSafeErrorMessages.forCode(PortfolioErrorCode.WORKLOG_NOT_FOUND), - null); - } - - @ExceptionHandler(WorkLogInvariantException.class) - public ResponseEntity> handleWorkLogInvariant(WorkLogInvariantException ex) { - return ErrorResponseFactory.envelope( - PortfolioErrorCode.WORKLOG_CONFLICT, - PortfolioClientSafeErrorMessages.forCode(PortfolioErrorCode.WORKLOG_CONFLICT), - null); - } - - @ExceptionHandler(PosterNotFoundException.class) - public ResponseEntity> handlePosterNotFound(PosterNotFoundException ex) { - return ErrorResponseFactory.envelope( - PortfolioErrorCode.POSTER_NOT_FOUND, - PortfolioClientSafeErrorMessages.forCode(PortfolioErrorCode.POSTER_NOT_FOUND), - null); - } - - @ExceptionHandler(PosterTitleAlreadyExistsException.class) - public ResponseEntity> handlePosterTitleConflict( - PosterTitleAlreadyExistsException ex) { - return ErrorResponseFactory.envelope( - PortfolioErrorCode.POSTER_ALREADY_EXISTS, - PortfolioClientSafeErrorMessages.forCode(PortfolioErrorCode.POSTER_ALREADY_EXISTS), - null); - } - - /** Maps the invariant's {@code reason()} to a specific code (finer-grained than WorkLog's). */ - @ExceptionHandler(PosterInvariantException.class) - public ResponseEntity> handlePosterInvariant(PosterInvariantException ex) { - PortfolioErrorCode code = - switch (ex.reason()) { - case IMAGE_REQUIRED -> PortfolioErrorCode.POSTER_IMAGE_REQUIRED; - case INVALID_STATUS_TRANSITION, TITLE_BLANK -> PortfolioErrorCode.POSTER_INVALID_STATE; - }; - return ErrorResponseFactory.envelope( - code, PortfolioClientSafeErrorMessages.forCode(code), null); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioClientSafeErrorMessages.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioClientSafeErrorMessages.java deleted file mode 100644 index 86c8aff..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioClientSafeErrorMessages.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; - -final class PortfolioClientSafeErrorMessages { - - private PortfolioClientSafeErrorMessages() {} - - static String forCode(PortfolioErrorCode code) { - return switch (code) { - case WORKLOG_NOT_FOUND -> "Work log was not found"; - case WORKLOG_CONFLICT -> "Work log conflicts with the current state"; - case POSTER_NOT_FOUND -> "Poster was not found"; - case POSTER_ALREADY_EXISTS -> "Poster title is already in use"; - case POSTER_INVALID_STATE -> "Poster is not valid in the current state"; - case POSTER_IMAGE_REQUIRED -> "Poster image is required"; - }; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCode.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCode.java deleted file mode 100644 index eb0feb6..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCode.java +++ /dev/null @@ -1,47 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; - -import dev.caskeleton.shared.error.ApiErrorCode; -import dev.caskeleton.shared.error.Category; - -/** - * Domain-specific error codes for the portfolio sample. Operational/transport/ security codes live - * in {@code dev.caskeleton.shared.error.OperationalError}. - */ -public enum PortfolioErrorCode implements ApiErrorCode { - WORKLOG_NOT_FOUND(Category.NOT_FOUND, 404, false), - WORKLOG_CONFLICT(Category.CONFLICT, 409, false), - POSTER_NOT_FOUND(Category.NOT_FOUND, 404, false), - POSTER_ALREADY_EXISTS(Category.CONFLICT, 409, false), - POSTER_INVALID_STATE(Category.VALIDATION, 400, false), - POSTER_IMAGE_REQUIRED(Category.VALIDATION, 400, false); - - private final Category category; - private final int httpStatus; - private final boolean retryable; - - PortfolioErrorCode(Category category, int httpStatus, boolean retryable) { - this.category = category; - this.httpStatus = httpStatus; - this.retryable = retryable; - } - - @Override - public String code() { - return name(); - } - - @Override - public Category category() { - return category; - } - - @Override - public int httpStatus() { - return httpStatus; - } - - @Override - public boolean retryable() { - return retryable; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/PosterIdSerializer.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/PosterIdSerializer.java deleted file mode 100644 index a955f17..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/PosterIdSerializer.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.json; - -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import org.springframework.boot.jackson.JacksonComponent; -import tools.jackson.core.JacksonException; -import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.SerializationContext; -import tools.jackson.databind.ValueSerializer; - -/** - * Serialises a {@link PosterId} as its bare 36-character lowercase UUID string instead of the - * record's default {@code {"value":"..."}} shape. Registered via {@link JacksonComponent}. Mirrors - * {@code WorkLogIdSerializer}. - */ -@JacksonComponent -public class PosterIdSerializer extends ValueSerializer { - - @Override - public void serialize(PosterId id, JsonGenerator gen, SerializationContext serializers) - throws JacksonException { - gen.writeString(id.value()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializer.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializer.java deleted file mode 100644 index 8ac4c4d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializer.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.json; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import org.springframework.boot.jackson.JacksonComponent; -import tools.jackson.core.JacksonException; -import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.SerializationContext; -import tools.jackson.databind.ValueSerializer; - -/** - * Serialises a {@link WorkLogId} as its bare 36-character lowercase UUID string instead of the - * record's default {@code {"value":"..."}} shape. Registered via {@link JacksonComponent}. See - * README. - */ -@JacksonComponent -public class WorkLogIdSerializer extends ValueSerializer { - - @Override - public void serialize(WorkLogId id, JsonGenerator gen, SerializationContext serializers) - throws JacksonException { - gen.writeString(id.value()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java deleted file mode 100644 index 5b6b501..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterImagePublicationWebMapper.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper; - -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationStatusResponse; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationReservation; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; - -public final class PosterImagePublicationWebMapper { - - private PosterImagePublicationWebMapper() {} - - public static PosterImagePublicationResponse toResponse( - PosterImagePublicationReservation reservation) { - return new PosterImagePublicationResponse( - reservation.operationId().value(), - statusPath(reservation.posterId().value(), reservation.operationId().value())); - } - - public static PosterImagePublicationStatusResponse toResponse( - PosterImagePublicationStatus status) { - return new PosterImagePublicationStatusResponse( - status.operationId().value(), - status.state().name(), - status.reference() == null ? null : status.reference().value(), - status.contentIdentity().exactLength(), - status.mediaType().canonicalText(), - status.contentIdentity().fullDigest().base64Value()); - } - - public static String statusPath(String posterId, String operationId) { - return "/posters/" + posterId + "/imagePublications/" + operationId; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java deleted file mode 100644 index c133f3a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/PosterWebMapper.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper; - -import dev.caskeleton.application.storage.StoredObject; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreatePosterRequest; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdatePosterRequest; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.StoredObjectResponse; -import dev.caskeleton.sample.portfolio.application.command.CreatePosterCommand; -import dev.caskeleton.sample.portfolio.application.command.UpdatePosterCommand; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; - -/** Translates between poster REST DTOs and application/domain types. */ -public final class PosterWebMapper { - - private PosterWebMapper() {} - - public static CreatePosterCommand toCommand(CreatePosterRequest r) { - return new CreatePosterCommand(r.title(), r.caption()); - } - - // PATCH goes through the mapper too (not built inline in the controller). The PosterId is parsed - // at the web edge and passed in. Mirrors WorkLogWebMapper. - public static UpdatePosterCommand toUpdateCommand(PosterId id, UpdatePosterRequest body) { - return new UpdatePosterCommand(id, body.titlePatch(), body.captionPatch()); - } - - public static PosterResponse toResponse(Poster p) { - return new PosterResponse(p.id(), p.title(), p.caption(), p.imageKey(), p.status()); - } - - // This exact response mapper remains active until data/API migration is complete. - @SuppressWarnings("removal") - public static StoredObjectResponse toStoredObjectResponse(StoredObject s) { - return new StoredObjectResponse(s.key(), s.size(), s.contentType(), s.location().toString()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/WorkLogWebMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/WorkLogWebMapper.java deleted file mode 100644 index f9083db..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/mapper/WorkLogWebMapper.java +++ /dev/null @@ -1,79 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.mapper; - -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.CreateWorkLogRequest; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.UpdateWorkLogRequest; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.RepoStatsResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.WorkLogResponse; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.WorkLogSummaryResponse; -import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.command.UpdateWorkLogCommand; -import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.shared.error.MappingException; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.List; - -public final class WorkLogWebMapper { - - private WorkLogWebMapper() {} - - public static CreateWorkLogCommand toCommand(CreateWorkLogRequest r, String owner) { - List links = r.links() == null ? List.of() : r.links(); - for (String link : links) { - try { - new URI(link); - } catch (URISyntaxException e) { - throw new MappingException("invalid link URI: " + link, e); - } - } - return new CreateWorkLogCommand( - owner, - r.title(), - r.category(), - r.summary(), - r.content(), - r.techStack() == null ? List.of() : r.techStack(), - links, - r.periodStart(), - r.periodEnd()); - } - - // PATCH goes through the mapper too (not built inline in the controller) so the web - // adapter owns all request DTO -> command translation. The WorkLogId is parsed at the - // web edge and passed in. See README. - public static UpdateWorkLogCommand toUpdateCommand(WorkLogId id, UpdateWorkLogRequest body) { - return new UpdateWorkLogCommand( - id, - body.titlePatch(), - body.summaryPatch(), - body.contentPatch(), - body.categoryPatch(), - body.statusPatch()); - } - - public static WorkLogResponse toResponse(WorkLog w) { - // owner (raw principal id) is intentionally NOT exposed in the response (privacy). See README. - return new WorkLogResponse( - w.id(), - w.title(), - w.category(), - w.status(), - w.summary(), - w.content(), - w.techStack(), - w.links(), - w.period().start(), - w.period().end()); - } - - public static WorkLogSummaryResponse toSummary(WorkLog w) { - return new WorkLogSummaryResponse( - w.id(), w.title(), w.category(), w.status(), w.period().start(), w.period().end()); - } - - public static RepoStatsResponse toRepoStatsResponse(RepoStats s) { - return new RepoStatsResponse(s.fullName(), s.stars(), s.lastPushedIso()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java deleted file mode 100644 index 85936e1..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducer.java +++ /dev/null @@ -1,65 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.objectstorage; - -import dev.caskeleton.application.objectstorage.content.ObjectChunkSink; -import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; -import dev.caskeleton.application.objectstorage.content.ObjectContentProductionContext; -import dev.caskeleton.application.objectstorage.content.ObjectContentProductionException; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import java.io.IOException; -import java.io.InputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.concurrent.atomic.AtomicBoolean; -import org.springframework.web.multipart.MultipartFile; - -/** Single-use request-bound stream bridge; it never calls {@code MultipartFile.getBytes()}. */ -public final class MultipartObjectContentProducer implements ObjectContentProducer { - - private final MultipartFile file; - private final ObjectContentIdentity expected; - private final AtomicBoolean consumed = new AtomicBoolean(); - - public MultipartObjectContentProducer(MultipartFile file, ObjectContentIdentity expected) { - if (file == null || expected == null) { - throw new IllegalArgumentException("multipart producer inputs are required"); - } - this.file = file; - this.expected = expected; - } - - @Override - public void produce(ObjectContentProductionContext context, ObjectChunkSink sink) - throws ObjectContentProductionException { - if (!consumed.compareAndSet(false, true)) { - throw new ObjectContentProductionException("multipart request content is already consumed"); - } - try (InputStream input = file.getInputStream()) { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] buffer = new byte[Math.min(context.maximumChunkBytes(), 64 * 1024)]; - long delivered = 0; - int read; - while ((read = input.read(buffer)) >= 0) { - if (read == 0) { - continue; - } - delivered += read; - if (delivered > expected.exactLength()) { - throw new ObjectContentProductionException( - "multipart content exceeds its declared length"); - } - digest.update(buffer, 0, read); - sink.write(buffer, 0, read); - } - if (delivered != expected.exactLength() - || !MessageDigest.isEqual(digest.digest(), expected.fullDigest().decodedValue())) { - throw new ObjectContentProductionException( - "multipart content identity does not match its declaration"); - } - } catch (IOException exception) { - throw new ObjectContentProductionException( - "multipart request stream could not be read", exception); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 must be available", exception); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/SampleOperationStore.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/SampleOperationStore.java deleted file mode 100644 index eb7af81..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/SampleOperationStore.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.operation; - -import com.github.f4b6a3.uuid.UuidCreator; -import dev.caskeleton.shared.operation.Operation; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import org.springframework.stereotype.Component; - -/** - * In-memory long-running-operation store backing the sample {@code :export} fixture. It mints the - * operation id here (web infrastructure), not in the controller, to respect the {@code - * no_uuid_random_in_controller} rule. The export completes synchronously, so the stored operation - * is already {@code SUCCEEDED} when polled. See README. - */ -@Component -public class SampleOperationStore { - - private final Map> store = new ConcurrentHashMap<>(); - - /** - * Accepts an export, persisting a terminal operation, and returns the accept-time (PENDING) view - * used for the 202 body. {@code apiBasePath} is the configured version prefix so {@code - * statusUrl} matches the live {@code /operations/{id}} route. - */ - public Operation startExport(String apiBasePath, long exportedCount) { - String id = UuidCreator.getTimeOrderedEpochPlus1().toString(); - String statusUrl = apiBasePath + "/operations/" + id; - store.put(id, Operation.succeeded(id, statusUrl, new WorkLogExportResult(exportedCount))); - return Operation.pending(id, statusUrl); - } - - public Optional> find(String operationId) { - return Optional.ofNullable(store.get(operationId)); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/WorkLogExportResult.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/WorkLogExportResult.java deleted file mode 100644 index be28282..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/operation/WorkLogExportResult.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.operation; - -/** - * Result payload of the sample long-running {@code :export} operation, surfaced as {@code - * data.result} on the polling response once the operation reaches {@code SUCCEEDED}. - */ -public record WorkLogExportResult(long exportedCount) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java deleted file mode 100644 index 8d4c35c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigester.java +++ /dev/null @@ -1,128 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; - -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigesterPort; -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.Supplier; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -/** HMAC-SHA-256 length-prefixed scope digester with a versioned retained key ring. */ -public final class HmacPosterImageIdempotencyScopeDigester - implements PosterImageIdempotencyScopeDigesterPort { - - private static final byte[] DOMAIN = - "poster-image-idempotency-scope-v1".getBytes(StandardCharsets.UTF_8); - - private final Supplier activeVersion; - private final Map keys; - - public HmacPosterImageIdempotencyScopeDigester(String activeVersion, Map keys) { - this(() -> activeVersion, keys); - } - - public HmacPosterImageIdempotencyScopeDigester( - Supplier activeVersion, Map keys) { - this.activeVersion = Objects.requireNonNull(activeVersion, "activeVersion must be non-null"); - Objects.requireNonNull(keys, "keys must be non-null"); - Map copied = new LinkedHashMap<>(); - for (Map.Entry entry : keys.entrySet()) { - String version = requireVersion(entry.getKey()); - byte[] key = - Objects.requireNonNull(entry.getValue(), "key material must be non-null").clone(); - if (key.length < 32 || key.length > 128) { - throw new IllegalArgumentException("idempotency HMAC key length is invalid"); - } - copied.put(version, key); - } - if (!copied.containsKey(requireVersion(this.activeVersion.get()))) { - throw new IllegalArgumentException("active idempotency HMAC key is absent"); - } - this.keys = Map.copyOf(copied); - } - - @Override - public PosterImageIdempotencyScopeDigest digest( - String tenant, String principal, String useCase, String idempotencyKey) { - byte[] canonical = - canonical( - tenant, - bounded(principal, "principal", 256), - bounded(useCase, "useCase", 128), - bounded(idempotencyKey, "idempotencyKey", 256)); - String selected = requireVersion(activeVersion.get()); - return digest(selected, canonical); - } - - @Override - public List retainedDigests( - String tenant, String principal, String useCase, String idempotencyKey) { - byte[] canonical = - canonical( - tenant, - bounded(principal, "principal", 256), - bounded(useCase, "useCase", 128), - bounded(idempotencyKey, "idempotencyKey", 256)); - return keys.keySet().stream().sorted().map(version -> digest(version, canonical)).toList(); - } - - private static byte[] canonical( - String tenant, String principal, String useCase, String idempotencyKey) { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - field(output, DOMAIN); - field(output, new byte[] {(byte) (tenant == null ? 0 : 1)}); - field( - output, - tenant == null - ? new byte[0] - : bounded(tenant, "tenant", 256).getBytes(StandardCharsets.UTF_8)); - field(output, principal.getBytes(StandardCharsets.UTF_8)); - field(output, useCase.getBytes(StandardCharsets.UTF_8)); - field(output, idempotencyKey.getBytes(StandardCharsets.UTF_8)); - return output.toByteArray(); - } - - private static void field(ByteArrayOutputStream output, byte[] value) { - output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array()); - output.writeBytes(value); - } - - private static String bounded(String value, String label, int maximum) { - if (value == null || value.isBlank() || value.length() > maximum) { - throw new IllegalArgumentException(label + " is invalid"); - } - return value; - } - - private static String requireVersion(String value) { - if (value == null || !value.matches("[a-z0-9][a-z0-9._-]{0,63}")) { - throw new IllegalArgumentException("idempotency key version is invalid"); - } - return value; - } - - private PosterImageIdempotencyScopeDigest digest(String version, byte[] canonical) { - byte[] key = keys.get(version); - if (key == null) { - throw new IllegalStateException( - "DB-active idempotency HMAC key is not retained by this process"); - } - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(key, "HmacSHA256")); - String encoded = - Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(canonical)); - return new PosterImageIdempotencyScopeDigest(version, encoded); - } catch (GeneralSecurityException exception) { - throw new IllegalStateException("HmacSHA256 must be available", exception); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidOutboxEventIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidOutboxEventIdFactory.java deleted file mode 100644 index 00728e7..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidOutboxEventIdFactory.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; - -import com.github.f4b6a3.uuid.UuidCreator; -import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; -import org.springframework.stereotype.Component; - -/** - * Infrastructure adapter for the {@link OutboxEventIdFactory} domain port. Uses the same {@code - * UuidCreator.getTimeOrderedEpochPlus1()} mechanism as {@link UuidWorkLogIdFactory}; id generation - * lives here, never in the application layer. See README. - */ -@Component -public class UuidOutboxEventIdFactory implements OutboxEventIdFactory { - - @Override - public String newEventId() { - return UuidCreator.getTimeOrderedEpochPlus1().toString(); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterIdFactory.java deleted file mode 100644 index 7d554df..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterIdFactory.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; - -import com.github.f4b6a3.uuid.UuidCreator; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterIdFactory; -import org.springframework.stereotype.Component; - -/** - * Infrastructure adapter for the {@link PosterIdFactory} domain port. {@code - * UuidCreator.getTimeOrderedEpochPlus1()} mints a monotonic UUIDv7 (RFC 9562, time-ordered): it - * increments strictly within a millisecond and is backed by a secure random source. Id generation - * is only allowed in this identifier adapter. Mirrors {@code UuidWorkLogIdFactory}. - */ -@Component -public class UuidPosterIdFactory implements PosterIdFactory { - - @Override - public PosterId newId() { - return PosterId.of(UuidCreator.getTimeOrderedEpochPlus1().toString()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java deleted file mode 100644 index 3b2871c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidPosterImageOperationIdFactory.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; - -import com.github.f4b6a3.uuid.UuidCreator; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; -import java.util.UUID; -import org.springframework.stereotype.Component; - -/** UUIDv7 allocator for the DB-committed Poster image operation identity. */ -@Component -public final class UuidPosterImageOperationIdFactory implements PosterImageOperationIdFactory { - - @Override - public AllocatedOperation allocate() { - UUID value = UuidCreator.getTimeOrderedEpochPlus1(); - return new AllocatedOperation(value, ObjectOperationId.of(value.toString())); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactory.java deleted file mode 100644 index 8403f8d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactory.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; - -import com.github.f4b6a3.uuid.UuidCreator; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; -import org.springframework.stereotype.Component; - -/** - * Infrastructure adapter for the {@link WorkLogIdFactory} domain port. {@code - * UuidCreator.getTimeOrderedEpochPlus1()} mints a monotonic UUIDv7 (RFC 9562, time-ordered): it - * increments strictly within a millisecond and is backed by a secure random source. This is the - * only place in the sample allowed to generate ids. See README. - */ -@Component -public class UuidWorkLogIdFactory implements WorkLogIdFactory { - - @Override - public WorkLogId newId() { - return WorkLogId.of(UuidCreator.getTimeOrderedEpochPlus1().toString()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/config/JpaConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/config/JpaConfig.java deleted file mode 100644 index 6eb855d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/config/JpaConfig.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config; - -import org.springframework.boot.persistence.autoconfigure.EntityScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; - -/** - * The Spring Boot main class lives in {@code dev.caskeleton.bootstrap}, so the default - * package-relative scan misses {@code - * dev.caskeleton.sample.portfolio.adapter.outbound.persistence.*}. Pointing entity and repository - * scans here keeps that wiring in the module that actually owns them. - */ -@Configuration -@EntityScan(basePackages = "dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity") -@EnableJpaRepositories( - basePackages = "dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository") -public class JpaConfig {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java deleted file mode 100644 index 1643068..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterEntity.java +++ /dev/null @@ -1,103 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; - -import dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.Version; -import java.util.UUID; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -// Extends AuditableEntity to inherit the created/updated audit columns; the domain Poster carries -// none of them — they are stamped by PosterRepositoryAdapter. Mirrors WorkLogEntity. -@Entity -@Table(name = "poster") -public class PosterEntity extends AuditableEntity { - - // UUIDv7 stored as the PostgreSQL native `uuid` type (16-byte binary), not varchar. - // UUID string<->uuid conversion happens in PosterPersistenceMapper. - @Id - @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) - @JdbcTypeCode(SqlTypes.UUID) - private UUID id; - - private String title; - - @Column(length = 2000) - private String caption; - - // Object-storage key of the uploaded image; null until an image is attached. - @Column(name = "image_key", length = 512) - private String imageKey; - - @Column(name = "image_reference", length = 96) - private String imageReference; - - @Enumerated(EnumType.STRING) - private PosterStatus status; - - // Optimistic-lock version (ETag source). Hibernate manages increments; `null` marks a new row so - // save() inserts. - @Version private Long version; - - protected PosterEntity() {} - - public PosterEntity( - UUID id, String title, String caption, String imageKey, PosterStatus status, Long version) { - this(id, title, caption, imageKey, null, status, version); - } - - public PosterEntity( - UUID id, - String title, - String caption, - String imageKey, - String imageReference, - PosterStatus status, - Long version) { - if (imageKey != null && imageReference != null) { - throw new IllegalArgumentException( - "poster entity cannot contain both image key and image reference"); - } - this.id = id; - this.title = title; - this.caption = caption; - this.imageKey = imageKey; - this.imageReference = imageReference; - this.status = status; - this.version = version; - } - - public UUID getId() { - return id; - } - - public String getTitle() { - return title; - } - - public String getCaption() { - return caption; - } - - public String getImageKey() { - return imageKey; - } - - public String getImageReference() { - return imageReference; - } - - public PosterStatus getStatus() { - return status; - } - - public Long getVersion() { - return version; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageIdempotencyKeyEpochEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageIdempotencyKeyEpochEntity.java deleted file mode 100644 index 9aec1a7..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageIdempotencyKeyEpochEntity.java +++ /dev/null @@ -1,58 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.Version; -import java.time.Instant; - -/** Single-row DB coordination fence for publication idempotency HMAC rotation. */ -@Entity -@Table(name = "poster_image_idempotency_key_epoch") -public class PosterImageIdempotencyKeyEpochEntity { - - @Id - @Column(name = "epoch_id", nullable = false, length = 64, updatable = false) - private String epochId; - - @Column(name = "active_key_version", nullable = false, length = 64) - private String activeKeyVersion; - - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @Version - @Column(nullable = false) - private long revision; - - protected PosterImageIdempotencyKeyEpochEntity() {} - - public PosterImageIdempotencyKeyEpochEntity( - String epochId, String activeKeyVersion, Instant updatedAt) { - this.epochId = epochId; - this.activeKeyVersion = activeKeyVersion; - this.updatedAt = updatedAt; - } - - public void activate(String activeKeyVersion, Instant now) { - this.activeKeyVersion = activeKeyVersion; - this.updatedAt = now; - } - - public String getEpochId() { - return epochId; - } - - public String getActiveKeyVersion() { - return activeKeyVersion; - } - - public Instant getUpdatedAt() { - return updatedAt; - } - - public long getRevision() { - return revision; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageRetirementIntentEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageRetirementIntentEntity.java deleted file mode 100644 index 25af5bb..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageRetirementIntentEntity.java +++ /dev/null @@ -1,198 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; - -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentState; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import jakarta.persistence.Version; -import java.time.Instant; -import java.util.UUID; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -/** JPA row for exact-reference logical retirement work. */ -@Entity -@Table( - name = "poster_image_retirement_intent", - uniqueConstraints = { - @UniqueConstraint( - name = "uq_poster_image_retirement_operation", - columnNames = "operation_id"), - @UniqueConstraint( - name = "uq_poster_image_retirement_exact", - columnNames = {"image_reference", "exact_version"}) - }) -public class PosterImageRetirementIntentEntity { - - @Id - @JdbcTypeCode(SqlTypes.UUID) - @Column(nullable = false, updatable = false) - private UUID id; - - @JdbcTypeCode(SqlTypes.UUID) - @Column(name = "poster_id", updatable = false) - private UUID posterId; - - @Column(name = "operation_id", nullable = false, length = 64, updatable = false) - private String operationId; - - @Column(name = "image_reference", nullable = false, length = 96, updatable = false) - private String imageReference; - - @Column(name = "exact_version", nullable = false, length = 96, updatable = false) - private String exactVersion; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private PosterImageRetirementIntentState state; - - @Column(name = "claimant_digest", length = 64) - private String claimantDigest; - - @Column(name = "claim_fence") - private Long claimFence; - - @Column(name = "claim_expires_at") - private Instant claimExpiresAt; - - @Column(name = "retain_until", nullable = false, updatable = false) - private Instant retainUntil; - - @Column(name = "retry_at") - private Instant retryAt; - - @Column(name = "attempt_count", nullable = false) - private int attemptCount; - - @Column(name = "last_error_code", length = 64) - private String lastErrorCode; - - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @Version - @Column(nullable = false) - private long revision; - - protected PosterImageRetirementIntentEntity() {} - - public PosterImageRetirementIntentEntity( - UUID id, - UUID posterId, - String operationId, - String imageReference, - String exactVersion, - PosterImageRetirementIntentState state, - String claimantDigest, - Long claimFence, - Instant claimExpiresAt, - Instant retainUntil, - Instant retryAt, - int attemptCount, - String lastErrorCode, - Instant createdAt, - Instant updatedAt, - long revision) { - this.id = id; - this.posterId = posterId; - this.operationId = operationId; - this.imageReference = imageReference; - this.exactVersion = exactVersion; - this.state = state; - this.claimantDigest = claimantDigest; - this.claimFence = claimFence; - this.claimExpiresAt = claimExpiresAt; - this.retainUntil = retainUntil; - this.retryAt = retryAt; - this.attemptCount = attemptCount; - this.lastErrorCode = lastErrorCode; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - this.revision = revision; - } - - public void applyMutableState(PosterImageRetirementIntentEntity source) { - if (!id.equals(source.id) || revision + 1 != source.revision) { - throw new IllegalArgumentException("retirement intent replacement revision conflicts"); - } - state = source.state; - claimantDigest = source.claimantDigest; - claimFence = source.claimFence; - claimExpiresAt = source.claimExpiresAt; - retryAt = source.retryAt; - attemptCount = source.attemptCount; - lastErrorCode = source.lastErrorCode; - updatedAt = source.updatedAt; - } - - public UUID getId() { - return id; - } - - public UUID getPosterId() { - return posterId; - } - - public String getOperationId() { - return operationId; - } - - public String getImageReference() { - return imageReference; - } - - public String getExactVersion() { - return exactVersion; - } - - public PosterImageRetirementIntentState getState() { - return state; - } - - public String getClaimantDigest() { - return claimantDigest; - } - - public Long getClaimFence() { - return claimFence; - } - - public Instant getClaimExpiresAt() { - return claimExpiresAt; - } - - public Instant getRetainUntil() { - return retainUntil; - } - - public Instant getRetryAt() { - return retryAt; - } - - public int getAttemptCount() { - return attemptCount; - } - - public String getLastErrorCode() { - return lastErrorCode; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public Instant getUpdatedAt() { - return updatedAt; - } - - public long getRevision() { - return revision; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageUploadIntentEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageUploadIntentEntity.java deleted file mode 100644 index bbb84b2..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/PosterImageUploadIntentEntity.java +++ /dev/null @@ -1,311 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; - -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import jakarta.persistence.Version; -import java.time.Instant; -import java.util.UUID; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -/** JPA representation of the V8 durable publication queue; no payload or provider locator. */ -@Entity -@Table( - name = "poster_image_upload_intent", - uniqueConstraints = { - @UniqueConstraint( - name = "uq_poster_image_upload_operation", - columnNames = {"operation_destination", "operation_epoch", "operation_id"}), - @UniqueConstraint( - name = "uq_poster_image_upload_scope", - columnNames = {"hmac_key_version", "idempotency_scope_digest"}) - }) -public class PosterImageUploadIntentEntity { - - @Id - @JdbcTypeCode(SqlTypes.UUID) - @Column(name = "id", nullable = false, updatable = false) - private UUID id; - - @JdbcTypeCode(SqlTypes.UUID) - @Column(name = "poster_id", nullable = false, updatable = false) - private UUID posterId; - - @Column(name = "operation_destination", nullable = false, length = 64, updatable = false) - private String operationDestination; - - @Column(name = "operation_epoch", nullable = false, length = 64, updatable = false) - private String operationEpoch; - - @Column(name = "operation_id", nullable = false, length = 64, updatable = false) - private String operationId; - - @Column(name = "hmac_key_version", nullable = false, length = 64, updatable = false) - private String hmacKeyVersion; - - @Column(name = "idempotency_scope_digest", nullable = false, length = 43, updatable = false) - private String idempotencyScopeDigest; - - @Column(name = "request_fingerprint", nullable = false, length = 64, updatable = false) - private String requestFingerprint; - - @Column(name = "expected_poster_version", nullable = false, updatable = false) - private long expectedPosterVersion; - - @Column(name = "content_length", nullable = false, updatable = false) - private long contentLength; - - @Column(name = "content_sha256", nullable = false, length = 44, updatable = false) - private String contentSha256; - - @Column(name = "media_type", nullable = false, length = 127, updatable = false) - private String mediaType; - - @Enumerated(EnumType.STRING) - @Column(name = "state", nullable = false, length = 32) - private PosterImageUploadIntentState state; - - @Column(name = "stage_handle", length = 96) - private String stageHandle; - - @Column(name = "exact_version", length = 96) - private String exactVersion; - - @Column(name = "handoff_claimant_digest", length = 64) - private String handoffClaimantDigest; - - @Column(name = "handoff_fence") - private Long handoffFence; - - @Column(name = "handoff_expires_at") - private Instant handoffExpiresAt; - - @Column(name = "published_reference", length = 96) - private String publishedReference; - - @Column(name = "published_version", length = 96) - private String publishedVersion; - - @Column(name = "replaced_reference", length = 96) - private String replacedReference; - - @Column(name = "retry_at") - private Instant retryAt; - - @Column(name = "expires_at", nullable = false, updatable = false) - private Instant expiresAt; - - @Column(name = "attempt_count", nullable = false) - private int attemptCount; - - @Column(name = "last_error_code", length = 64) - private String lastErrorCode; - - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @Column(name = "updated_at", nullable = false) - private Instant updatedAt; - - @Version - @Column(name = "revision", nullable = false) - private long revision; - - protected PosterImageUploadIntentEntity() {} - - public PosterImageUploadIntentEntity( - UUID id, - UUID posterId, - String operationDestination, - String operationEpoch, - String operationId, - String hmacKeyVersion, - String idempotencyScopeDigest, - String requestFingerprint, - long expectedPosterVersion, - long contentLength, - String contentSha256, - String mediaType, - PosterImageUploadIntentState state, - String stageHandle, - String exactVersion, - String handoffClaimantDigest, - Long handoffFence, - Instant handoffExpiresAt, - String publishedReference, - String publishedVersion, - String replacedReference, - Instant retryAt, - Instant expiresAt, - int attemptCount, - String lastErrorCode, - Instant createdAt, - Instant updatedAt, - long revision) { - this.id = id; - this.posterId = posterId; - this.operationDestination = operationDestination; - this.operationEpoch = operationEpoch; - this.operationId = operationId; - this.hmacKeyVersion = hmacKeyVersion; - this.idempotencyScopeDigest = idempotencyScopeDigest; - this.requestFingerprint = requestFingerprint; - this.expectedPosterVersion = expectedPosterVersion; - this.contentLength = contentLength; - this.contentSha256 = contentSha256; - this.mediaType = mediaType; - this.state = state; - this.stageHandle = stageHandle; - this.exactVersion = exactVersion; - this.handoffClaimantDigest = handoffClaimantDigest; - this.handoffFence = handoffFence; - this.handoffExpiresAt = handoffExpiresAt; - this.publishedReference = publishedReference; - this.publishedVersion = publishedVersion; - this.replacedReference = replacedReference; - this.retryAt = retryAt; - this.expiresAt = expiresAt; - this.attemptCount = attemptCount; - this.lastErrorCode = lastErrorCode; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - this.revision = revision; - } - - public void applyMutableState(PosterImageUploadIntentEntity source) { - if (!id.equals(source.id) || revision + 1 != source.revision) { - throw new IllegalArgumentException("upload intent replacement revision conflicts"); - } - state = source.state; - stageHandle = source.stageHandle; - exactVersion = source.exactVersion; - handoffClaimantDigest = source.handoffClaimantDigest; - handoffFence = source.handoffFence; - handoffExpiresAt = source.handoffExpiresAt; - publishedReference = source.publishedReference; - publishedVersion = source.publishedVersion; - replacedReference = source.replacedReference; - retryAt = source.retryAt; - attemptCount = source.attemptCount; - lastErrorCode = source.lastErrorCode; - updatedAt = source.updatedAt; - } - - public UUID getId() { - return id; - } - - public UUID getPosterId() { - return posterId; - } - - public String getOperationDestination() { - return operationDestination; - } - - public String getOperationEpoch() { - return operationEpoch; - } - - public String getOperationId() { - return operationId; - } - - public String getHmacKeyVersion() { - return hmacKeyVersion; - } - - public String getIdempotencyScopeDigest() { - return idempotencyScopeDigest; - } - - public String getRequestFingerprint() { - return requestFingerprint; - } - - public long getExpectedPosterVersion() { - return expectedPosterVersion; - } - - public long getContentLength() { - return contentLength; - } - - public String getContentSha256() { - return contentSha256; - } - - public String getMediaType() { - return mediaType; - } - - public PosterImageUploadIntentState getState() { - return state; - } - - public String getStageHandle() { - return stageHandle; - } - - public String getExactVersion() { - return exactVersion; - } - - public String getHandoffClaimantDigest() { - return handoffClaimantDigest; - } - - public Long getHandoffFence() { - return handoffFence; - } - - public Instant getHandoffExpiresAt() { - return handoffExpiresAt; - } - - public String getPublishedReference() { - return publishedReference; - } - - public String getPublishedVersion() { - return publishedVersion; - } - - public String getReplacedReference() { - return replacedReference; - } - - public Instant getRetryAt() { - return retryAt; - } - - public Instant getExpiresAt() { - return expiresAt; - } - - public int getAttemptCount() { - return attemptCount; - } - - public String getLastErrorCode() { - return lastErrorCode; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public Instant getUpdatedAt() { - return updatedAt; - } - - public long getRevision() { - return revision; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/WorkLogEntity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/WorkLogEntity.java deleted file mode 100644 index 68ff681..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/entity/WorkLogEntity.java +++ /dev/null @@ -1,170 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity; - -import dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; -import jakarta.persistence.CollectionTable; -import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.Table; -import jakarta.persistence.Version; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -// Extends AuditableEntity to inherit the created/updated audit columns; the domain WorkLog -// carries none of them — they are stamped by WorkLogRepositoryAdapter. See README. -@Entity -@Table(name = "work_log") -public class WorkLogEntity extends AuditableEntity { - - // UUIDv7 stored as the PostgreSQL native `uuid` type (16-byte binary), not varchar. - // UUID string<->uuid conversion happens in WorkLogPersistenceMapper. See README. - @Id - @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) - @JdbcTypeCode(SqlTypes.UUID) - private UUID id; - - private String owner; - private String title; - - @Enumerated(EnumType.STRING) - private WorkCategory category; - - @Enumerated(EnumType.STRING) - private WorkLogStatus status; - - @Column(length = 500) - private String summary; - - @Column(length = 10000) - private String content; - - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable(name = "work_log_tech_stack", joinColumns = @JoinColumn(name = "work_log_id")) - @Column(name = "tech") - private List techStack = new ArrayList<>(); - - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable(name = "work_log_link", joinColumns = @JoinColumn(name = "work_log_id")) - @Column(name = "url") - private List links = new ArrayList<>(); - - private LocalDate periodStart; - private LocalDate periodEnd; - // Optimistic-lock version (ETag/If-Match source). Hibernate manages increments; `null` - // marks a new row so save() inserts. See README. - @Version private Long version; - - protected WorkLogEntity() {} - - public WorkLogEntity( - UUID id, - String title, - WorkCategory category, - String summary, - String content, - List techStack, - List links, - LocalDate periodStart, - LocalDate periodEnd, - Long version) { - this( - id, - "sample-owner", - title, - category, - WorkLogStatus.OPEN, - summary, - content, - techStack, - links, - periodStart, - periodEnd, - version); - } - - public WorkLogEntity( - UUID id, - String owner, - String title, - WorkCategory category, - WorkLogStatus status, - String summary, - String content, - List techStack, - List links, - LocalDate periodStart, - LocalDate periodEnd, - Long version) { - this.id = id; - this.owner = owner; - this.title = title; - this.category = category; - this.status = status; - this.summary = summary; - this.content = content; - this.techStack = new ArrayList<>(techStack); - this.links = new ArrayList<>(links); - this.periodStart = periodStart; - this.periodEnd = periodEnd; - this.version = version; - } - - public UUID getId() { - return id; - } - - public String getOwner() { - return owner; - } - - public String getTitle() { - return title; - } - - public WorkCategory getCategory() { - return category; - } - - public WorkLogStatus getStatus() { - return status; - } - - public String getSummary() { - return summary; - } - - public String getContent() { - return content; - } - - public List getTechStack() { - return techStack; - } - - public List getLinks() { - return links; - } - - public LocalDate getPeriodStart() { - return periodStart; - } - - public LocalDate getPeriodEnd() { - return periodEnd; - } - - public Long getVersion() { - return version; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java deleted file mode 100644 index 5c39b4c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageRetirementIntentPersistenceMapper.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageRetirementIntentEntity; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; - -/** Pure V8 retirement-intent domain/JPA translation. */ -public final class PosterImageRetirementIntentPersistenceMapper { - - private PosterImageRetirementIntentPersistenceMapper() {} - - public static PosterImageRetirementIntentEntity toEntity(PosterImageRetirementIntent intent) { - return new PosterImageRetirementIntentEntity( - intent.id(), - intent.posterId() == null ? null : PosterPersistenceMapper.toUuid(intent.posterId()), - intent.operationId().value(), - intent.reference().value(), - intent.exactVersion().canonicalText(), - intent.state(), - intent.claimantDigest(), - intent.claimFence(), - intent.claimExpiresAt(), - intent.retainUntil(), - intent.retryAt(), - intent.attemptCount(), - intent.lastErrorCode(), - intent.createdAt(), - intent.updatedAt(), - intent.revision()); - } - - public static PosterImageRetirementIntent toDomain(PosterImageRetirementIntentEntity entity) { - return new PosterImageRetirementIntent( - entity.getId(), - entity.getPosterId() == null ? null : PosterId.of(entity.getPosterId().toString()), - ObjectOperationId.of(entity.getOperationId()), - PosterImageReference.of(entity.getImageReference()), - ObjectVersionToken.parse(entity.getExactVersion()), - entity.getState(), - entity.getClaimantDigest(), - entity.getClaimFence(), - entity.getClaimExpiresAt(), - entity.getRetainUntil(), - entity.getRetryAt(), - entity.getAttemptCount(), - entity.getLastErrorCode(), - entity.getCreatedAt(), - entity.getUpdatedAt(), - entity.getRevision()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java deleted file mode 100644 index bcd11da..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterImageUploadIntentPersistenceMapper.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; - -import dev.caskeleton.application.idempotency.RequestFingerprint; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectDigest; -import dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageUploadIntentEntity; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; - -/** Pure V8 upload-intent domain/JPA translation. */ -public final class PosterImageUploadIntentPersistenceMapper { - - private PosterImageUploadIntentPersistenceMapper() {} - - public static PosterImageUploadIntentEntity toEntity(PosterImageUploadIntent intent) { - return new PosterImageUploadIntentEntity( - intent.id(), - PosterPersistenceMapper.toUuid(intent.posterId()), - intent.operationKey().destination().value(), - intent.operationKey().epoch().value(), - intent.operationKey().operationId().value(), - intent.idempotencyScope().keyVersion(), - intent.idempotencyScope().base64UrlDigest(), - intent.requestFingerprint().hex(), - intent.expectedPosterVersion(), - intent.contentIdentity().exactLength(), - intent.contentIdentity().fullDigest().base64Value(), - intent.mediaType().canonicalText(), - intent.state(), - text(intent.stageHandle()), - text(intent.exactVersion()), - intent.handoffClaimantDigest(), - intent.handoffFence(), - intent.handoffExpiresAt(), - text(intent.publishedReference()), - text(intent.publishedVersion()), - text(intent.replacedReference()), - intent.retryAt(), - intent.expiresAt(), - intent.attemptCount(), - intent.lastErrorCode(), - intent.createdAt(), - intent.updatedAt(), - intent.revision()); - } - - public static PosterImageUploadIntent toDomain(PosterImageUploadIntentEntity entity) { - return new PosterImageUploadIntent( - entity.getId(), - PosterId.of(entity.getPosterId().toString()), - new ObjectOperationKey( - ObjectDestinationId.of(entity.getOperationDestination()), - ObjectOperationEpoch.of(entity.getOperationEpoch()), - ObjectOperationId.of(entity.getOperationId())), - new PosterImageIdempotencyScopeDigest( - entity.getHmacKeyVersion(), entity.getIdempotencyScopeDigest()), - new RequestFingerprint(entity.getRequestFingerprint()), - entity.getExpectedPosterVersion(), - new ObjectContentIdentity( - entity.getContentLength(), - ObjectDigest.of(ObjectDigestAlgorithm.SHA_256, entity.getContentSha256())), - ObjectMediaType.of(entity.getMediaType()), - entity.getState(), - entity.getStageHandle() == null ? null : ObjectStageHandle.parse(entity.getStageHandle()), - entity.getExactVersion() == null - ? null - : ObjectVersionToken.parse(entity.getExactVersion()), - entity.getHandoffClaimantDigest(), - entity.getHandoffFence(), - entity.getHandoffExpiresAt(), - entity.getPublishedReference() == null - ? null - : PosterImageReference.of(entity.getPublishedReference()), - entity.getPublishedVersion() == null - ? null - : ObjectVersionToken.parse(entity.getPublishedVersion()), - entity.getReplacedReference() == null - ? null - : PosterImageReference.of(entity.getReplacedReference()), - entity.getRetryAt(), - entity.getExpiresAt(), - entity.getAttemptCount(), - entity.getLastErrorCode(), - entity.getCreatedAt(), - entity.getUpdatedAt(), - entity.getRevision()); - } - - private static String text(ObjectStageHandle handle) { - return handle == null ? null : handle.canonicalText(); - } - - private static String text(ObjectVersionToken version) { - return version == null ? null : version.canonicalText(); - } - - private static String text(PosterImageReference reference) { - return reference == null ? null : reference.value(); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java deleted file mode 100644 index ab7110c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/PosterPersistenceMapper.java +++ /dev/null @@ -1,50 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; - -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterEntity; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.util.UUID; - -/** - * Pure translation between the {@link Poster} aggregate and its JPA entity. Mirrors {@code - * WorkLogPersistenceMapper}. - */ -public final class PosterPersistenceMapper { - - private PosterPersistenceMapper() {} - - /** - * Canonical UUID string -> PostgreSQL native {@code uuid} (128-bit). Uses {@code java.util.UUID} - * (JDK stdlib) directly, so the persistence adapter carries no id-library dependency. - */ - public static UUID toUuid(PosterId id) { - return UUID.fromString(id.value()); - } - - public static PosterEntity toEntity(Poster p) { - // Carry the domain version so JPA tells a new row (null → insert) from a tracked one. - return new PosterEntity( - toUuid(p.id()), - p.title(), - p.caption(), - p.imageAttachment() - instanceof dev.caskeleton.sample.portfolio.domain.poster.LegacyPosterImageAttachment - ? p.imageKey() - : null, - p.imageReference(), - p.status(), - p.version()); - } - - public static Poster toDomain(PosterEntity e) { - PosterId id = PosterId.of(e.getId().toString()); - return Poster.rehydrate( - id, - e.getTitle(), - e.getCaption(), - e.getImageKey(), - e.getImageReference(), - e.getStatus(), - e.getVersion()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapper.java deleted file mode 100644 index 43f9347..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapper.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; - -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.WorkLogEntity; -import dev.caskeleton.sample.portfolio.domain.worklog.Period; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogOwner; -import java.util.UUID; - -public final class WorkLogPersistenceMapper { - - private WorkLogPersistenceMapper() {} - - /** - * Canonical UUID string -> PostgreSQL native {@code uuid} (128-bit). Uses {@code java.util.UUID} - * (JDK stdlib) directly, so the persistence adapter carries no id-library dependency. See README. - */ - public static UUID toUuid(WorkLogId id) { - return UUID.fromString(id.value()); - } - - public static WorkLogEntity toEntity(WorkLog w) { - // Carry the domain version so JPA tells a new row (null → insert) from a tracked one. - return new WorkLogEntity( - toUuid(w.id()), - w.owner().value(), - w.title(), - w.category(), - w.status(), - w.summary(), - w.content(), - w.techStack(), - w.links(), - w.period().start(), - w.period().end(), - w.version()); - } - - public static WorkLog toDomain(WorkLogEntity e) { - WorkLogId id = WorkLogId.of(e.getId().toString()); - return WorkLog.rehydrate( - id, - WorkLogOwner.of(e.getOwner()), - e.getTitle(), - e.getCategory(), - e.getStatus(), - e.getSummary(), - e.getContent(), - e.getTechStack(), - e.getLinks(), - new Period(e.getPeriodStart(), e.getPeriodEnd()), - e.getVersion()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageAttachmentCasRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageAttachmentCasRepository.java deleted file mode 100644 index a80817d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageAttachmentCasRepository.java +++ /dev/null @@ -1,72 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageAttachmentCompareAndSetPort; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import jakarta.persistence.EntityManager; -import java.time.Instant; -import java.util.List; -import org.springframework.stereotype.Repository; - -/** PostgreSQL conditional V8 attachment update that returns the exact replaced reference. */ -@Repository -public class PosterImageAttachmentCasRepository implements PosterImageAttachmentCompareAndSetPort { - - private static final String ATTACH_SQL = - """ - WITH candidate AS ( - SELECT id, image_reference AS replaced_reference - FROM poster - WHERE id = :posterId - AND version = :expectedVersion - AND image_key IS NULL - FOR UPDATE - ), - updated AS ( - UPDATE poster AS poster - SET image_reference = :reference, - updated_at = :updatedAt, - updated_by = :updatedBy, - version = poster.version + 1 - FROM candidate - WHERE poster.id = candidate.id - RETURNING candidate.replaced_reference - ) - SELECT TRUE AS attached, replaced_reference - FROM updated - """; - - private final EntityManager entityManager; - private final AuditContextPort auditContext; - - public PosterImageAttachmentCasRepository( - EntityManager entityManager, AuditContextPort auditContext) { - this.entityManager = entityManager; - this.auditContext = auditContext; - } - - @Override - public Result attach( - PosterId posterId, long expectedPosterVersion, PosterImageReference reference, Instant now) { - java.util.Objects.requireNonNull(now, "now must be non-null"); - @SuppressWarnings("unchecked") - List rows = - entityManager - .createNativeQuery(ATTACH_SQL) - .setParameter( - "posterId", - dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper - .PosterPersistenceMapper.toUuid(posterId)) - .setParameter("expectedVersion", expectedPosterVersion) - .setParameter("reference", reference.value()) - .setParameter("updatedAt", now) - .setParameter("updatedBy", auditContext.currentActor()) - .getResultList(); - if (rows.isEmpty()) { - return new Result(false, null); - } - String replaced = (String) rows.getFirst()[1]; - return new Result(true, replaced == null ? null : PosterImageReference.of(replaced)); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochJpaRepository.java deleted file mode 100644 index f91f9f6..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochJpaRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageIdempotencyKeyEpochEntity; -import jakarta.persistence.LockModeType; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface PosterImageIdempotencyKeyEpochJpaRepository - extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query( - "select epoch from PosterImageIdempotencyKeyEpochEntity epoch" - + " where epoch.epochId = :epochId") - Optional findByIdForUpdate( - @Param("epochId") String epochId); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochRepositoryAdapter.java deleted file mode 100644 index f80bc62..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageIdempotencyKeyEpochRepositoryAdapter.java +++ /dev/null @@ -1,48 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageIdempotencyKeyEpochEntity; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyKeyEpochPort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentConflictException; -import java.time.Clock; -import org.springframework.stereotype.Repository; - -@Repository -public class PosterImageIdempotencyKeyEpochRepositoryAdapter - implements PosterImageIdempotencyKeyEpochPort { - - static final String EPOCH_ID = "poster-image-publication"; - - private final PosterImageIdempotencyKeyEpochJpaRepository jpa; - private final Clock clock; - - public PosterImageIdempotencyKeyEpochRepositoryAdapter( - PosterImageIdempotencyKeyEpochJpaRepository jpa, Clock clock) { - this.jpa = jpa; - this.clock = clock; - } - - @Override - public String activeVersionForReservation() { - return jpa.findByIdForUpdate(EPOCH_ID) - .orElseThrow( - () -> - new IllegalStateException("poster image idempotency key epoch is not initialized")) - .getActiveKeyVersion(); - } - - @Override - public void activate(String expectedVersion, String newVersion) { - PosterImageIdempotencyKeyEpochEntity epoch = - jpa.findByIdForUpdate(EPOCH_ID) - .orElseGet( - () -> - new PosterImageIdempotencyKeyEpochEntity( - EPOCH_ID, expectedVersion, clock.instant())); - if (!epoch.getActiveKeyVersion().equals(expectedVersion)) { - throw new PosterImageUploadIntentConflictException( - "poster image idempotency key epoch conflicts"); - } - epoch.activate(newVersion, clock.instant()); - jpa.saveAndFlush(epoch); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentJpaRepository.java deleted file mode 100644 index cf9e521..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentJpaRepository.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageRetirementIntentEntity; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentState; -import jakarta.persistence.LockModeType; -import java.time.Instant; -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface PosterImageRetirementIntentJpaRepository - extends JpaRepository { - - Optional findByOperationId(String operationId); - - Optional findByImageReferenceAndExactVersion( - String reference, String exactVersion); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query( - """ - select intent - from PosterImageRetirementIntentEntity intent - where intent.state in :states - and intent.retainUntil <= :now - and (intent.retryAt is null or intent.retryAt <= :now) - and (intent.claimExpiresAt is null or intent.claimExpiresAt <= :now) - order by intent.updatedAt, intent.id - """) - List findClaimable( - @Param("states") Collection states, - @Param("now") Instant now, - Pageable pageable); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentRepositoryAdapter.java deleted file mode 100644 index f77bebc..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageRetirementIntentRepositoryAdapter.java +++ /dev/null @@ -1,95 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageRetirementIntentEntity; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.PosterImageRetirementIntentPersistenceMapper; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentState; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentConflictException; -import java.time.Duration; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Repository; - -@Repository -public class PosterImageRetirementIntentRepositoryAdapter - implements PosterImageRetirementIntentStorePort { - - private final PosterImageRetirementIntentJpaRepository jpa; - - public PosterImageRetirementIntentRepositoryAdapter( - PosterImageRetirementIntentJpaRepository jpa) { - this.jpa = jpa; - } - - @Override - public PosterImageRetirementIntent createOrRead(PosterImageRetirementIntent proposed) { - Optional existing = - jpa.findByOperationId(proposed.operationId().value()); - if (existing.isEmpty()) { - existing = - jpa.findByImageReferenceAndExactVersion( - proposed.reference().value(), proposed.exactVersion().canonicalText()); - } - if (existing.isPresent()) { - PosterImageRetirementIntent found = - PosterImageRetirementIntentPersistenceMapper.toDomain(existing.orElseThrow()); - if (!found.operationId().equals(proposed.operationId()) - || !found.reference().equals(proposed.reference()) - || !found.exactVersion().equals(proposed.exactVersion())) { - throw new PosterImageUploadIntentConflictException( - "poster image retirement operation conflicts"); - } - return found; - } - return PosterImageRetirementIntentPersistenceMapper.toDomain( - jpa.saveAndFlush(PosterImageRetirementIntentPersistenceMapper.toEntity(proposed))); - } - - @Override - public Optional findByOperation(ObjectOperationId operation) { - return jpa.findByOperationId(operation.value()) - .map(PosterImageRetirementIntentPersistenceMapper::toDomain); - } - - @Override - public PosterImageRetirementIntent compareAndSet( - long expectedRevision, PosterImageRetirementIntent replacement) { - PosterImageRetirementIntentEntity current = - jpa.findById(replacement.id()) - .orElseThrow( - () -> - new PosterImageUploadIntentConflictException( - "poster image retirement intent is absent")); - if (current.getRevision() != expectedRevision - || replacement.revision() != expectedRevision + 1) { - throw new PosterImageUploadIntentConflictException( - "poster image retirement revision conflicts"); - } - current.applyMutableState(PosterImageRetirementIntentPersistenceMapper.toEntity(replacement)); - return PosterImageRetirementIntentPersistenceMapper.toDomain(jpa.saveAndFlush(current)); - } - - @Override - public Optional claimReady( - String claimantDigest, Instant now, Duration leaseDuration) { - return jpa - .findClaimable( - List.of( - PosterImageRetirementIntentState.PENDING, - PosterImageRetirementIntentState.IN_FLIGHT), - now, - PageRequest.of(0, 1)) - .stream() - .findFirst() - .map(PosterImageRetirementIntentPersistenceMapper::toDomain) - .map( - current -> - compareAndSet( - current.revision(), - current.claim(current.revision(), claimantDigest, now, leaseDuration))); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentJpaRepository.java deleted file mode 100644 index e7cb1dd..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentJpaRepository.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageUploadIntentEntity; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import jakarta.persistence.LockModeType; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface PosterImageUploadIntentJpaRepository - extends JpaRepository { - - Optional findByOperationDestinationAndOperationEpochAndOperationId( - String destination, String epoch, String operationId); - - Optional findByHmacKeyVersionAndIdempotencyScopeDigest( - String version, String digest); - - Optional findByPublishedReference(String reference); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query( - """ - select intent - from PosterImageUploadIntentEntity intent - where intent.state = :state - and (intent.retryAt is null or intent.retryAt <= :now) - and (intent.handoffExpiresAt is null or intent.handoffExpiresAt <= :now) - order by intent.updatedAt, intent.id - """) - List findClaimable( - @Param("state") PosterImageUploadIntentState state, - @Param("now") Instant now, - Pageable pageable); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryAdapter.java deleted file mode 100644 index ee87cc6..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryAdapter.java +++ /dev/null @@ -1,119 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterImageUploadIntentEntity; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.PosterImageUploadIntentPersistenceMapper; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentClaim; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentConflictException; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.time.Duration; -import java.time.Instant; -import java.util.Collection; -import java.util.Optional; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Repository; - -@Repository -public class PosterImageUploadIntentRepositoryAdapter implements PosterImageUploadIntentStorePort { - - private final PosterImageUploadIntentJpaRepository jpa; - - public PosterImageUploadIntentRepositoryAdapter(PosterImageUploadIntentJpaRepository jpa) { - this.jpa = jpa; - } - - @Override - public PosterImageUploadIntent createOrRead(PosterImageUploadIntent proposed) { - Optional existing = - findByAnyScopeDigest(java.util.List.of(proposed.idempotencyScope())); - if (existing.isEmpty()) { - existing = findByOperation(proposed.operationKey()); - } - if (existing.isPresent()) { - PosterImageUploadIntent found = existing.orElseThrow(); - if (!found.operationKey().equals(proposed.operationKey()) - || !found.requestFingerprint().equals(proposed.requestFingerprint()) - || !found.posterId().equals(proposed.posterId())) { - throw new PosterImageUploadIntentConflictException( - "poster image idempotency scope is already bound to different work"); - } - return found; - } - if (proposed.state() != PosterImageUploadIntentState.RESERVED || proposed.revision() != 0) { - throw new IllegalArgumentException("only a fresh RESERVED intent can be created"); - } - return PosterImageUploadIntentPersistenceMapper.toDomain( - jpa.saveAndFlush(PosterImageUploadIntentPersistenceMapper.toEntity(proposed))); - } - - @Override - public Optional findByOperation(ObjectOperationKey operation) { - return jpa.findByOperationDestinationAndOperationEpochAndOperationId( - operation.destination().value(), - operation.epoch().value(), - operation.operationId().value()) - .map(PosterImageUploadIntentPersistenceMapper::toDomain); - } - - @Override - public Optional findByAnyScopeDigest( - Collection retainedDigests) { - for (PosterImageIdempotencyScopeDigest digest : retainedDigests) { - Optional found = - jpa.findByHmacKeyVersionAndIdempotencyScopeDigest( - digest.keyVersion(), digest.base64UrlDigest()); - if (found.isPresent()) { - return found.map(PosterImageUploadIntentPersistenceMapper::toDomain); - } - } - return Optional.empty(); - } - - @Override - public Optional findReadyByPublishedReference( - PosterImageReference reference) { - return jpa.findByPublishedReference(reference.value()) - .map(PosterImageUploadIntentPersistenceMapper::toDomain) - .filter(intent -> intent.state() == PosterImageUploadIntentState.READY); - } - - @Override - public PosterImageUploadIntent compareAndSet( - long expectedRevision, PosterImageUploadIntent replacement) { - PosterImageUploadIntentEntity current = - jpa.findById(replacement.id()) - .orElseThrow( - () -> - new PosterImageUploadIntentConflictException( - "poster image upload intent is absent")); - if (current.getRevision() != expectedRevision - || replacement.revision() != expectedRevision + 1) { - throw new PosterImageUploadIntentConflictException( - "poster image upload intent revision conflicts"); - } - current.applyMutableState(PosterImageUploadIntentPersistenceMapper.toEntity(replacement)); - return PosterImageUploadIntentPersistenceMapper.toDomain(jpa.saveAndFlush(current)); - } - - @Override - public Optional claimPending( - String claimantDigest, Instant now, Duration leaseDuration) { - return jpa - .findClaimable(PosterImageUploadIntentState.PENDING, now, PageRequest.of(0, 1)) - .stream() - .findFirst() - .map(PosterImageUploadIntentPersistenceMapper::toDomain) - .map( - current -> { - PosterImageUploadIntent replacement = - current.claim(current.revision(), claimantDigest, now, leaseDuration); - PosterImageUploadIntent saved = compareAndSet(current.revision(), replacement); - return new PosterImageUploadIntentClaim( - saved, saved.handoffFence(), saved.handoffExpiresAt()); - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterJpaRepository.java deleted file mode 100644 index 8d876ca..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterJpaRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterEntity; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; -import java.util.UUID; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PosterJpaRepository extends JpaRepository { - - // Flat equality filter (the only filter syntax the contract allows), kept in the data layer. - Page findByStatus(PosterStatus status, Pageable pageable); - - boolean existsByTitleIgnoreCase(String title); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapter.java deleted file mode 100644 index b4b6bdb..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapter.java +++ /dev/null @@ -1,95 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.PosterEntity; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.PosterPersistenceMapper; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Repository; - -@Repository -public class PosterRepositoryAdapter implements PosterRepository { - - private final PosterJpaRepository jpa; - // Audit metadata (time/actor) is stamped here in the persistence adapter; the domain Poster - // carries no audit fields. Mirrors WorkLogRepositoryAdapter. - private final Clock clock; - private final AuditContextPort auditContext; - - public PosterRepositoryAdapter( - PosterJpaRepository jpa, Clock clock, AuditContextPort auditContext) { - this.jpa = jpa; - this.clock = clock; - this.auditContext = auditContext; - } - - @Override - public Poster save(Poster poster) { - PosterEntity entity = PosterPersistenceMapper.toEntity(poster); - Instant now = clock.instant(); - String actor = auditContext.currentActor(); - if (poster.version() == null) { - // INSERT: created_* and updated_* both take now/actor. - entity.initializeAudit(now, actor); - } else { - // UPDATE: carry the persisted created_* forward and bump only updated_*. - PosterEntity existing = - jpa.findById(entity.getId()) - .orElseThrow( - () -> - new IllegalStateException( - "no persisted poster row to carry audit creation from: " - + entity.getId())); - entity.carryCreation(existing.getCreatedAt(), existing.getCreatedBy()); - entity.applyModification(now, actor); - } - return PosterPersistenceMapper.toDomain(jpa.save(entity)); - } - - @Override - public Optional findById(PosterId id) { - return jpa.findById(PosterPersistenceMapper.toUuid(id)).map(PosterPersistenceMapper::toDomain); - } - - @Override - public PosterPage findPage( - int page, int size, PosterSortField sortField, boolean ascending, PosterStatus status) { - int safeSize = size <= 0 ? 20 : size; - int safePage = Math.max(0, page); - Sort sort = - sortField == null - ? Sort.unsorted() - : Sort.by(ascending ? Sort.Direction.ASC : Sort.Direction.DESC, sortField.property()); - PageRequest pageRequest = PageRequest.of(safePage, safeSize, sort); - Page result = - status == null ? jpa.findAll(pageRequest) : jpa.findByStatus(status, pageRequest); - List items = result.map(PosterPersistenceMapper::toDomain).getContent(); - return new PosterPage(items, result.getTotalElements()); - } - - @Override - public boolean existsById(PosterId id) { - return jpa.existsById(PosterPersistenceMapper.toUuid(id)); - } - - @Override - public boolean existsByTitle(String title) { - return jpa.existsByTitleIgnoreCase(title); - } - - @Override - public void deleteById(PosterId id) { - jpa.deleteById(PosterPersistenceMapper.toUuid(id)); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogJpaRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogJpaRepository.java deleted file mode 100644 index 588738d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogJpaRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.WorkLogEntity; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import java.util.List; -import java.util.UUID; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -public interface WorkLogJpaRepository extends JpaRepository { - - // Flat equality filter (the only filter syntax the contract allows), kept in the data layer. - Page findByCategory(WorkCategory category, Pageable pageable); - - long countByCategory(WorkCategory category); - - // CQRS-lite projection read: a JPQL SELECT-new selects only the summary columns into - // WorkLogSummaryRow, skipping aggregate hydration and lazy @ElementCollection joins. - // SELECT-new is preferred over a closed interface projection. See README. - @Query( - "select new dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository.WorkLogSummaryRow(" - + "e.id, e.title, e.category, e.periodStart, e.periodEnd) from WorkLogEntity e") - List findRecentSummaryRows(Pageable pageable); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapter.java deleted file mode 100644 index 5d2e90f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapter.java +++ /dev/null @@ -1,91 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.WorkLogEntity; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.WorkLogPersistenceMapper; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Repository; - -@Repository -public class WorkLogRepositoryAdapter implements WorkLogRepository { - - private final WorkLogJpaRepository jpa; - // Audit metadata (time/actor) is stamped here in the persistence adapter; the domain - // WorkLog carries no audit fields. See README. - private final Clock clock; - private final AuditContextPort auditContext; - - public WorkLogRepositoryAdapter( - WorkLogJpaRepository jpa, Clock clock, AuditContextPort auditContext) { - this.jpa = jpa; - this.clock = clock; - this.auditContext = auditContext; - } - - @Override - public WorkLog save(WorkLog workLog) { - WorkLogEntity entity = WorkLogPersistenceMapper.toEntity(workLog); - Instant now = clock.instant(); - String actor = auditContext.currentActor(); - if (workLog.version() == null) { - // INSERT: created_* and updated_* both take now/actor. - entity.initializeAudit(now, actor); - } else { - // UPDATE: carry the persisted created_* forward and bump only updated_*. - WorkLogEntity existing = - jpa.findById(entity.getId()) - .orElseThrow( - () -> - new IllegalStateException( - "no persisted work_log row to carry audit creation from: " - + entity.getId())); - entity.carryCreation(existing.getCreatedAt(), existing.getCreatedBy()); - entity.applyModification(now, actor); - } - return WorkLogPersistenceMapper.toDomain(jpa.save(entity)); - } - - @Override - public Optional findById(WorkLogId id) { - return jpa.findById(WorkLogPersistenceMapper.toUuid(id)) - .map(WorkLogPersistenceMapper::toDomain); - } - - @Override - public WorkLogPage findPage( - int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) { - int safeSize = size <= 0 ? 20 : size; - int safePage = Math.max(0, page); - Sort sort = - sortField == null - ? Sort.unsorted() - : Sort.by(ascending ? Sort.Direction.ASC : Sort.Direction.DESC, sortField.property()); - PageRequest pageRequest = PageRequest.of(safePage, safeSize, sort); - Page result = - category == null ? jpa.findAll(pageRequest) : jpa.findByCategory(category, pageRequest); - List items = result.map(WorkLogPersistenceMapper::toDomain).getContent(); - return new WorkLogPage(items, result.getTotalElements()); - } - - @Override - public boolean existsById(WorkLogId id) { - return jpa.existsById(WorkLogPersistenceMapper.toUuid(id)); - } - - @Override - public void deleteById(WorkLogId id) { - jpa.deleteById(WorkLogPersistenceMapper.toUuid(id)); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapter.java deleted file mode 100644 index b5840a1..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapter.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.sample.portfolio.application.port.WorkLogSummaryQueryPort; -import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; -import java.util.List; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Repository; - -/** - * Persistence adapter for the WorkLog summary projection read. Implements {@link - * WorkLogSummaryQueryPort} via the JPQL {@code SELECT new} query and maps the storage-native {@link - * WorkLogSummaryRow} (UUID id) to {@link WorkLogSummary} (canonical UUID string id). The - * storage-native {@code UUID} never escapes this adapter. See README. - */ -@Repository -public class WorkLogSummaryQueryAdapter implements WorkLogSummaryQueryPort { - - private final WorkLogJpaRepository jpa; - - public WorkLogSummaryQueryAdapter(WorkLogJpaRepository jpa) { - this.jpa = jpa; - } - - @Override - public List findRecentSummaries(int limit) { - int safeLimit = limit <= 0 ? 1 : limit; - PageRequest pageRequest = - PageRequest.of(0, safeLimit, Sort.by(Sort.Direction.DESC, "periodStart")); - return jpa.findRecentSummaryRows(pageRequest).stream() - .map(WorkLogSummaryQueryAdapter::toSummary) - .toList(); - } - - private static WorkLogSummary toSummary(WorkLogSummaryRow row) { - return new WorkLogSummary( - row.id().toString(), row.title(), row.category(), row.periodStart(), row.periodEnd()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryRow.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryRow.java deleted file mode 100644 index 335ecbc..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryRow.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import java.time.LocalDate; -import java.util.UUID; - -/** - * Persistence-side read row for the WorkLog summary projection: the target of a JPQL {@code SELECT - * new} in {@link WorkLogJpaRepository#findRecentSummaryRows}, mirroring the storage shape (native - * {@code UUID} id). Kept inside the persistence package so the storage-native {@code UUID} never - * escapes the adapter. See README. - */ -public record WorkLogSummaryRow( - UUID id, String title, WorkCategory category, LocalDate periodStart, LocalDate periodEnd) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RawRepoStatsResponse.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RawRepoStatsResponse.java deleted file mode 100644 index c48cf69..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RawRepoStatsResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.repostats; - -/** Raw external provider shape — never crosses into domain. Package-private. */ -record RawRepoStatsResponse(String fullName, int stargazers, String pushedAt, String echoedToken) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java deleted file mode 100644 index 6f1f861..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapper.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.repostats; - -import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; -import dev.caskeleton.shared.error.MappingException; -import java.util.Locale; - -/** Anti-Corruption Layer mapper: normalize, mask, select public fields. See README. */ -final class RepoStatsAclMapper { - - private RepoStatsAclMapper() {} - - static RepoStats toDomain(RawRepoStatsResponse raw) { - if (raw == null || raw.fullName() == null || raw.fullName().isBlank()) { - throw new MappingException("repo provider: missing 'fullName'"); - } - // normalization: lower-case full name. masking: echoedToken is dropped (never reaches domain). - // public-field selection: only fullName/stargazers/pushedAt reach RepoStats. - return new RepoStats( - raw.fullName().toLowerCase(Locale.ROOT), Math.max(0, raw.stargazers()), raw.pushedAt()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsPortClient.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsPortClient.java deleted file mode 100644 index a5af09c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsPortClient.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.repostats; - -import dev.caskeleton.sample.portfolio.application.port.RepoStatsPort; -import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; -import org.springframework.stereotype.Component; - -/** - * Outbound adapter for repository stats. The actual HTTP fetch is abstracted ({@link #fetchRaw}) so - * the template stays infra-light; the raw response passes through the ACL before any domain type is - * produced. See README. - */ -@Component -public class RepoStatsPortClient implements RepoStatsPort { - - @Override - public RepoStats fetch(String repoUrl) { - return RepoStatsAclMapper.toDomain(fetchRaw(repoUrl)); - } - - /** Abstracted external fetch — stubbed for the template. */ - protected RawRepoStatsResponse fetchRaw(String repoUrl) { - return new RawRepoStatsResponse( - "caskeleton/" + safeName(repoUrl), 0, "1970-01-01T00:00:00Z", "REDACTED"); - } - - private static String safeName(String repoUrl) { - if (repoUrl == null || repoUrl.isBlank()) { - return "unknown"; - } - int slash = repoUrl.lastIndexOf('/'); - return slash >= 0 && slash < repoUrl.length() - 1 ? repoUrl.substring(slash + 1) : repoUrl; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java deleted file mode 100644 index 82123ab..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AbortPosterImageUploadCommand.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.outbound.CallBudget; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; - -public record AbortPosterImageUploadCommand( - PosterId posterId, - ObjectOperationId operationId, - CallBudget budget, - CancellationView cancellation) - implements Command { - - public AbortPosterImageUploadCommand { - if (posterId == null || operationId == null || budget == null || cancellation == null) { - throw new IllegalArgumentException("Poster image abort command is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AdoptLegacyPosterImageCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AdoptLegacyPosterImageCommand.java deleted file mode 100644 index 4499252..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/AdoptLegacyPosterImageCommand.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.util.Arrays; - -/** Explicit administrative report/apply command; never exposed through a normal endpoint. */ -// The adoption command remains active until data/API migration is complete. -@SuppressWarnings("deprecation") -public final class AdoptLegacyPosterImageCommand implements Command { - - private final PosterId posterId; - private final long expectedPosterVersion; - private final LegacyObjectAdoptionRequest request; - private final byte[] canonicalApprovalDocument; - - public AdoptLegacyPosterImageCommand( - PosterId posterId, - long expectedPosterVersion, - LegacyObjectAdoptionRequest request, - byte[] canonicalApprovalDocument) { - if (posterId == null || expectedPosterVersion < 0 || request == null) { - throw new IllegalArgumentException("legacy Poster image adoption command is invalid"); - } - this.posterId = posterId; - this.expectedPosterVersion = expectedPosterVersion; - this.request = request; - this.canonicalApprovalDocument = - canonicalApprovalDocument == null - ? null - : Arrays.copyOf(canonicalApprovalDocument, canonicalApprovalDocument.length); - } - - public PosterId posterId() { - return posterId; - } - - public long expectedPosterVersion() { - return expectedPosterVersion; - } - - public LegacyObjectAdoptionRequest request() { - return request; - } - - public byte[] canonicalApprovalDocument() { - return canonicalApprovalDocument == null - ? null - : Arrays.copyOf(canonicalApprovalDocument, canonicalApprovalDocument.length); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ArchivePosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ArchivePosterCommand.java deleted file mode 100644 index 8a05b27..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ArchivePosterCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.util.Objects; - -public record ArchivePosterCommand(PosterId id) implements Command { - - public ArchivePosterCommand { - Objects.requireNonNull(id, "id"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/BatchCreateWorkLogsCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/BatchCreateWorkLogsCommand.java deleted file mode 100644 index 9b9fbb8..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/BatchCreateWorkLogsCommand.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import java.util.List; - -/** - * Atomic batch-create intent: all items succeed together or the whole batch rolls back. Wraps the - * per-item {@link CreateWorkLogCommand}s so the inbound port stays a single {@link Command}. - */ -public record BatchCreateWorkLogsCommand(List items) implements Command { - - public BatchCreateWorkLogsCommand { - items = items == null ? List.of() : List.copyOf(items); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreatePosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreatePosterCommand.java deleted file mode 100644 index abb4387..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreatePosterCommand.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import java.util.Objects; - -/** - * Self-validating input model for creating a poster: fail fast on a null required field, regardless - * of the calling adapter. {@code caption} is optional (may be null → normalised to empty). - */ -public record CreatePosterCommand(String title, String caption) implements Command { - - public CreatePosterCommand { - Objects.requireNonNull(title, "title"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreateWorkLogCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreateWorkLogCommand.java deleted file mode 100644 index 47ac5d2..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/CreateWorkLogCommand.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import java.time.LocalDate; -import java.util.List; -import java.util.Objects; - -public record CreateWorkLogCommand( - String owner, - String title, - WorkCategory category, - String summary, - String content, - List techStack, - List links, - LocalDate periodStart, - LocalDate periodEnd) - implements Command { - - // Self-validating input model: fail fast on null required fields, regardless of the - // calling adapter. `owner` is validated in the use case; summary/content/periodEnd are - // optional. See README. - public CreateWorkLogCommand { - Objects.requireNonNull(title, "title"); - Objects.requireNonNull(category, "category"); - Objects.requireNonNull(periodStart, "periodStart"); - techStack = techStack == null ? List.of() : List.copyOf(techStack); - links = links == null ? List.of() : List.copyOf(links); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeletePosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeletePosterCommand.java deleted file mode 100644 index b016cf0..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeletePosterCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.util.Objects; - -public record DeletePosterCommand(PosterId id) implements Command { - - public DeletePosterCommand { - Objects.requireNonNull(id, "id"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeleteWorkLogCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeleteWorkLogCommand.java deleted file mode 100644 index 908bd25..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/DeleteWorkLogCommand.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import java.util.Objects; - -public record DeleteWorkLogCommand(WorkLogId id) implements Command { - - // Self-validating input model: a delete with no target id fails fast at construction. See README. - public DeleteWorkLogCommand { - Objects.requireNonNull(id, "id"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterCommand.java deleted file mode 100644 index 644b038..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.util.Objects; - -public record PublishPosterCommand(PosterId id) implements Command { - - public PublishPosterCommand { - Objects.requireNonNull(id, "id"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java deleted file mode 100644 index fe49ea2..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/PublishPosterImageCommand.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.objectstorage.content.ObjectContentProducer; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.application.outbound.CallBudget; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.util.Objects; - -/** Framework-free request-memory publication command; no MultipartFile or payload array. */ -public record PublishPosterImageCommand( - PosterId posterId, - long expectedPosterVersion, - String tenant, - String principal, - String idempotencyKey, - ObjectContentIdentity contentIdentity, - ObjectMediaType mediaType, - ObjectContentProducer producer, - CallBudget budget, - CancellationView cancellation) - implements Command { - - public PublishPosterImageCommand { - Objects.requireNonNull(posterId, "posterId must be non-null"); - Objects.requireNonNull(principal, "principal must be non-null"); - Objects.requireNonNull(idempotencyKey, "idempotencyKey must be non-null"); - Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); - Objects.requireNonNull(mediaType, "mediaType must be non-null"); - Objects.requireNonNull(producer, "producer must be non-null"); - Objects.requireNonNull(budget, "budget must be non-null"); - Objects.requireNonNull(cancellation, "cancellation must be non-null"); - if (expectedPosterVersion < 0) { - throw new IllegalArgumentException("expected Poster version is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageRetirementCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageRetirementCommand.java deleted file mode 100644 index 184a947..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageRetirementCommand.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.outbound.CallBudget; -import java.time.Duration; - -/** One bounded worker claim/reconcile attempt. */ -public record ReconcilePosterImageRetirementCommand( - String claimantDigest, - Duration leaseDuration, - Duration retryBackoff, - CallBudget budget, - CancellationView cancellation) - implements Command { - - public ReconcilePosterImageRetirementCommand { - if (claimantDigest == null - || !claimantDigest.matches("[0-9a-f]{64}") - || leaseDuration == null - || leaseDuration.isZero() - || leaseDuration.isNegative() - || retryBackoff == null - || retryBackoff.isNegative() - || budget == null - || cancellation == null) { - throw new IllegalArgumentException( - "Poster image retirement reconciliation command is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java deleted file mode 100644 index e836631..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/ReconcilePosterImageUploadCommand.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.outbound.CallBudget; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; - -public record ReconcilePosterImageUploadCommand( - PosterId posterId, - ObjectOperationId operationId, - CallBudget budget, - CancellationView cancellation) - implements Command { - - public ReconcilePosterImageUploadCommand { - if (posterId == null || operationId == null || budget == null || cancellation == null) { - throw new IllegalArgumentException("Poster image reconciliation command is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/RetirePosterImageCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/RetirePosterImageCommand.java deleted file mode 100644 index 423143a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/RetirePosterImageCommand.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.time.Instant; - -/** Durable enqueue request for logical retirement of one exact published image version. */ -public record RetirePosterImageCommand( - PosterId posterId, - ObjectOperationId operationId, - PosterImageReference reference, - ObjectVersionToken exactVersion, - Instant retainUntil) - implements Command { - - public RetirePosterImageCommand { - if (operationId == null || reference == null || exactVersion == null || retainUntil == null) { - throw new IllegalArgumentException("Poster image retirement command is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdatePosterCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdatePosterCommand.java deleted file mode 100644 index 97487c8..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdatePosterCommand.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.shared.request.Patch; -import java.util.Objects; - -/** - * Partial update of a poster. {@code Patch} distinguishes the three PATCH states (absent / explicit - * null / value). A null id or null patch is a programming error — fail fast; use {@link - * Patch#absent()} for "no change", never {@code null}. Mirrors {@code UpdateWorkLogCommand}. - */ -public record UpdatePosterCommand(PosterId id, Patch title, Patch caption) - implements Command { - - public UpdatePosterCommand { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(title, "title"); - Objects.requireNonNull(caption, "caption"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdateWorkLogCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdateWorkLogCommand.java deleted file mode 100644 index 570a5d8..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UpdateWorkLogCommand.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; -import dev.caskeleton.shared.request.Patch; -import java.util.Objects; - -public record UpdateWorkLogCommand( - WorkLogId id, - Patch title, - Patch summary, - Patch content, - Patch category, - Patch status) - implements Command { - - // Self-validating input model: a null id or null patch is a programming error — fail - // fast at construction; use Patch.absent() for "no change", never null. See README. - public UpdateWorkLogCommand { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(title, "title"); - Objects.requireNonNull(summary, "summary"); - Objects.requireNonNull(content, "content"); - Objects.requireNonNull(category, "category"); - Objects.requireNonNull(status, "status"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java deleted file mode 100644 index a9b1496..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/command/UploadPosterImageCommand.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import dev.caskeleton.application.command.Command; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.util.Objects; - -/** - * Uploads an image for a poster: the raw bytes plus their MIME type. The use case stores them under - * an object-storage key and records the key on the aggregate. - */ -@SuppressWarnings("ArrayRecordComponent") // raw image bytes destined for object storage -public record UploadPosterImageCommand(PosterId id, byte[] content, String contentType) - implements Command { - - public UploadPosterImageCommand { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(content, "content"); - Objects.requireNonNull(contentType, "contentType"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java deleted file mode 100644 index 58c24ea..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisher.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import dev.caskeleton.application.observability.CorrelationIdPort; -import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; -import dev.caskeleton.sample.portfolio.domain.poster.PosterArchived; -import dev.caskeleton.sample.portfolio.domain.poster.PosterCreated; -import dev.caskeleton.sample.portfolio.domain.poster.PosterDeleted; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageAttached; -import dev.caskeleton.sample.portfolio.domain.poster.PosterPublished; -import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; -import java.time.Clock; -import java.time.Instant; -import org.springframework.stereotype.Component; - -/** - * Publishes Poster lifecycle domain events to the transactional outbox. MUST be invoked from within - * a use case's {@code TransactionPort.inWrite(...)} block so the append participates in the same - * write transaction (no dual-write). Factors out the outbox plumbing shown once in {@code - * CreateWorkLogUseCase}, because Poster emits five lifecycle events. {@code eventId} (a UUIDv7 from - * {@link OutboxEventIdFactory}) doubles as the idempotency key; {@code correlationId} comes through - * {@link CorrelationIdPort}, falling back to {@code eventId} off-HTTP. - */ -@Component -public class PosterEventPublisher { - - private final OutboxAppendPort outbox; - private final OutboxEventIdFactory eventIdFactory; - private final CorrelationIdPort correlationIdPort; - private final Clock clock; - - public PosterEventPublisher( - OutboxAppendPort outbox, - OutboxEventIdFactory eventIdFactory, - CorrelationIdPort correlationIdPort, - Clock clock) { - this.outbox = outbox; - this.eventIdFactory = eventIdFactory; - this.correlationIdPort = correlationIdPort; - this.clock = clock; - } - - public void publishCreated(PosterCreated e) { - append( - "poster.created", - e.posterId().value(), - "{" + field("posterId", e.posterId().value()) + "," + field("title", e.title()) + "}"); - } - - public void publishImageAttached(PosterImageAttached e) { - append( - "poster.image-attached", - e.posterId().value(), - "{" - + field("posterId", e.posterId().value()) - + "," - + field("imageKey", e.imageKey()) - + "}"); - } - - public void publishPublished(PosterPublished e) { - append("poster.published", e.posterId().value(), onlyId(e.posterId().value())); - } - - public void publishArchived(PosterArchived e) { - append("poster.archived", e.posterId().value(), onlyId(e.posterId().value())); - } - - public void publishDeleted(PosterDeleted e) { - append("poster.deleted", e.posterId().value(), onlyId(e.posterId().value())); - } - - private void append(String eventType, String aggregateId, String payload) { - String eventId = eventIdFactory.newEventId(); - String correlationId = - correlationIdPort.currentCorrelationId().filter(value -> !value.isBlank()).orElse(eventId); - outbox.append( - new NewOutboxEvent( - eventId, eventType, aggregateId, payload, Instant.now(clock), correlationId, eventId)); - } - - private static String onlyId(String posterId) { - return "{" + field("posterId", posterId) + "}"; - } - - private static String field(String key, String value) { - return "\"" + key + "\":\"" + escape(value) + "\""; - } - - /** Escapes a JSON string value per RFC 8259 §7 (reverse solidus, quote, C0 controls). */ - private static String escape(String s) { - if (s == null) { - return ""; - } - StringBuilder sb = new StringBuilder(s.length()); - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - if (c == '\\') { - sb.append("\\\\"); - } else if (c == '"') { - sb.append("\\\""); - } else if (c < 0x20) { - sb.append(String.format("\\u%04x", (int) c)); - } else { - sb.append(c); - } - } - return sb.toString(); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentPrepared.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentPrepared.java deleted file mode 100644 index 382c237..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentPrepared.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; - -/** Versioned locator-free notification committed with PENDING. */ -public record PosterImageAttachmentPrepared( - PosterId posterId, - ObjectOperationId operationId, - ObjectContentIdentity contentIdentity, - ObjectMediaType mediaType) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentReadyV2.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentReadyV2.java deleted file mode 100644 index 1225b14..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImageAttachmentReadyV2.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; - -/** New V2 ready event; the legacy image-attached/imageKey event remains unchanged. */ -public record PosterImageAttachmentReadyV2( - PosterId posterId, - ObjectOperationId operationId, - PosterImageReference reference, - ObjectContentIdentity contentIdentity, - ObjectMediaType mediaType) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java deleted file mode 100644 index d3b3a56..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/PosterImagePublicationEventPublisher.java +++ /dev/null @@ -1,81 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import dev.caskeleton.application.observability.CorrelationIdPort; -import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; -import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; -import java.time.Clock; -import org.springframework.stereotype.Component; - -/** Transactional locator-free publication integration notifications. */ -@Component -public final class PosterImagePublicationEventPublisher { - - private final OutboxAppendPort outbox; - private final OutboxEventIdFactory ids; - private final CorrelationIdPort correlations; - private final Clock clock; - - public PosterImagePublicationEventPublisher( - OutboxAppendPort outbox, - OutboxEventIdFactory ids, - CorrelationIdPort correlations, - Clock clock) { - this.outbox = outbox; - this.ids = ids; - this.correlations = correlations; - this.clock = clock; - } - - public void publishPrepared(PosterImageAttachmentPrepared event) { - append( - "poster.image-attachment-prepared.v1", - event.posterId().value(), - "{" - + field("posterId", event.posterId().value()) - + "," - + field("operationId", event.operationId().value()) - + "," - + field("mediaType", event.mediaType().canonicalText()) - + "," - + number("exactLength", event.contentIdentity().exactLength()) - + "}"); - } - - public void publishReady(PosterImageAttachmentReadyV2 event) { - append( - "poster.image-attachment-ready.v2", - event.posterId().value(), - "{" - + field("posterId", event.posterId().value()) - + "," - + field("operationId", event.operationId().value()) - + "," - + field("reference", event.reference().value()) - + "," - + field("mediaType", event.mediaType().canonicalText()) - + "," - + number("exactLength", event.contentIdentity().exactLength()) - + "}"); - } - - private void append(String type, String aggregate, String payload) { - String id = ids.newEventId(); - String correlation = - correlations.currentCorrelationId().filter(value -> !value.isBlank()).orElse(id); - outbox.append( - new NewOutboxEvent(id, type, aggregate, payload, clock.instant(), correlation, id)); - } - - private static String field(String name, String value) { - return "\"" + name + "\":\"" + escape(value) + "\""; - } - - private static String number(String name, long value) { - return "\"" + name + "\":" + value; - } - - private static String escape(String value) { - return value.replace("\\", "\\\\").replace("\"", "\\\""); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java deleted file mode 100644 index 26704cb..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContribution.java +++ /dev/null @@ -1,69 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import dev.caskeleton.application.messaging.contract.ContractDescriptor; -import dev.caskeleton.application.messaging.contract.ContractId; -import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution; -import dev.caskeleton.application.messaging.contract.LogicalDestinationId; -import dev.caskeleton.application.messaging.contract.SchemaResourceId; -import dev.caskeleton.application.messaging.contract.Sha256; -import java.time.Duration; -import java.util.HexFormat; -import java.util.List; - -/** Static sample contribution for the {@code portfolio.worklog.reserved} v1 contract. */ -public final class WorkLogReservedContractContribution - implements IntegrationEventContractContribution { - - private static final ContractId CONTRACT_ID = new ContractId("portfolio.worklog.reserved"); - private static final SchemaResourceId SCHEMA_RESOURCE = - new SchemaResourceId("contracts/messaging/portfolio.worklog.reserved/v1.schema.json"); - private static final Sha256 SCHEMA_HASH = - new Sha256( - HexFormat.of() - .parseHex("7264efd4e2531e6fd00010bb3deca96a" + "f2f1d0408d0021d014feaf90190f2eac")); - private static final ContractDescriptor DESCRIPTOR = - new ContractDescriptor( - "sample-portfolio", - new LogicalDestinationId("portfolio-domain-events"), - "json-schema-envelope-v1", - true, - 64 * 1024, - 128 * 1024, - ContractDescriptor.SensitivityClassification.INTERNAL, - Duration.ofDays(7)); - - @Override - public ContractId contractId() { - return CONTRACT_ID; - } - - @Override - public int payloadVersion() { - return 1; - } - - @Override - public Class exactPayloadRecordType() { - return WorkLogReservedPayload.class; - } - - @Override - public List canonicalRecordComponentOrder() { - return List.of("workLogId"); - } - - @Override - public SchemaResourceId payloadSchemaResource() { - return SCHEMA_RESOURCE; - } - - @Override - public Sha256 payloadSchemaHash() { - return SCHEMA_HASH; - } - - @Override - public ContractDescriptor descriptor() { - return DESCRIPTOR; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEvent.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEvent.java deleted file mode 100644 index 90bcb74..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEvent.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -/** - * Integration (wire) event derived from the {@code WorkLogReserved} domain event: the - * boundary-owned, serialisable shape an outbound adapter would publish (e.g. to Kafka). It lives in - * the application layer because translating a domain fact into a wire contract is an application - * concern; the domain stays unaware of the wire. See README. - */ -public record WorkLogReservedIntegrationEvent( - String workLogId, String category, String occurredOn) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapper.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapper.java deleted file mode 100644 index b266524..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapper.java +++ /dev/null @@ -1,75 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; - -/** - * Maps the transport-free {@code WorkLogReserved} domain event to a {@link - * WorkLogReservedIntegrationEvent} at the application boundary, and serialises it to a hand-rolled - * JSON payload string for the transactional outbox. See README. - */ -public final class WorkLogReservedIntegrationEventMapper { - - /** Stable logical event type name; becomes the broker topic. See README. */ - public static final String EVENT_TYPE = "worklog.reserved"; - - private WorkLogReservedIntegrationEventMapper() {} - - /** - * Maps a {@link WorkLogReserved} domain event to its integration event form, flattening value - * objects to primitive strings. - * - * @param event the domain event; must not be {@code null} - * @return the integration event record - */ - public static WorkLogReservedIntegrationEvent toIntegrationEvent(WorkLogReserved event) { - return new WorkLogReservedIntegrationEvent( - event.workLogId().value(), event.category().name(), event.periodStart().toString()); - } - - /** - * Serialises a {@link WorkLogReservedIntegrationEvent} to a JSON string for the outbox {@code - * payload} field (hand-rolled, no Jackson/Gson dependency). See README. - * - * @param event the integration event to serialise; must not be {@code null} - * @return a JSON object string, e.g. {@code - * {"workLogId":"0190bd6e-7c3e-7abc-8def-0123456789ab","category":"BACKEND","occurredOn":"2025-01-01"}} - */ - public static String toJson(WorkLogReservedIntegrationEvent event) { - return "{" - + jsonField("workLogId", event.workLogId()) - + "," - + jsonField("category", event.category()) - + "," - + jsonField("occurredOn", event.occurredOn()) - + "}"; - } - - /** Renders a JSON key-value pair, escaping the value per RFC 8259 §7. */ - static String jsonField(String key, String value) { - return "\"" + key + "\":\"" + escape(value) + "\""; - } - - /** - * Escapes a string value for embedding in a JSON string literal. Covers the mandatory escapes in - * RFC 8259 §7: reverse solidus, quotation mark, and the C0 control characters (U+0000–U+001F). - */ - static String escape(String s) { - if (s == null) { - return ""; - } - StringBuilder sb = new StringBuilder(s.length()); - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - if (c == '\\') { - sb.append("\\\\"); - } else if (c == '"') { - sb.append("\\\""); - } else if (c < 0x20) { - sb.append(String.format("\\u%04x", (int) c)); - } else { - sb.append(c); - } - } - return sb.toString(); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java deleted file mode 100644 index 3f78dec..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedPayload.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import dev.caskeleton.application.messaging.contract.IntegrationPayload; - -/** Immutable sample payload for the {@code portfolio.worklog.reserved} v1 contract. */ -public record WorkLogReservedPayload(String workLogId) implements IntegrationPayload { - - private static final int MAXIMUM_WORK_LOG_ID_LENGTH = 160; - private static final String WORK_LOG_ID_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*"; - - public WorkLogReservedPayload { - if (workLogId == null - || workLogId.length() > MAXIMUM_WORK_LOG_ID_LENGTH - || !workLogId.matches(WORK_LOG_ID_GRAMMAR)) { - throw new IllegalArgumentException( - "workLogId must be a 1-160 character canonical US-ASCII identifier"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterNotFoundException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterNotFoundException.java deleted file mode 100644 index 07fcc55..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterNotFoundException.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.exception; - -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; - -/** Raised when a poster lookup misses. Carries no error code — the web handler maps it. */ -public class PosterNotFoundException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - public PosterNotFoundException(PosterId id) { - super("Poster not found: " + id.value()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterTitleAlreadyExistsException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterTitleAlreadyExistsException.java deleted file mode 100644 index 4edaffa..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/PosterTitleAlreadyExistsException.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.exception; - -/** Raised when creating a poster whose title is already taken. Web handler maps it to 409. */ -public class PosterTitleAlreadyExistsException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - public PosterTitleAlreadyExistsException(String title) { - super("Poster title already in use: " + title); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/WorkLogNotFoundException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/WorkLogNotFoundException.java deleted file mode 100644 index 632fed2..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/exception/WorkLogNotFoundException.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.exception; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; - -public class WorkLogNotFoundException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - public WorkLogNotFoundException(WorkLogId id) { - super("WorkLog not found: " + id.value()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/RepoStatsPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/RepoStatsPort.java deleted file mode 100644 index ea92b55..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/RepoStatsPort.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.port; - -import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; - -/** Outbound port: fetch normalized repository statistics for a repo URL. */ -public interface RepoStatsPort { - RepoStats fetch(String repoUrl); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/WorkLogSummaryQueryPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/WorkLogSummaryQueryPort.java deleted file mode 100644 index 4fc4fe2..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/port/WorkLogSummaryQueryPort.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.port; - -import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; -import java.util.List; - -/** - * Application read/query port for WorkLog projections. By convention a read port returning a - * projection (not an aggregate) ends with {@code QueryPort}, and an ArchUnit rule keeps such a port - * from leaking domain aggregate / JPA / web types. The read-model bypass is an opt-in optimisation, - * not a forced default. See README. - */ -public interface WorkLogSummaryQueryPort { - - /** Returns the most recent WorkLog summaries (newest period first), at most {@code limit}. */ - List findRecentSummaries(int limit); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java deleted file mode 100644 index 0854c23..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/AbortPosterImageUploadUseCase.java +++ /dev/null @@ -1,111 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; -import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; -import dev.caskeleton.application.objectstorage.request.ObjectAbortAuthorization; -import dev.caskeleton.application.objectstorage.request.ObjectAbortRequest; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.AbortPosterImageUploadCommand; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import java.time.Clock; - -/** Explicit application-first abort authorization; lease expiry alone is never destructive. */ -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, - externalOutboundAllowed = true) -public final class AbortPosterImageUploadUseCase - implements CommandUseCase { - - private final PosterImageUploadIntentStorePort intents; - private final TransactionPort transactions; - private final ObjectPublicationHandoffPort handoffs; - private final StagedObjectPublicationPort publications; - private final ObjectDestinationId destination; - private final ObjectOperationEpoch epoch; - private final Clock clock; - - public AbortPosterImageUploadUseCase( - PosterImageUploadIntentStorePort intents, - TransactionPort transactions, - ObjectPublicationHandoffPort handoffs, - StagedObjectPublicationPort publications, - ObjectDestinationId destination, - ObjectOperationEpoch epoch, - Clock clock) { - this.intents = intents; - this.transactions = transactions; - this.handoffs = handoffs; - this.publications = publications; - this.destination = destination; - this.epoch = epoch; - this.clock = clock; - } - - @Override - public PosterImagePublicationStatus handle(AbortPosterImageUploadCommand command) { - ObjectOperationKey key = new ObjectOperationKey(destination, epoch, command.operationId()); - PosterImageUploadIntent authorized = - transactions.inWrite( - () -> { - PosterImageUploadIntent current = - intents - .findByOperation(key) - .filter(found -> found.posterId().equals(command.posterId())) - .orElseThrow( - () -> - new IllegalArgumentException( - "Poster image publication operation was not found")); - if (current.state().terminal() - || current.state() == PosterImageUploadIntentState.ABORT_AUTHORIZED) { - return current; - } - PosterImageUploadIntent replacement = - current.authorizeAbort(current.revision(), clock.instant()); - return intents.compareAndSet(current.revision(), replacement); - }); - if (authorized.state().terminal()) { - return PosterImagePublicationStatus.from(authorized); - } - ObjectAbortAuthorization authorization = - new ObjectAbortAuthorization( - key, - authorized.stageHandle(), - authorized.exactVersion(), - authorized.handoffFence(), - clock.instant()); - handoffs.authorizeAbort(authorization); - publications.abort( - new ObjectAbortRequest( - key, - authorized.stageHandle(), - authorization, - command.budget(), - command.cancellation())); - PosterImageUploadIntent aborted = - transactions.inWrite( - () -> { - PosterImageUploadIntent current = intents.findByOperation(key).orElseThrow(); - if (current.state() == PosterImageUploadIntentState.ABORTED) { - return current; - } - return intents.compareAndSet( - current.revision(), current.aborted(current.revision(), clock.instant())); - }); - return PosterImagePublicationStatus.from(aborted); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ArchivePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ArchivePosterUseCase.java deleted file mode 100644 index 5382cde..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ArchivePosterUseCase.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.ArchivePosterCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterArchived; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import org.springframework.stereotype.Service; - -/** - * Archives a poster (→ {@code ARCHIVED}, terminal). Emits a {@code poster.archived} outbox event. - */ -@Service -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public class ArchivePosterUseCase implements CommandUseCase { - - private final PosterRepository repository; - private final PosterEventPublisher events; - private final TransactionPort tx; - - public ArchivePosterUseCase( - PosterRepository repository, PosterEventPublisher events, TransactionPort tx) { - this.repository = repository; - this.events = events; - this.tx = tx; - } - - @Override - public Poster handle(ArchivePosterCommand cmd) { - return tx.inWrite( - () -> { - Poster p = - repository - .findById(cmd.id()) - .orElseThrow(() -> new PosterNotFoundException(cmd.id())); - p.archive(); - Poster saved = repository.save(p); - events.publishArchived(new PosterArchived(saved.id())); - return saved; - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/CreatePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/CreatePosterUseCase.java deleted file mode 100644 index 5ed0390..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/CreatePosterUseCase.java +++ /dev/null @@ -1,61 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.CreatePosterCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; -import dev.caskeleton.sample.portfolio.application.exception.PosterTitleAlreadyExistsException; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterCreated; -import dev.caskeleton.sample.portfolio.domain.poster.PosterIdFactory; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import org.springframework.stereotype.Service; - -/** - * Creates a new {@link Poster} (server-assigned id via {@link PosterIdFactory}) and appends a - * {@code poster.created} outbox event in the same write transaction. Rejects a duplicate title. - * Mirrors {@code CreateWorkLogUseCase}. - */ -@Service -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.NOT_IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public class CreatePosterUseCase implements CommandUseCase { - - private final PosterRepository repository; - private final PosterIdFactory idFactory; - private final PosterEventPublisher events; - private final TransactionPort tx; - - public CreatePosterUseCase( - PosterRepository repository, - PosterIdFactory idFactory, - PosterEventPublisher events, - TransactionPort tx) { - this.repository = repository; - this.idFactory = idFactory; - this.events = events; - this.tx = tx; - } - - @Override - public Poster handle(CreatePosterCommand cmd) { - return tx.inWrite( - () -> { - if (repository.existsByTitle(cmd.title())) { - throw new PosterTitleAlreadyExistsException(cmd.title()); - } - Poster saved = - repository.save(Poster.create(idFactory.newId(), cmd.title(), cmd.caption())); - events.publishCreated(new PosterCreated(saved.id(), saved.title())); - return saved; - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java deleted file mode 100644 index ee60d11..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterUseCase.java +++ /dev/null @@ -1,89 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterDeleted; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import java.time.Duration; -import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -/** - * Deletes a poster — the destructive, admin-tier operation, gated by {@code poster:close} (granted - * only to {@code admin}). Emits a {@code poster.deleted} outbox event. Mirrors {@code - * DeleteWorkLogUseCase}. - */ -@Service -@RequiresPermission("poster:close") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public class DeletePosterUseCase implements CommandUseCase { - - private final PosterRepository repository; - private final PosterEventPublisher events; - private final TransactionPort tx; - private final Optional retireImages; - private final Duration retirementRetention; - - public DeletePosterUseCase( - PosterRepository repository, PosterEventPublisher events, TransactionPort tx) { - this(repository, events, tx, Optional.empty(), Duration.ZERO); - } - - @Autowired - public DeletePosterUseCase( - PosterRepository repository, - PosterEventPublisher events, - TransactionPort tx, - Optional retireImages) { - this(repository, events, tx, retireImages, Duration.ZERO); - } - - public DeletePosterUseCase( - PosterRepository repository, - PosterEventPublisher events, - TransactionPort tx, - Optional retireImages, - Duration retirementRetention) { - this.repository = repository; - this.events = events; - this.tx = tx; - this.retireImages = retireImages; - this.retirementRetention = retirementRetention; - } - - @Override - public Void handle(DeletePosterCommand cmd) { - return tx.inWrite( - () -> { - if (retireImages.isEmpty()) { - if (!repository.existsById(cmd.id())) { - throw new PosterNotFoundException(cmd.id()); - } - } else { - Poster poster = - repository - .findById(cmd.id()) - .orElseThrow(() -> new PosterNotFoundException(cmd.id())); - retireImages - .orElseThrow() - .enqueueAttachedInCurrentTransaction(poster, retirementRetention); - } - events.publishDeleted(new PosterDeleted(cmd.id())); - repository.deleteById(cmd.id()); - return null; - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java deleted file mode 100644 index 7b6da5a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterImagePublicationStatusUseCase.java +++ /dev/null @@ -1,57 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.QueryUseCase; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import dev.caskeleton.sample.portfolio.application.query.GetPosterImagePublicationStatusQuery; - -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.READ_ONLY, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetPosterImagePublicationStatusUseCase - implements QueryUseCase { - - private final PosterImageUploadIntentStorePort intents; - private final TransactionPort transactions; - private final ObjectDestinationId destination; - private final ObjectOperationEpoch epoch; - - public GetPosterImagePublicationStatusUseCase( - PosterImageUploadIntentStorePort intents, - TransactionPort transactions, - ObjectDestinationId destination, - ObjectOperationEpoch epoch) { - this.intents = intents; - this.transactions = transactions; - this.destination = destination; - this.epoch = epoch; - } - - @Override - public PosterImagePublicationStatus handle(GetPosterImagePublicationStatusQuery query) { - return transactions.inRead( - () -> { - PosterImageUploadIntent intent = - intents - .findByOperation(new ObjectOperationKey(destination, epoch, query.operationId())) - .filter(found -> found.posterId().equals(query.posterId())) - .orElseThrow( - () -> - new IllegalArgumentException( - "Poster image publication operation was not found")); - return PosterImagePublicationStatus.from(intent); - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterUseCase.java deleted file mode 100644 index 46acbe1..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/GetPosterUseCase.java +++ /dev/null @@ -1,35 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.QueryUseCase; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.application.query.GetPosterQuery; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import org.springframework.stereotype.Service; - -@Service -@UseCaseCapability( - transactionMode = TransactionMode.READ_ONLY, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public class GetPosterUseCase implements QueryUseCase { - - private final PosterRepository repository; - private final TransactionPort tx; - - public GetPosterUseCase(PosterRepository repository, TransactionPort tx) { - this.repository = repository; - this.tx = tx; - } - - @Override - public Poster handle(GetPosterQuery q) { - return tx.inRead( - () -> repository.findById(q.id()).orElseThrow(() -> new PosterNotFoundException(q.id()))); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ListPostersUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ListPostersUseCase.java deleted file mode 100644 index afbb9f4..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ListPostersUseCase.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.QueryUseCase; -import dev.caskeleton.sample.portfolio.application.query.ListPostersQuery; -import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import org.springframework.stereotype.Service; - -@Service -@UseCaseCapability( - transactionMode = TransactionMode.READ_ONLY, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public class ListPostersUseCase implements QueryUseCase { - - private final PosterRepository repository; - private final TransactionPort tx; - - public ListPostersUseCase(PosterRepository repository, TransactionPort tx) { - this.repository = repository; - this.tx = tx; - } - - @Override - public PosterPage handle(ListPostersQuery q) { - return tx.inRead( - () -> repository.findPage(q.page(), q.size(), q.sortField(), q.ascending(), q.status())); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java deleted file mode 100644 index d0b0247..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterImageUseCase.java +++ /dev/null @@ -1,266 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.idempotency.IdempotencyExecutor; -import dev.caskeleton.application.idempotency.RequestFingerprint; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.application.objectstorage.model.ObjectCapabilityRequirement; -import dev.caskeleton.application.objectstorage.model.ObjectEncryptionRequirement; -import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; -import dev.caskeleton.application.objectstorage.model.ObjectRetentionRequirement; -import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; -import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; -import dev.caskeleton.application.objectstorage.request.ObjectHandoffClaimRequest; -import dev.caskeleton.application.objectstorage.request.ObjectStageRequest; -import dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.PublishPosterImageCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterImageAttachmentPrepared; -import dev.caskeleton.sample.portfolio.application.event.PosterImagePublicationEventPublisher; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationFingerprint; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationPolicy; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationReservation; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationReservationCodec; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Clock; -import java.time.Duration; -import java.util.EnumSet; -import java.util.HexFormat; - -/** TX1 reservation followed by request-bound staging and a fenced TX2 PENDING handoff. */ -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.KEYED, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, - externalOutboundAllowed = true, - sensitiveRead = true) -public final class PublishPosterImageUseCase - implements CommandUseCase { - - private static final PosterImagePublicationReservationCodec RESERVATION_CODEC = - new PosterImagePublicationReservationCodec(); - - private final PosterImageUploadIntentStorePort intents; - private final PosterImageSanitizedIdempotencyContextFactory sanitizedContexts; - private final PosterImageOperationIdFactory ids; - private final IdempotencyExecutor idempotency; - private final TransactionPort transactions; - private final StagedObjectPublicationPort publications; - private final ObjectPublicationHandoffPort handoffs; - private final PosterImagePublicationEventPublisher events; - private final PosterImagePublicationPolicy policy; - private final ObjectDestinationId destination; - private final ObjectOperationEpoch epoch; - private final Duration intentTtl; - private final Duration handoffLease; - private final Clock clock; - - public PublishPosterImageUseCase( - PosterImageUploadIntentStorePort intents, - PosterImageSanitizedIdempotencyContextFactory sanitizedContexts, - PosterImageOperationIdFactory ids, - IdempotencyExecutor idempotency, - TransactionPort transactions, - StagedObjectPublicationPort publications, - ObjectPublicationHandoffPort handoffs, - PosterImagePublicationEventPublisher events, - PosterImagePublicationPolicy policy, - ObjectDestinationId destination, - ObjectOperationEpoch epoch, - Duration intentTtl, - Duration handoffLease, - Clock clock) { - this.intents = intents; - this.sanitizedContexts = sanitizedContexts; - this.ids = ids; - this.idempotency = idempotency; - this.transactions = transactions; - this.publications = publications; - this.handoffs = handoffs; - this.events = events; - this.policy = policy; - this.destination = destination; - this.epoch = epoch; - this.intentTtl = intentTtl; - this.handoffLease = handoffLease; - this.clock = clock; - } - - @Override - public PosterImagePublicationReservation handle(PublishPosterImageCommand command) { - policy.requireAllowed(command.contentIdentity(), command.mediaType()); - RequestFingerprint fingerprint = - PosterImagePublicationFingerprint.create( - command.posterId(), - command.expectedPosterVersion(), - destination, - command.mediaType(), - command.contentIdentity()); - PosterImageSanitizedIdempotencyContextFactory.SanitizedContext sanitized = - sanitizedContexts.create( - command.tenant(), command.principal(), command.idempotencyKey(), fingerprint); - - PosterImagePublicationReservation reservation = - transactions.inWrite( - () -> - idempotency.execute( - sanitized.generic(), - () -> reserve(command, fingerprint, sanitized), - RESERVATION_CODEC)); - - PosterImageUploadIntent intent = - transactions.inRead( - () -> - intents - .findByOperation(operation(reservation.operationId())) - .orElseThrow( - () -> - new IllegalStateException( - "committed publication reservation has no intent"))); - if (intent.state() == PosterImageUploadIntentState.RESERVED) { - stageAndPrepare(command, intent); - } - return reservation; - } - - private PosterImagePublicationReservation reserve( - PublishPosterImageCommand command, - RequestFingerprint fingerprint, - PosterImageSanitizedIdempotencyContextFactory.SanitizedContext sanitized) { - java.util.Optional retained = - intents.findByAnyScopeDigest(sanitized.retainedScopes()); - if (retained.isPresent()) { - PosterImageUploadIntent existing = retained.orElseThrow(); - if (!existing.requestFingerprint().equals(fingerprint) - || !existing.posterId().equals(command.posterId())) { - throw new dev.caskeleton.sample.portfolio.application.posterimage - .PosterImageUploadIntentConflictException( - "Poster image idempotency scope is already bound to different work"); - } - return reservation(existing); - } - PosterImageOperationIdFactory.AllocatedOperation allocated = ids.allocate(); - PosterImageUploadIntent created = - intents.createOrRead( - PosterImageUploadIntent.reserve( - allocated.intentId(), - command.posterId(), - operation(allocated.operationId()), - sanitized.activeScope(), - fingerprint, - command.expectedPosterVersion(), - command.contentIdentity(), - command.mediaType(), - clock.instant(), - clock.instant().plus(intentTtl))); - return reservation(created); - } - - private void stageAndPrepare( - PublishPosterImageCommand command, PosterImageUploadIntent reserved) { - var stage = - publications.stage( - new ObjectStageRequest( - reserved.operationKey(), - command.mediaType(), - command.contentIdentity(), - ObjectPublicationRequirement.INTEGRITY_VERIFIED, - ObjectRetentionRequirement.NONE, - ObjectEncryptionRequirement.PROVIDER_MANAGED, - EnumSet.of( - ObjectCapabilityRequirement.IMMUTABLE_CREATE, - ObjectCapabilityRequirement.EXACT_VERSION_READ, - ObjectCapabilityRequirement.CONDITIONAL_RETIREMENT, - ObjectCapabilityRequirement.SHA_256_VERIFICATION, - ObjectCapabilityRequirement.RESPONSE_LOSS_RECONCILIATION), - command.budget(), - command.cancellation()), - command.producer()); - var verified = - publications.verifyIntegrity( - new ObjectVerifyRequest( - reserved.operationKey(), - stage.stageHandle(), - stage.exactVersion(), - reserved.contentIdentity(), - command.budget(), - command.cancellation())); - if (!verified.verifiedContent().equals(reserved.contentIdentity())) { - throw new IllegalStateException("verified Poster image identity conflicts"); - } - String claimant = "poster-image-" + reserved.operationKey().operationId().value(); - var handoff = - handoffs.claimForPublication( - new ObjectHandoffClaimRequest( - reserved.operationKey(), - stage.stageHandle(), - stage.exactVersion(), - claimant, - handoffLease, - command.budget(), - command.cancellation())); - - transactions.inWrite( - () -> { - PosterImageUploadIntent current = - intents - .findByOperation(reserved.operationKey()) - .orElseThrow( - () -> new IllegalStateException("reserved publication intent is absent")); - if (current.state() == PosterImageUploadIntentState.PENDING) { - return; - } - PosterImageUploadIntent pending = - current.pending( - current.revision(), - stage.stageHandle(), - stage.exactVersion(), - sha256Hex(claimant), - handoff.fence(), - handoff.leaseExpiresAt(), - clock.instant()); - intents.compareAndSet(current.revision(), pending); - events.publishPrepared( - new PosterImageAttachmentPrepared( - current.posterId(), - current.operationKey().operationId(), - current.contentIdentity(), - current.mediaType())); - }); - } - - private ObjectOperationKey operation( - dev.caskeleton.application.objectstorage.identity.ObjectOperationId operationId) { - return new ObjectOperationKey(destination, epoch, operationId); - } - - private static PosterImagePublicationReservation reservation(PosterImageUploadIntent intent) { - return new PosterImagePublicationReservation( - intent.posterId(), intent.operationKey().operationId()); - } - - private static String sha256Hex(String value) { - try { - return HexFormat.of() - .formatHex( - MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 must be available", exception); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterUseCase.java deleted file mode 100644 index 29efe89..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/PublishPosterUseCase.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.PublishPosterCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterPublished; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import org.springframework.stereotype.Service; - -/** - * Publishes a poster ({@code DRAFT → PUBLISHED}); the aggregate rejects publishing without an - * attached image. Emits a {@code poster.published} outbox event. - */ -@Service -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public class PublishPosterUseCase implements CommandUseCase { - - private final PosterRepository repository; - private final PosterEventPublisher events; - private final TransactionPort tx; - - public PublishPosterUseCase( - PosterRepository repository, PosterEventPublisher events, TransactionPort tx) { - this.repository = repository; - this.events = events; - this.tx = tx; - } - - @Override - public Poster handle(PublishPosterCommand cmd) { - return tx.inWrite( - () -> { - Poster p = - repository - .findById(cmd.id()) - .orElseThrow(() -> new PosterNotFoundException(cmd.id())); - p.publish(); - Poster saved = repository.save(p); - events.publishPublished(new PosterPublished(saved.id())); - return saved; - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java deleted file mode 100644 index d46d390..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageRetirementUseCase.java +++ /dev/null @@ -1,116 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.application.objectstorage.identity.ObjectReference; -import dev.caskeleton.application.objectstorage.port.ObjectRetirementPort; -import dev.caskeleton.application.objectstorage.request.ObjectRetireRequest; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.ReconcilePosterImageRetirementCommand; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementResult; -import java.time.Clock; - -/** Fenced worker: claim in DB, retire outside DB, persist the exact outcome in a short TX. */ -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, - externalOutboundAllowed = true) -public final class ReconcilePosterImageRetirementUseCase - implements CommandUseCase { - - private final PosterImageRetirementIntentStorePort retirements; - private final TransactionPort transactions; - private final ObjectRetirementPort objects; - private final ObjectDestinationId destination; - private final ObjectOperationEpoch epoch; - private final Clock clock; - - public ReconcilePosterImageRetirementUseCase( - PosterImageRetirementIntentStorePort retirements, - TransactionPort transactions, - ObjectRetirementPort objects, - ObjectDestinationId destination, - ObjectOperationEpoch epoch, - Clock clock) { - this.retirements = retirements; - this.transactions = transactions; - this.objects = objects; - this.destination = destination; - this.epoch = epoch; - this.clock = clock; - } - - @Override - public PosterImageRetirementResult handle(ReconcilePosterImageRetirementCommand command) { - PosterImageRetirementIntent claimed = - transactions - .inWrite( - () -> - retirements.claimReady( - command.claimantDigest(), clock.instant(), command.leaseDuration())) - .orElse(null); - if (claimed == null) { - return PosterImageRetirementResult.none(); - } - - try { - var receipt = - objects.retire( - new ObjectRetireRequest( - new ObjectOperationKey(destination, epoch, claimed.operationId()), - ObjectReference.parse(claimed.reference().value()), - claimed.exactVersion(), - command.budget(), - command.cancellation())); - PosterImageRetirementIntent terminal = - switch (receipt.outcome()) { - case APPLIED, REPLAYED, NO_CHANGE -> - claimed.retired(claimed.revision(), claimed.claimFence(), clock.instant()); - case HELD -> - claimed.held( - claimed.revision(), claimed.claimFence(), "RETENTION_HELD", clock.instant()); - case INDETERMINATE -> - claimed.retry( - claimed.revision(), - claimed.claimFence(), - receipt.error().name(), - clock.instant().plus(command.retryBackoff()), - clock.instant()); - case REJECTED -> - claimed.failed( - claimed.revision(), - claimed.claimFence(), - receipt.error().name(), - clock.instant()); - }; - return persist(claimed, terminal); - } catch (RuntimeException failure) { - PosterImageRetirementIntent retry = - claimed.retry( - claimed.revision(), - claimed.claimFence(), - "PROVIDER_UNAVAILABLE", - clock.instant().plus(command.retryBackoff()), - clock.instant()); - return persist(claimed, retry); - } - } - - private PosterImageRetirementResult persist( - PosterImageRetirementIntent claimed, PosterImageRetirementIntent replacement) { - PosterImageRetirementIntent stored = - transactions.inWrite(() -> retirements.compareAndSet(claimed.revision(), replacement)); - return PosterImageRetirementResult.from(stored); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java deleted file mode 100644 index 045e504..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/ReconcilePosterImageUploadUseCase.java +++ /dev/null @@ -1,213 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.application.objectstorage.model.ObjectPublicationRequirement; -import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; -import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; -import dev.caskeleton.application.objectstorage.request.ObjectFinalizeRequest; -import dev.caskeleton.application.objectstorage.request.ObjectHandoffReleaseRequest; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.ReconcilePosterImageUploadCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterImageAttachmentReadyV2; -import dev.caskeleton.sample.portfolio.application.event.PosterImagePublicationEventPublisher; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageAttachmentCompareAndSetPort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.time.Clock; -import java.time.Duration; - -/** Worker boundary: finalize outside DB, then commit one deterministic READY/SUPERSEDED TX3. */ -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, - externalOutboundAllowed = true) -public final class ReconcilePosterImageUploadUseCase - implements CommandUseCase { - - private final PosterImageUploadIntentStorePort uploads; - private final PosterImageRetirementIntentStorePort retirements; - private final PosterImageAttachmentCompareAndSetPort attachments; - private final PosterImageOperationIdFactory ids; - private final PosterImagePublicationEventPublisher events; - private final TransactionPort transactions; - private final StagedObjectPublicationPort publications; - private final ObjectPublicationHandoffPort handoffs; - private final ObjectDestinationId destination; - private final ObjectOperationEpoch epoch; - private final Duration retirementRetention; - private final Clock clock; - - public ReconcilePosterImageUploadUseCase( - PosterImageUploadIntentStorePort uploads, - PosterImageRetirementIntentStorePort retirements, - PosterImageAttachmentCompareAndSetPort attachments, - PosterImageOperationIdFactory ids, - PosterImagePublicationEventPublisher events, - TransactionPort transactions, - StagedObjectPublicationPort publications, - ObjectPublicationHandoffPort handoffs, - ObjectDestinationId destination, - ObjectOperationEpoch epoch, - Duration retirementRetention, - Clock clock) { - this.uploads = uploads; - this.retirements = retirements; - this.attachments = attachments; - this.ids = ids; - this.events = events; - this.transactions = transactions; - this.publications = publications; - this.handoffs = handoffs; - this.destination = destination; - this.epoch = epoch; - this.retirementRetention = retirementRetention; - this.clock = clock; - } - - @Override - public PosterImagePublicationStatus handle(ReconcilePosterImageUploadCommand command) { - ObjectOperationKey key = new ObjectOperationKey(destination, epoch, command.operationId()); - PosterImageUploadIntent intent = - transactions.inRead( - () -> - uploads - .findByOperation(key) - .filter(found -> found.posterId().equals(command.posterId())) - .orElseThrow( - () -> - new IllegalArgumentException( - "Poster image publication operation was not found"))); - if (intent.state().terminal()) { - releaseIfPublished(intent, command); - return PosterImagePublicationStatus.from(intent); - } - if (intent.state() != PosterImageUploadIntentState.PENDING) { - throw new IllegalStateException("Poster image publication is not ready to reconcile"); - } - - var published = - publications.finalizePublication( - new ObjectFinalizeRequest( - key, - intent.stageHandle(), - intent.exactVersion(), - ObjectPublicationRequirement.INTEGRITY_VERIFIED, - command.budget(), - command.cancellation())); - PosterImageReference reference = PosterImageReference.of(published.reference().canonicalText()); - - PosterImageUploadIntent terminal = - transactions.inWrite(() -> commitTerminal(intent, reference, published.exactVersion())); - - releaseIfPublished(terminal, command); - return PosterImagePublicationStatus.from(terminal); - } - - private void releaseIfPublished( - PosterImageUploadIntent intent, ReconcilePosterImageUploadCommand command) { - if ((intent.state() != PosterImageUploadIntentState.READY - && intent.state() != PosterImageUploadIntentState.SUPERSEDED) - || intent.handoffFence() == null) { - return; - } - handoffs.releaseClaim( - new ObjectHandoffReleaseRequest( - intent.operationKey(), - intent.stageHandle(), - intent.handoffFence(), - "poster-image-" + intent.operationKey().operationId().value(), - command.budget(), - command.cancellation())); - } - - private PosterImageUploadIntent commitTerminal( - PosterImageUploadIntent observed, - PosterImageReference reference, - dev.caskeleton.application.objectstorage.identity.ObjectVersionToken exactVersion) { - PosterImageUploadIntent current = - uploads - .findByOperation(observed.operationKey()) - .orElseThrow(() -> new IllegalStateException("publication intent disappeared")); - if (current.state().terminal()) { - return current; - } - if (current.state() != PosterImageUploadIntentState.PENDING - || current.handoffFence() == null - || !current.stageHandle().equals(observed.stageHandle())) { - throw new IllegalStateException("publication intent handoff changed before TX3"); - } - - PosterImageAttachmentCompareAndSetPort.Result attached = - attachments.attach( - current.posterId(), current.expectedPosterVersion(), reference, clock.instant()); - PosterImageUploadIntent replacement; - if (attached.attached()) { - replacement = - current.ready( - current.revision(), - reference, - exactVersion, - attached.replacedReference(), - clock.instant()); - if (attached.replacedReference() != null) { - enqueueRetirement( - current, - attached.replacedReference(), - uploads - .findReadyByPublishedReference(attached.replacedReference()) - .map(PosterImageUploadIntent::publishedVersion) - .orElseThrow( - () -> - new IllegalStateException( - "replaced Poster image has no exact READY evidence")), - "-replace-retire"); - } - events.publishReady( - new PosterImageAttachmentReadyV2( - current.posterId(), - current.operationKey().operationId(), - reference, - current.contentIdentity(), - current.mediaType())); - } else { - replacement = - current.superseded(current.revision(), reference, exactVersion, clock.instant()); - enqueueRetirement(current, reference, exactVersion, "-loser-retire"); - } - return uploads.compareAndSet(current.revision(), replacement); - } - - private void enqueueRetirement( - PosterImageUploadIntent upload, - PosterImageReference reference, - dev.caskeleton.application.objectstorage.identity.ObjectVersionToken version, - String suffix) { - PosterImageOperationIdFactory.AllocatedOperation allocated = ids.allocate(); - retirements.createOrRead( - PosterImageRetirementIntent.pending( - allocated.intentId(), - upload.posterId(), - dev.caskeleton.application.objectstorage.identity.ObjectOperationId.of( - upload.operationKey().operationId().value() + suffix), - reference, - version, - clock.instant().plus(retirementRetention), - clock.instant())); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java deleted file mode 100644 index 7fca322..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/RetirePosterImageUseCase.java +++ /dev/null @@ -1,91 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.RetirePosterImageCommand; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementResult; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PublishedPosterImageAttachment; -import java.time.Clock; -import java.time.Duration; - -/** Enqueues exact logical retirement without calling object storage in the database transaction. */ -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public final class RetirePosterImageUseCase - implements CommandUseCase { - - private final PosterImageRetirementIntentStorePort retirements; - private final PosterImageUploadIntentStorePort uploads; - private final PosterImageOperationIdFactory ids; - private final TransactionPort transactions; - private final Clock clock; - - public RetirePosterImageUseCase( - PosterImageRetirementIntentStorePort retirements, - PosterImageUploadIntentStorePort uploads, - PosterImageOperationIdFactory ids, - TransactionPort transactions, - Clock clock) { - this.retirements = retirements; - this.uploads = uploads; - this.ids = ids; - this.transactions = transactions; - this.clock = clock; - } - - @Override - public PosterImageRetirementResult handle(RetirePosterImageCommand command) { - return transactions.inWrite(() -> enqueueInCurrentTransaction(command)); - } - - public PosterImageRetirementResult enqueueInCurrentTransaction(RetirePosterImageCommand command) { - PosterImageOperationIdFactory.AllocatedOperation allocated = ids.allocate(); - PosterImageRetirementIntent stored = - retirements.createOrRead( - PosterImageRetirementIntent.pending( - allocated.intentId(), - command.posterId(), - command.operationId(), - command.reference(), - command.exactVersion(), - command.retainUntil(), - clock.instant())); - return PosterImageRetirementResult.from(stored); - } - - public PosterImageRetirementResult enqueueAttachedInCurrentTransaction( - Poster poster, Duration retention) { - if (!(poster.imageAttachment() instanceof PublishedPosterImageAttachment published)) { - return PosterImageRetirementResult.none(); - } - PosterImageUploadIntent ready = - uploads - .findReadyByPublishedReference(published.reference()) - .orElseThrow( - () -> - new IllegalStateException( - "published Poster image has no exact READY evidence")); - PosterImageOperationIdFactory.AllocatedOperation allocated = ids.allocate(); - return enqueueInCurrentTransaction( - new RetirePosterImageCommand( - poster.id(), - allocated.operationId(), - published.reference(), - ready.publishedVersion(), - clock.instant().plus(retention))); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UpdatePosterUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UpdatePosterUseCase.java deleted file mode 100644 index 79c8af9..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UpdatePosterUseCase.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.UpdatePosterCommand; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import org.springframework.stereotype.Service; - -/** - * Partial update of a poster's title/caption using {@code Patch} 3-state semantics. Mirrors {@code - * UpdateWorkLogUseCase}. - */ -@Service -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public class UpdatePosterUseCase implements CommandUseCase { - - private final PosterRepository repository; - private final TransactionPort tx; - - public UpdatePosterUseCase(PosterRepository repository, TransactionPort tx) { - this.repository = repository; - this.tx = tx; - } - - @Override - public Poster handle(UpdatePosterCommand cmd) { - return tx.inWrite( - () -> { - Poster p = - repository - .findById(cmd.id()) - .orElseThrow(() -> new PosterNotFoundException(cmd.id())); - if (cmd.title().hasValue()) { - p.rename(cmd.title().value()); - } - if (cmd.caption().hasValue()) { - p.changeCaption(cmd.caption().value()); - } else if (cmd.caption().isExplicitNull()) { - p.changeCaption(null); - } - return repository.save(p); - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java deleted file mode 100644 index 56b02f4..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/UploadPosterImageUseCase.java +++ /dev/null @@ -1,69 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.storage.ObjectStoragePort; -import dev.caskeleton.application.storage.StoredObject; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.UploadPosterImageCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageAttached; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; - -/** - * Uploads a poster's image to object storage AND records its key on the aggregate — the single - * collaboration that ties object storage to the domain (an image is what lets a poster be - * published). Stores the bytes under {@code posters/{id}/image}, then {@code attachImage}, save, - * and a {@code poster.image-attached} outbox event, all in one write transaction. - */ -@RequiresPermission("poster:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.NOT_IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, - externalOutboundAllowed = true) -// This exact whole-byte use case remains active until data/API migration is complete. -@SuppressWarnings("removal") -public class UploadPosterImageUseCase - implements CommandUseCase { - - private final PosterRepository repository; - private final ObjectStoragePort objectStorage; - private final PosterEventPublisher events; - private final TransactionPort tx; - - public UploadPosterImageUseCase( - PosterRepository repository, - ObjectStoragePort objectStorage, - PosterEventPublisher events, - TransactionPort tx) { - this.repository = repository; - this.objectStorage = objectStorage; - this.events = events; - this.tx = tx; - } - - @Override - public StoredObject handle(UploadPosterImageCommand cmd) { - return tx.inWrite( - () -> { - Poster p = - repository - .findById(cmd.id()) - .orElseThrow(() -> new PosterNotFoundException(cmd.id())); - StoredObject stored = - objectStorage.put( - "posters/" + cmd.id().value() + "/image", cmd.content(), cmd.contentType()); - p.attachImage(stored.key()); - repository.save(p); - events.publishImageAttached(new PosterImageAttached(p.id(), stored.key())); - return stored; - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java deleted file mode 100644 index 7f8a45f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/poster/migration/AdoptLegacyPosterImageUseCase.java +++ /dev/null @@ -1,101 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster.migration; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionApproval; -import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionApprovalVerifierPort; -import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionPort; -import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionRequest; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.AdoptLegacyPosterImageCommand; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.application.posterimage.LegacyPosterImageAdoptionAuthorizationPolicy; -import dev.caskeleton.sample.portfolio.application.posterimage.LegacyPosterImageAdoptionExecutionIdentityPort; -import dev.caskeleton.sample.portfolio.application.posterimage.LegacyPosterImageAdoptionResult; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; - -/** Isolated administrative legacy exception; performs no remote I/O inside a DB transaction. */ -@Deprecated -// The adoption implementation remains active until data/API migration is complete. -@RequiresPermission("poster:image-adopt") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.KEYED, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, - externalOutboundAllowed = true) -public final class AdoptLegacyPosterImageUseCase - implements CommandUseCase { - - private final LegacyObjectAdoptionPort adoptions; - private final LegacyObjectAdoptionApprovalVerifierPort approvals; - private final LegacyPosterImageAdoptionExecutionIdentityPort identity; - private final LegacyPosterImageAdoptionAuthorizationPolicy authorization; - private final PosterRepository posters; - private final TransactionPort transactions; - - public AdoptLegacyPosterImageUseCase( - LegacyObjectAdoptionPort adoptions, - LegacyObjectAdoptionApprovalVerifierPort approvals, - LegacyPosterImageAdoptionExecutionIdentityPort identity, - LegacyPosterImageAdoptionAuthorizationPolicy authorization, - PosterRepository posters, - TransactionPort transactions) { - this.adoptions = adoptions; - this.approvals = approvals; - this.identity = identity; - this.authorization = authorization; - this.posters = posters; - this.transactions = transactions; - } - - @Override - public LegacyPosterImageAdoptionResult handle(AdoptLegacyPosterImageCommand command) { - authorization.authorize(identity.current()); - LegacyObjectAdoptionRequest request = authorizeApply(command); - var receipt = adoptions.adopt(request); - if (request.mode() == LegacyObjectAdoptionRequest.Mode.REPORT_ONLY) { - return new LegacyPosterImageAdoptionResult(receipt, false); - } - transactions.inWrite( - () -> { - Poster poster = - posters - .findById(command.posterId()) - .orElseThrow(() -> new PosterNotFoundException(command.posterId())); - if (poster.version() == null || poster.version() != command.expectedPosterVersion()) { - throw new IllegalStateException("Poster changed during legacy image adoption"); - } - poster.attachPublishedImage( - PosterImageReference.of(receipt.adoptedReference().canonicalText())); - posters.save(poster); - return null; - }); - return new LegacyPosterImageAdoptionResult(receipt, true); - } - - private LegacyObjectAdoptionRequest authorizeApply(AdoptLegacyPosterImageCommand command) { - LegacyObjectAdoptionRequest request = command.request(); - if (request.mode() == LegacyObjectAdoptionRequest.Mode.REPORT_ONLY) { - return request; - } - byte[] document = command.canonicalApprovalDocument(); - if (document == null) { - throw new SecurityException("legacy adoption APPLY approval is required"); - } - LegacyObjectAdoptionApproval approval = approvals.verify(document, request); - return new LegacyObjectAdoptionRequest( - request.locator(), - request.publicationRequest(), - request.manifestSha256(), - request.legacyNamespaceDigest(), - request.targetNamespaceDigest(), - request.mode(), - approval); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionAuthorizationPolicy.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionAuthorizationPolicy.java deleted file mode 100644 index 3bc22e5..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionAuthorizationPolicy.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -/** Exact maintenance identity/capability policy checked before reading approval or legacy data. */ -public final class LegacyPosterImageAdoptionAuthorizationPolicy { - - public static final String REQUIRED_CAPABILITY = "poster:image-adopt"; - - private final String expectedIdentityDigest; - - public LegacyPosterImageAdoptionAuthorizationPolicy(String expectedIdentityDigest) { - if (expectedIdentityDigest == null || !expectedIdentityDigest.matches("[0-9a-f]{64}")) { - throw new IllegalArgumentException("legacy adoption expected identity is invalid"); - } - this.expectedIdentityDigest = expectedIdentityDigest; - } - - public void authorize(LegacyPosterImageAdoptionExecutionIdentity identity) { - if (identity == null - || !expectedIdentityDigest.equals(identity.identityDigest()) - || !identity.capabilities().contains(REQUIRED_CAPABILITY)) { - throw new SecurityException("legacy Poster image adoption is not authorized"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentity.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentity.java deleted file mode 100644 index e8b2627..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentity.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import java.util.Set; - -/** Explicit non-web maintenance identity for administrative legacy adoption. */ -public record LegacyPosterImageAdoptionExecutionIdentity( - String identityDigest, Set capabilities) { - - public LegacyPosterImageAdoptionExecutionIdentity { - if (identityDigest == null - || !identityDigest.matches("[0-9a-f]{64}") - || capabilities == null - || capabilities.contains(null)) { - throw new IllegalArgumentException("legacy adoption execution identity is invalid"); - } - capabilities = Set.copyOf(capabilities); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentityPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentityPort.java deleted file mode 100644 index 89c58d9..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionExecutionIdentityPort.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -/** Supplies only the isolated maintenance workload identity, never a web security context. */ -public interface LegacyPosterImageAdoptionExecutionIdentityPort { - - LegacyPosterImageAdoptionExecutionIdentity current(); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionResult.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionResult.java deleted file mode 100644 index 5a74951..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/LegacyPosterImageAdoptionResult.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.storage.migration.LegacyObjectAdoptionReceipt; - -/** Administrative adoption result; the underlying receipt is locator-free. */ -// The adoption result remains active until data/API migration is complete. -@SuppressWarnings("deprecation") -public record LegacyPosterImageAdoptionResult( - LegacyObjectAdoptionReceipt receipt, boolean attachedToPoster) { - - public LegacyPosterImageAdoptionResult { - if (receipt == null) { - throw new IllegalArgumentException("legacy Poster image adoption result is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageAttachmentCompareAndSetPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageAttachmentCompareAndSetPort.java deleted file mode 100644 index 8942d0e..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageAttachmentCompareAndSetPort.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.time.Instant; - -/** Atomic V8 canonical attachment write; legacy-key rows must be adopted first. */ -public interface PosterImageAttachmentCompareAndSetPort { - - Result attach( - PosterId posterId, long expectedPosterVersion, PosterImageReference reference, Instant now); - - record Result(boolean attached, PosterImageReference replacedReference) { - - public Result { - if (!attached && replacedReference != null) { - throw new IllegalArgumentException("CAS loser cannot report a replaced reference"); - } - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyKeyEpochPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyKeyEpochPort.java deleted file mode 100644 index 8ee3165..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyKeyEpochPort.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -/** DB-coordinated active HMAC key version used by every publication-reservation pod. */ -public interface PosterImageIdempotencyKeyEpochPort { - - String activeVersionForReservation(); - - void activate(String expectedVersion, String newVersion); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigest.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigest.java deleted file mode 100644 index 8b2c157..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigest.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import java.util.Base64; - -/** Versioned non-reversible idempotency lookup digest. */ -public record PosterImageIdempotencyScopeDigest(String keyVersion, String base64UrlDigest) { - - public PosterImageIdempotencyScopeDigest { - if (keyVersion == null - || !keyVersion.matches("[a-z0-9][a-z0-9._-]{0,63}") - || base64UrlDigest == null - || base64UrlDigest.length() != 43) { - throw new IllegalArgumentException("poster image idempotency digest is invalid"); - } - try { - if (Base64.getUrlDecoder().decode(base64UrlDigest).length != 32) { - throw new IllegalArgumentException("poster image idempotency digest is invalid"); - } - } catch (IllegalArgumentException exception) { - throw new IllegalArgumentException("poster image idempotency digest is invalid", exception); - } - } - - @Override - public String toString() { - return "PosterImageIdempotencyScopeDigest[keyVersion=" + keyVersion + ", digest=]"; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigesterPort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigesterPort.java deleted file mode 100644 index 47ad1f5..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageIdempotencyScopeDigesterPort.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import java.util.List; - -/** HMAC boundary for the request-memory-only tenant/principal/client-key scope. */ -public interface PosterImageIdempotencyScopeDigesterPort { - - PosterImageIdempotencyScopeDigest digest( - String tenant, String principal, String useCase, String idempotencyKey); - - /** Digests under every retained version for overlap-first rotation lookup. */ - default List retainedDigests( - String tenant, String principal, String useCase, String idempotencyKey) { - return List.of(digest(tenant, principal, useCase, idempotencyKey)); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java deleted file mode 100644 index df4a729..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageOperationIdFactory.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import java.util.UUID; - -/** Identifier boundary for one stable durable publication reservation. */ -public interface PosterImageOperationIdFactory { - - AllocatedOperation allocate(); - - record AllocatedOperation(UUID intentId, ObjectOperationId operationId) { - - public AllocatedOperation { - if (intentId == null || operationId == null) { - throw new IllegalArgumentException("allocated publication operation is invalid"); - } - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprint.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprint.java deleted file mode 100644 index 8296b70..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationFingerprint.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.idempotency.RequestFingerprint; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; - -/** Frozen transport-independent semantic request fingerprint. */ -public final class PosterImagePublicationFingerprint { - - private static final byte[] SCHEMA = - "poster-image-publication-fingerprint-v1".getBytes(StandardCharsets.UTF_8); - - private PosterImagePublicationFingerprint() {} - - public static RequestFingerprint create( - PosterId posterId, - long expectedPosterVersion, - ObjectDestinationId destination, - ObjectMediaType mediaType, - ObjectContentIdentity identity) { - if (expectedPosterVersion < 0) { - throw new IllegalArgumentException("expected Poster version is invalid"); - } - ByteArrayOutputStream output = new ByteArrayOutputStream(); - field(output, SCHEMA); - field(output, posterId.value().getBytes(StandardCharsets.UTF_8)); - field(output, ByteBuffer.allocate(Long.BYTES).putLong(expectedPosterVersion).array()); - field(output, destination.value().getBytes(StandardCharsets.UTF_8)); - field(output, mediaType.canonicalText().getBytes(StandardCharsets.UTF_8)); - field(output, ByteBuffer.allocate(Long.BYTES).putLong(identity.exactLength()).array()); - field(output, identity.fullDigest().decodedValue()); - return RequestFingerprint.ofSha256(output.toByteArray()); - } - - private static void field(ByteArrayOutputStream output, byte[] value) { - output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array()); - output.writeBytes(value); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java deleted file mode 100644 index 7e1e98e..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationPolicy.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectDigestAlgorithm; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import java.util.Set; - -/** Sample business policy for accepted Poster image semantics. */ -public final class PosterImagePublicationPolicy { - - private static final Set ALLOWED_MEDIA_TYPES = - Set.of("image/jpeg", "image/png", "image/webp"); - - private final long maximumBytes; - - public PosterImagePublicationPolicy(long maximumBytes) { - if (maximumBytes < 1 || maximumBytes > 100L * 1024 * 1024) { - throw new IllegalArgumentException("Poster image maximum bytes is invalid"); - } - this.maximumBytes = maximumBytes; - } - - public void requireAllowed(ObjectContentIdentity identity, ObjectMediaType mediaType) { - if (identity.exactLength() < 1 || identity.exactLength() > maximumBytes) { - throw new IllegalArgumentException("Poster image logical size is not allowed"); - } - if (identity.fullDigest().algorithm() != ObjectDigestAlgorithm.SHA_256) { - throw new IllegalArgumentException("Poster image requires a full SHA-256 digest"); - } - if (!ALLOWED_MEDIA_TYPES.contains(mediaType.canonicalText())) { - throw new IllegalArgumentException("Poster image media type is not allowed"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java deleted file mode 100644 index 3778130..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservation.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; - -/** Stable bounded response committed in the generic idempotency reservation. */ -public record PosterImagePublicationReservation(PosterId posterId, ObjectOperationId operationId) { - - public PosterImagePublicationReservation { - if (posterId == null || operationId == null) { - throw new IllegalArgumentException("Poster image publication reservation is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java deleted file mode 100644 index 414d439..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationReservationCodec.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.idempotency.IdempotentResponseCodec; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; - -/** Bounded internal replay encoding for the stable 202 reservation. */ -public final class PosterImagePublicationReservationCodec - implements IdempotentResponseCodec { - - @Override - public String serialize(PosterImagePublicationReservation result) { - return result.posterId().value() + "|" + result.operationId().value(); - } - - @Override - public PosterImagePublicationReservation deserialize(String payload) { - if (payload == null || payload.length() > 160) { - throw new IllegalArgumentException("publication reservation replay payload is invalid"); - } - String[] parts = payload.split("\\|", -1); - if (parts.length != 2) { - throw new IllegalArgumentException("publication reservation replay payload is invalid"); - } - return new PosterImagePublicationReservation( - PosterId.of(parts[0]), ObjectOperationId.of(parts[1])); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationStatus.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationStatus.java deleted file mode 100644 index 70f9a50..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImagePublicationStatus.java +++ /dev/null @@ -1,38 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; - -/** Authorized locator-free status projection. */ -public record PosterImagePublicationStatus( - PosterId posterId, - ObjectOperationId operationId, - PosterImageUploadIntentState state, - PosterImageReference reference, - ObjectContentIdentity contentIdentity, - ObjectMediaType mediaType) { - - public PosterImagePublicationStatus { - if (posterId == null - || operationId == null - || state == null - || contentIdentity == null - || mediaType == null - || (state == PosterImageUploadIntentState.READY) != (reference != null)) { - throw new IllegalArgumentException("Poster image publication status is invalid"); - } - } - - public static PosterImagePublicationStatus from(PosterImageUploadIntent intent) { - return new PosterImagePublicationStatus( - intent.posterId(), - intent.operationKey().operationId(), - intent.state(), - intent.state() == PosterImageUploadIntentState.READY ? intent.publishedReference() : null, - intent.contentIdentity(), - intent.mediaType()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntent.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntent.java deleted file mode 100644 index e09bdc1..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntent.java +++ /dev/null @@ -1,213 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.time.Duration; -import java.time.Instant; -import java.util.Objects; -import java.util.UUID; - -/** Exact immutable publication version queued independently for logical retirement. */ -public record PosterImageRetirementIntent( - UUID id, - PosterId posterId, - ObjectOperationId operationId, - PosterImageReference reference, - ObjectVersionToken exactVersion, - PosterImageRetirementIntentState state, - String claimantDigest, - Long claimFence, - Instant claimExpiresAt, - Instant retainUntil, - Instant retryAt, - int attemptCount, - String lastErrorCode, - Instant createdAt, - Instant updatedAt, - long revision) { - - public PosterImageRetirementIntent { - Objects.requireNonNull(id, "id must be non-null"); - Objects.requireNonNull(operationId, "operationId must be non-null"); - Objects.requireNonNull(reference, "reference must be non-null"); - Objects.requireNonNull(exactVersion, "exactVersion must be non-null"); - Objects.requireNonNull(state, "state must be non-null"); - Objects.requireNonNull(retainUntil, "retainUntil must be non-null"); - Objects.requireNonNull(createdAt, "createdAt must be non-null"); - Objects.requireNonNull(updatedAt, "updatedAt must be non-null"); - if (attemptCount < 0 || revision < 0) { - throw new IllegalArgumentException("retirement counters are invalid"); - } - if ((claimantDigest == null) != (claimFence == null) - || (claimFence == null) != (claimExpiresAt == null)) { - throw new IllegalArgumentException("retirement claim evidence is incomplete"); - } - } - - public static PosterImageRetirementIntent pending( - UUID id, - PosterId posterId, - ObjectOperationId operationId, - PosterImageReference reference, - ObjectVersionToken exactVersion, - Instant retainUntil, - Instant now) { - return new PosterImageRetirementIntent( - id, - posterId, - operationId, - reference, - exactVersion, - PosterImageRetirementIntentState.PENDING, - null, - null, - null, - retainUntil, - null, - 0, - null, - now, - now, - 0); - } - - public PosterImageRetirementIntent claim( - long expectedRevision, String claimant, Instant now, Duration duration) { - if (revision != expectedRevision - || !claimableAt(now) - || retainUntil.isAfter(now) - || duration == null - || duration.isZero() - || duration.isNegative() - || claimant == null - || !claimant.matches("[0-9a-f]{64}")) { - throw new PosterImageUploadIntentConflictException( - "poster image retirement intent cannot be claimed"); - } - return copy( - PosterImageRetirementIntentState.IN_FLIGHT, - claimant, - claimFence == null ? 1 : claimFence + 1, - now.plus(duration), - retryAt, - attemptCount, - lastErrorCode, - now); - } - - public PosterImageRetirementIntent retired( - long expectedRevision, long expectedFence, Instant now) { - requireClaim(expectedRevision, expectedFence); - return copy( - PosterImageRetirementIntentState.RETIRED, - claimantDigest, - claimFence, - claimExpiresAt, - null, - attemptCount, - null, - now); - } - - public PosterImageRetirementIntent retry( - long expectedRevision, long expectedFence, String errorCode, Instant retryAt, Instant now) { - requireClaim(expectedRevision, expectedFence); - if (errorCode == null - || errorCode.isBlank() - || errorCode.length() > 64 - || retryAt == null - || retryAt.isBefore(now)) { - throw new PosterImageUploadIntentConflictException( - "poster image retirement retry evidence is invalid"); - } - return copy( - PosterImageRetirementIntentState.PENDING, - claimantDigest, - claimFence, - now, - retryAt, - attemptCount + 1, - errorCode, - now); - } - - public PosterImageRetirementIntent held( - long expectedRevision, long expectedFence, String evidenceCode, Instant now) { - return terminal( - PosterImageRetirementIntentState.HELD, evidenceCode, expectedRevision, expectedFence, now); - } - - public PosterImageRetirementIntent failed( - long expectedRevision, long expectedFence, String errorCode, Instant now) { - return terminal( - PosterImageRetirementIntentState.FAILED, errorCode, expectedRevision, expectedFence, now); - } - - private PosterImageRetirementIntent terminal( - PosterImageRetirementIntentState terminalState, - String evidenceCode, - long expectedRevision, - long expectedFence, - Instant now) { - requireClaim(expectedRevision, expectedFence); - if (evidenceCode == null || evidenceCode.isBlank() || evidenceCode.length() > 64) { - throw new PosterImageUploadIntentConflictException( - "poster image retirement terminal evidence is invalid"); - } - return copy( - terminalState, - claimantDigest, - claimFence, - claimExpiresAt, - null, - attemptCount, - evidenceCode, - now); - } - - private PosterImageRetirementIntent copy( - PosterImageRetirementIntentState next, - String nextClaimant, - Long nextFence, - Instant nextClaimExpiry, - Instant nextRetry, - int nextAttemptCount, - String nextError, - Instant now) { - return new PosterImageRetirementIntent( - id, - posterId, - operationId, - reference, - exactVersion, - next, - nextClaimant, - nextFence, - nextClaimExpiry, - retainUntil, - nextRetry, - nextAttemptCount, - nextError, - createdAt, - now, - revision + 1); - } - - private void requireClaim(long expectedRevision, long expectedFence) { - if (revision != expectedRevision - || state != PosterImageRetirementIntentState.IN_FLIGHT - || claimFence == null - || claimFence != expectedFence) { - throw new PosterImageUploadIntentConflictException("poster image retirement fence conflicts"); - } - } - - private boolean claimableAt(Instant now) { - return state == PosterImageRetirementIntentState.PENDING - || (state == PosterImageRetirementIntentState.IN_FLIGHT - && claimExpiresAt != null - && !claimExpiresAt.isAfter(now)); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentState.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentState.java deleted file mode 100644 index a8c5ca6..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentState.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -/** Separate exact-reference retirement work state. */ -public enum PosterImageRetirementIntentState { - PENDING, - IN_FLIGHT, - RETIRED, - HELD, - FAILED; - - public boolean terminal() { - return this == RETIRED || this == HELD || this == FAILED; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentStorePort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentStorePort.java deleted file mode 100644 index 704e81f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementIntentStorePort.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import java.time.Duration; -import java.time.Instant; -import java.util.Optional; - -/** Persistence boundary for exact-reference retirement work that survives Poster deletion. */ -public interface PosterImageRetirementIntentStorePort { - - PosterImageRetirementIntent createOrRead(PosterImageRetirementIntent proposed); - - Optional findByOperation(ObjectOperationId operation); - - PosterImageRetirementIntent compareAndSet( - long expectedRevision, PosterImageRetirementIntent replacement); - - Optional claimReady( - String claimantDigest, Instant now, Duration leaseDuration); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementResult.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementResult.java deleted file mode 100644 index c82687b..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageRetirementResult.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; - -/** Locator-free result from enqueueing or reconciling exact logical retirement work. */ -public record PosterImageRetirementResult( - ObjectOperationId operationId, PosterImageRetirementIntentState state, boolean workFound) { - - public PosterImageRetirementResult { - if (workFound && (operationId == null || state == null)) { - throw new IllegalArgumentException("retirement result evidence is incomplete"); - } - if (!workFound && (operationId != null || state != null)) { - throw new IllegalArgumentException("empty retirement result cannot contain evidence"); - } - } - - public static PosterImageRetirementResult none() { - return new PosterImageRetirementResult(null, null, false); - } - - public static PosterImageRetirementResult from(PosterImageRetirementIntent intent) { - return new PosterImageRetirementResult(intent.operationId(), intent.state(), true); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactory.java deleted file mode 100644 index b9c5865..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactory.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.idempotency.IdempotencyContext; -import dev.caskeleton.application.idempotency.IdempotencyScope; -import dev.caskeleton.application.idempotency.RequestFingerprint; -import java.util.Objects; - -/** Replaces every raw caller dimension before the generic idempotency store sees it. */ -public final class PosterImageSanitizedIdempotencyContextFactory { - - public static final String RESERVATION_USE_CASE = "poster-image-publication-reservation-v1"; - - private final PosterImageIdempotencyScopeDigesterPort digester; - - public PosterImageSanitizedIdempotencyContextFactory( - PosterImageIdempotencyScopeDigesterPort digester) { - this.digester = Objects.requireNonNull(digester, "digester must be non-null"); - } - - public SanitizedContext create( - String tenant, String principal, String idempotencyKey, RequestFingerprint fingerprint) { - PosterImageIdempotencyScopeDigest full = - digester.digest(tenant, principal, RESERVATION_USE_CASE, idempotencyKey); - PosterImageIdempotencyScopeDigest principalAlias = - digester.digest( - tenant, principal, "poster-image-principal-alias-v1", "principal-dimension"); - String tenantAlias = - tenant == null - ? null - : encoded( - digester.digest( - tenant, - "tenant-dimension", - "poster-image-tenant-alias-v1", - "tenant-dimension")); - IdempotencyScope sanitized = - IdempotencyScope.of( - tenantAlias, encoded(principalAlias), encoded(full), RESERVATION_USE_CASE); - return new SanitizedContext( - IdempotencyContext.of(sanitized, fingerprint), - full, - digester.retainedDigests(tenant, principal, RESERVATION_USE_CASE, idempotencyKey)); - } - - private static String encoded(PosterImageIdempotencyScopeDigest digest) { - return digest.keyVersion() + "." + digest.base64UrlDigest(); - } - - public record SanitizedContext( - IdempotencyContext generic, - PosterImageIdempotencyScopeDigest activeScope, - java.util.List retainedScopes) { - - public SanitizedContext { - Objects.requireNonNull(generic, "generic must be non-null"); - Objects.requireNonNull(activeScope, "activeScope must be non-null"); - retainedScopes = java.util.List.copyOf(retainedScopes); - if (retainedScopes.isEmpty()) { - throw new IllegalArgumentException("retained scopes must be non-empty"); - } - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntent.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntent.java deleted file mode 100644 index e244d9d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntent.java +++ /dev/null @@ -1,333 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.idempotency.RequestFingerprint; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.time.Instant; -import java.util.Objects; -import java.util.UUID; - -/** Durable application-owned publication intent and crash-recovery queue item. */ -public record PosterImageUploadIntent( - UUID id, - PosterId posterId, - ObjectOperationKey operationKey, - PosterImageIdempotencyScopeDigest idempotencyScope, - RequestFingerprint requestFingerprint, - long expectedPosterVersion, - ObjectContentIdentity contentIdentity, - ObjectMediaType mediaType, - PosterImageUploadIntentState state, - ObjectStageHandle stageHandle, - ObjectVersionToken exactVersion, - String handoffClaimantDigest, - Long handoffFence, - Instant handoffExpiresAt, - PosterImageReference publishedReference, - ObjectVersionToken publishedVersion, - PosterImageReference replacedReference, - Instant retryAt, - Instant expiresAt, - int attemptCount, - String lastErrorCode, - Instant createdAt, - Instant updatedAt, - long revision) { - - public PosterImageUploadIntent { - Objects.requireNonNull(id, "id must be non-null"); - Objects.requireNonNull(posterId, "posterId must be non-null"); - Objects.requireNonNull(operationKey, "operationKey must be non-null"); - Objects.requireNonNull(idempotencyScope, "idempotencyScope must be non-null"); - Objects.requireNonNull(requestFingerprint, "requestFingerprint must be non-null"); - Objects.requireNonNull(contentIdentity, "contentIdentity must be non-null"); - Objects.requireNonNull(mediaType, "mediaType must be non-null"); - Objects.requireNonNull(state, "state must be non-null"); - Objects.requireNonNull(expiresAt, "expiresAt must be non-null"); - Objects.requireNonNull(createdAt, "createdAt must be non-null"); - Objects.requireNonNull(updatedAt, "updatedAt must be non-null"); - if (expectedPosterVersion < 0 || attemptCount < 0 || revision < 0) { - throw new IllegalArgumentException("poster image upload counters are invalid"); - } - if ((stageHandle == null) != (exactVersion == null)) { - throw new IllegalArgumentException("stage handle and version must be present together"); - } - if ((handoffClaimantDigest == null) != (handoffFence == null) - || (handoffFence == null) != (handoffExpiresAt == null)) { - throw new IllegalArgumentException("handoff evidence is incomplete"); - } - if (handoffFence != null - && (handoffFence < 1 - || handoffClaimantDigest.length() != 64 - || !handoffClaimantDigest.matches("[0-9a-f]{64}"))) { - throw new IllegalArgumentException("handoff evidence is invalid"); - } - if ((publishedReference == null) != (publishedVersion == null)) { - throw new IllegalArgumentException( - "published reference and version must be present together"); - } - if ((state == PosterImageUploadIntentState.PENDING - || state == PosterImageUploadIntentState.ABORT_AUTHORIZED) - && (stageHandle == null || handoffFence == null)) { - throw new IllegalArgumentException("pending intent requires exact staged handoff evidence"); - } - if ((state == PosterImageUploadIntentState.READY - || state == PosterImageUploadIntentState.SUPERSEDED) - && publishedReference == null) { - throw new IllegalArgumentException("published terminal intent requires exact reference"); - } - if (state == PosterImageUploadIntentState.RESERVED - && (stageHandle != null || publishedReference != null)) { - throw new IllegalArgumentException( - "reserved intent cannot contain staged or published state"); - } - if (lastErrorCode != null - && (lastErrorCode.isBlank() - || lastErrorCode.length() > 64 - || !lastErrorCode.matches("[A-Z0-9_]+"))) { - throw new IllegalArgumentException("last error code is invalid"); - } - } - - public static PosterImageUploadIntent reserve( - UUID id, - PosterId posterId, - ObjectOperationKey operation, - PosterImageIdempotencyScopeDigest scope, - RequestFingerprint fingerprint, - long expectedPosterVersion, - ObjectContentIdentity contentIdentity, - ObjectMediaType mediaType, - Instant now, - Instant expiresAt) { - if (!expiresAt.isAfter(now)) { - throw new IllegalArgumentException("intent expiry must be in the future"); - } - return new PosterImageUploadIntent( - id, - posterId, - operation, - scope, - fingerprint, - expectedPosterVersion, - contentIdentity, - mediaType, - PosterImageUploadIntentState.RESERVED, - null, - null, - null, - null, - null, - null, - null, - null, - null, - expiresAt, - 0, - null, - now, - now, - 0); - } - - public PosterImageUploadIntent pending( - long expectedRevision, - ObjectStageHandle stage, - ObjectVersionToken version, - String claimantDigest, - long fence, - Instant claimExpiry, - Instant now) { - require(expectedRevision, PosterImageUploadIntentState.RESERVED); - return copy( - PosterImageUploadIntentState.PENDING, - stage, - version, - claimantDigest, - fence, - claimExpiry, - null, - null, - null, - null, - attemptCount, - null, - now); - } - - public PosterImageUploadIntent ready( - long expectedRevision, - PosterImageReference reference, - ObjectVersionToken version, - PosterImageReference replaced, - Instant now) { - require(expectedRevision, PosterImageUploadIntentState.PENDING); - return copy( - PosterImageUploadIntentState.READY, - stageHandle, - exactVersion, - handoffClaimantDigest, - handoffFence, - handoffExpiresAt, - reference, - version, - replaced, - null, - attemptCount, - null, - now); - } - - public PosterImageUploadIntent claim( - long expectedRevision, String claimantDigest, Instant now, java.time.Duration leaseDuration) { - require(expectedRevision, PosterImageUploadIntentState.PENDING); - if (leaseDuration == null || leaseDuration.isZero() || leaseDuration.isNegative()) { - throw new IllegalArgumentException("upload intent claim duration is invalid"); - } - if (handoffExpiresAt != null - && handoffExpiresAt.isAfter(now) - && !handoffClaimantDigest.equals(claimantDigest)) { - throw conflict(); - } - long nextFence = handoffFence == null ? 1 : handoffFence + 1; - return copy( - state, - stageHandle, - exactVersion, - claimantDigest, - nextFence, - now.plus(leaseDuration), - publishedReference, - publishedVersion, - replacedReference, - retryAt, - attemptCount, - lastErrorCode, - now); - } - - public PosterImageUploadIntent superseded( - long expectedRevision, - PosterImageReference losingReference, - ObjectVersionToken losingVersion, - Instant now) { - require(expectedRevision, PosterImageUploadIntentState.PENDING); - return copy( - PosterImageUploadIntentState.SUPERSEDED, - stageHandle, - exactVersion, - handoffClaimantDigest, - handoffFence, - handoffExpiresAt, - losingReference, - losingVersion, - replacedReference, - null, - attemptCount, - null, - now); - } - - public PosterImageUploadIntent authorizeAbort(long expectedRevision, Instant now) { - if (revision != expectedRevision - || (state != PosterImageUploadIntentState.PENDING - && state != PosterImageUploadIntentState.ABORT_AUTHORIZED) - || (state == PosterImageUploadIntentState.PENDING - && handoffExpiresAt != null - && handoffExpiresAt.isAfter(now))) { - throw conflict(); - } - if (state == PosterImageUploadIntentState.ABORT_AUTHORIZED) { - return this; - } - return copy( - PosterImageUploadIntentState.ABORT_AUTHORIZED, - stageHandle, - exactVersion, - handoffClaimantDigest, - handoffFence, - handoffExpiresAt, - null, - null, - replacedReference, - null, - attemptCount, - null, - now); - } - - public PosterImageUploadIntent aborted(long expectedRevision, Instant now) { - require(expectedRevision, PosterImageUploadIntentState.ABORT_AUTHORIZED); - return copy( - PosterImageUploadIntentState.ABORTED, - stageHandle, - exactVersion, - handoffClaimantDigest, - handoffFence, - handoffExpiresAt, - null, - null, - replacedReference, - null, - attemptCount, - null, - now); - } - - private PosterImageUploadIntent copy( - PosterImageUploadIntentState next, - ObjectStageHandle nextStage, - ObjectVersionToken nextExactVersion, - String nextClaimant, - Long nextFence, - Instant nextClaimExpiry, - PosterImageReference nextPublished, - ObjectVersionToken nextPublishedVersion, - PosterImageReference nextReplaced, - Instant nextRetry, - int nextAttempts, - String nextError, - Instant now) { - return new PosterImageUploadIntent( - id, - posterId, - operationKey, - idempotencyScope, - requestFingerprint, - expectedPosterVersion, - contentIdentity, - mediaType, - next, - nextStage, - nextExactVersion, - nextClaimant, - nextFence, - nextClaimExpiry, - nextPublished, - nextPublishedVersion, - nextReplaced, - nextRetry, - expiresAt, - nextAttempts, - nextError, - createdAt, - now, - revision + 1); - } - - private void require(long expectedRevision, PosterImageUploadIntentState expectedState) { - if (revision != expectedRevision || state != expectedState) { - throw conflict(); - } - } - - private PosterImageUploadIntentConflictException conflict() { - return new PosterImageUploadIntentConflictException( - "poster image upload intent revision or state conflicts"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentClaim.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentClaim.java deleted file mode 100644 index 8f0fb80..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentClaim.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import java.time.Instant; - -/** Bounded worker lease returned from an upload-intent CAS claim. */ -public record PosterImageUploadIntentClaim( - PosterImageUploadIntent intent, long fence, Instant expiresAt) { - - public PosterImageUploadIntentClaim { - if (intent == null || fence < 1 || expiresAt == null) { - throw new IllegalArgumentException("poster image upload claim is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentConflictException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentConflictException.java deleted file mode 100644 index 8709eee..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentConflictException.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -/** Optimistic revision/state/fence conflict for a durable Poster image operation. */ -public final class PosterImageUploadIntentConflictException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - public PosterImageUploadIntentConflictException(String message) { - super(message); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentState.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentState.java deleted file mode 100644 index 77e0f7d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentState.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -/** Durable publication queue state; READY/SUPERSEDED/ABORTED/FAILED are terminal. */ -public enum PosterImageUploadIntentState { - RESERVED, - PENDING, - READY, - SUPERSEDED, - ABORT_AUTHORIZED, - ABORTED, - FAILED; - - public boolean terminal() { - return this == READY || this == SUPERSEDED || this == ABORTED || this == FAILED; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentStorePort.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentStorePort.java deleted file mode 100644 index d74d767..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageUploadIntentStorePort.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.time.Duration; -import java.time.Instant; -import java.util.Collection; -import java.util.Optional; - -/** Persistence boundary for the canonical durable publication queue. */ -public interface PosterImageUploadIntentStorePort { - - PosterImageUploadIntent createOrRead(PosterImageUploadIntent proposed); - - Optional findByOperation(ObjectOperationKey operation); - - Optional findByAnyScopeDigest( - Collection retainedDigests); - - Optional findReadyByPublishedReference(PosterImageReference reference); - - PosterImageUploadIntent compareAndSet(long expectedRevision, PosterImageUploadIntent replacement); - - Optional claimPending( - String claimantDigest, Instant now, Duration leaseDuration); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java deleted file mode 100644 index b98ee97..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterImagePublicationStatusQuery.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.query; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.query.Query; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; - -public record GetPosterImagePublicationStatusQuery(PosterId posterId, ObjectOperationId operationId) - implements Query { - - public GetPosterImagePublicationStatusQuery { - if (posterId == null || operationId == null) { - throw new IllegalArgumentException("Poster image status query is invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterQuery.java deleted file mode 100644 index 4f40115..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetPosterQuery.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.query; - -import dev.caskeleton.application.query.Query; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import java.util.Objects; - -public record GetPosterQuery(PosterId id) implements Query { - - public GetPosterQuery { - Objects.requireNonNull(id, "id"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetRepoStatsQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetRepoStatsQuery.java deleted file mode 100644 index 6dbb1d7..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetRepoStatsQuery.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.query; - -import dev.caskeleton.application.query.Query; -import java.util.Objects; - -public record GetRepoStatsQuery(String repoUrl) implements Query { - - // Self-validating input model: a null repoUrl fails fast at construction. See README. - public GetRepoStatsQuery { - Objects.requireNonNull(repoUrl, "repoUrl"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetWorkLogQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetWorkLogQuery.java deleted file mode 100644 index 61c3bff..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/GetWorkLogQuery.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.query; - -import dev.caskeleton.application.query.Query; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import java.util.Objects; - -public record GetWorkLogQuery(WorkLogId id) implements Query { - - // Self-validating input model: a lookup with no id fails fast at construction. See README. - public GetWorkLogQuery { - Objects.requireNonNull(id, "id"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListPostersQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListPostersQuery.java deleted file mode 100644 index 80bf570..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListPostersQuery.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.query; - -import dev.caskeleton.application.query.Query; -import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; - -/** - * List query carrying validated pagination, an optional allow-listed sort field, and an optional - * flat status filter. {@code sortField}/{@code status} are {@code null} when not supplied. Mirrors - * {@code ListWorkLogsQuery}. - */ -public record ListPostersQuery( - int page, int size, PosterSortField sortField, boolean ascending, PosterStatus status) - implements Query {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListRecentWorkLogSummariesQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListRecentWorkLogSummariesQuery.java deleted file mode 100644 index 51b9b85..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListRecentWorkLogSummariesQuery.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.query; - -import dev.caskeleton.application.query.Query; - -/** - * Read intent for the recent-WorkLog summary projection. - * - * @param limit maximum number of summaries to return; the use case / adapter clamps non-positive - * values to a safe minimum. - */ -public record ListRecentWorkLogSummariesQuery(int limit) implements Query {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListWorkLogsQuery.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListWorkLogsQuery.java deleted file mode 100644 index 496b805..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/ListWorkLogsQuery.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.query; - -import dev.caskeleton.application.query.Query; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; - -/** - * List query carrying validated pagination, an optional allow-listed sort field, and an optional - * flat equality filter. {@code sortField}/{@code category} are {@code null} when not supplied. - */ -public record ListWorkLogsQuery( - int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) - implements Query {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/WorkLogSummary.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/WorkLogSummary.java deleted file mode 100644 index 733576d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/query/WorkLogSummary.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.query; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import java.time.LocalDate; - -/** - * Application-layer read projection for a WorkLog list view (CQRS-lite "query bypass"): carries - * only the columns a list view needs, not the full aggregate. A projection DTO is an application - * type — never a domain aggregate, JPA entity, or web DTO (referencing a domain value type like - * {@link WorkCategory} is fine). The {@code id} is the canonical 36-char UUID string, converted - * from the storage-native {@code UUID} inside the adapter. See README. - */ -public record WorkLogSummary( - String id, String title, WorkCategory category, LocalDate periodStart, LocalDate periodEnd) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/BatchCreateWorkLogsUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/BatchCreateWorkLogsUseCase.java deleted file mode 100644 index 25d9b9a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/BatchCreateWorkLogsUseCase.java +++ /dev/null @@ -1,68 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.BatchCreateWorkLogsCommand; -import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; -import dev.caskeleton.sample.portfolio.domain.worklog.Period; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogOwner; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import java.util.ArrayList; -import java.util.List; -import org.springframework.stereotype.Service; - -/** - * Atomic sync batch create: every item runs inside a single {@code tx.inWrite} unit of work, so any - * item failure rolls the whole batch back (one 4xx, never partial success). See README. - */ -@Service -@RequiresPermission("worklog:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.NOT_IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, - bulkWrite = true) -public class BatchCreateWorkLogsUseCase - implements CommandUseCase> { - - private final WorkLogRepository repository; - private final WorkLogIdFactory idFactory; - private final TransactionPort tx; - - public BatchCreateWorkLogsUseCase( - WorkLogRepository repository, WorkLogIdFactory idFactory, TransactionPort tx) { - this.repository = repository; - this.idFactory = idFactory; - this.tx = tx; - } - - @Override - public List handle(BatchCreateWorkLogsCommand cmd) { - return tx.inWrite( - () -> { - List created = new ArrayList<>(cmd.items().size()); - for (CreateWorkLogCommand item : cmd.items()) { - created.add( - repository.save( - WorkLog.create( - idFactory.newId(), - WorkLogOwner.of(item.owner()), - item.title(), - item.category(), - item.summary(), - item.content(), - item.techStack(), - item.links(), - new Period(item.periodStart(), item.periodEnd())))); - } - return created; - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java deleted file mode 100644 index 50b6f14..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogUseCase.java +++ /dev/null @@ -1,121 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.observability.CorrelationIdPort; -import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.event.WorkLogReservedIntegrationEvent; -import dev.caskeleton.sample.portfolio.application.event.WorkLogReservedIntegrationEventMapper; -import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.Period; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogOwner; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; -import java.time.Clock; -import java.time.Instant; -import org.springframework.stereotype.Service; - -/** - * Creates a new {@link WorkLog} and appends a {@link WorkLogReserved} outbox event in the same - * write transaction, demonstrating the dual-write prohibition. {@code eventId} is the UUIDv7 from - * {@link OutboxEventIdFactory} and doubles as the idempotency key; {@code correlationId} comes - * through {@link CorrelationIdPort}. See README. - */ -@Service -@RequiresPermission("worklog:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.NOT_IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public class CreateWorkLogUseCase implements CommandUseCase { - - private final WorkLogRepository repository; - private final WorkLogIdFactory idFactory; - private final OutboxEventIdFactory eventIdFactory; - private final OutboxAppendPort outboxAppendPort; - private final CorrelationIdPort correlationIdPort; - private final Clock clock; - private final TransactionPort tx; - - public CreateWorkLogUseCase( - WorkLogRepository repository, - WorkLogIdFactory idFactory, - OutboxEventIdFactory eventIdFactory, - OutboxAppendPort outboxAppendPort, - CorrelationIdPort correlationIdPort, - Clock clock, - TransactionPort tx) { - this.repository = repository; - this.idFactory = idFactory; - this.eventIdFactory = eventIdFactory; - this.outboxAppendPort = outboxAppendPort; - this.correlationIdPort = correlationIdPort; - this.clock = clock; - this.tx = tx; - } - - @Override - public WorkLog handle(CreateWorkLogCommand cmd) { - // The server mints the id through the domain WorkLogIdFactory port (see README). - return tx.inWrite( - () -> { - WorkLog saved = - repository.save( - WorkLog.create( - idFactory.newId(), - WorkLogOwner.of(cmd.owner()), - cmd.title(), - cmd.category(), - cmd.summary(), - cmd.content(), - cmd.techStack(), - cmd.links(), - new Period(cmd.periodStart(), cmd.periodEnd()))); - - // Append the outbox event in the SAME transaction as the save (no dual-write). - appendReservedEvent(saved); - - return saved; - }); - } - - /** - * Builds the {@link WorkLogReserved} domain event, maps it to an integration event, serialises - * it, and appends it to the outbox — all within the caller's transaction. - */ - private void appendReservedEvent(WorkLog saved) { - WorkLogReserved domainEvent = - new WorkLogReserved(saved.id(), saved.category(), saved.period().start()); - - WorkLogReservedIntegrationEvent integrationEvent = - WorkLogReservedIntegrationEventMapper.toIntegrationEvent(domainEvent); - - String payload = WorkLogReservedIntegrationEventMapper.toJson(integrationEvent); - - String eventId = eventIdFactory.newEventId(); - Instant occurredAt = Instant.now(clock); - - String correlationId = - correlationIdPort.currentCorrelationId().filter(value -> !value.isBlank()).orElse(eventId); - - outboxAppendPort.append( - new NewOutboxEvent( - eventId, - WorkLogReservedIntegrationEventMapper.EVENT_TYPE, - saved.id().value(), - payload, - occurredAt, - correlationId, - eventId // idempotencyKey = eventId - )); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/DeleteWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/DeleteWorkLogUseCase.java deleted file mode 100644 index d515d64..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/DeleteWorkLogUseCase.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import org.springframework.stereotype.Service; - -// Deleting a work log is the destructive, admin-tier operation, gated by `worklog:close` -// (granted only to `admin`, never `user`). See README. -@Service -@RequiresPermission("worklog:close") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public class DeleteWorkLogUseCase implements CommandUseCase { - - private final WorkLogRepository repository; - private final TransactionPort tx; - - public DeleteWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { - this.repository = repository; - this.tx = tx; - } - - @Override - public Void handle(DeleteWorkLogCommand cmd) { - return tx.inWrite( - () -> { - if (!repository.existsById(cmd.id())) { - throw new WorkLogNotFoundException(cmd.id()); - } - repository.deleteById(cmd.id()); - return null; - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCase.java deleted file mode 100644 index 61adac8..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCase.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.usecase.QueryUseCase; -import dev.caskeleton.sample.portfolio.application.port.RepoStatsPort; -import dev.caskeleton.sample.portfolio.application.query.GetRepoStatsQuery; -import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; -import org.springframework.stereotype.Service; - -@Service -@UseCaseCapability( - transactionMode = TransactionMode.READ_ONLY, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.NONE, - externalOutboundAllowed = true) -public class GetRepoStatsUseCase implements QueryUseCase { - - private final RepoStatsPort repoStatsPort; - - public GetRepoStatsUseCase(RepoStatsPort repoStatsPort) { - this.repoStatsPort = repoStatsPort; - } - - @Override - public RepoStats handle(GetRepoStatsQuery q) { - return repoStatsPort.fetch(q.repoUrl()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetWorkLogUseCase.java deleted file mode 100644 index b99052f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/GetWorkLogUseCase.java +++ /dev/null @@ -1,35 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.QueryUseCase; -import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; -import dev.caskeleton.sample.portfolio.application.query.GetWorkLogQuery; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import org.springframework.stereotype.Service; - -@Service -@UseCaseCapability( - transactionMode = TransactionMode.READ_ONLY, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public class GetWorkLogUseCase implements QueryUseCase { - - private final WorkLogRepository repository; - private final TransactionPort tx; - - public GetWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { - this.repository = repository; - this.tx = tx; - } - - @Override - public WorkLog handle(GetWorkLogQuery q) { - return tx.inRead( - () -> repository.findById(q.id()).orElseThrow(() -> new WorkLogNotFoundException(q.id()))); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCase.java deleted file mode 100644 index 4336212..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCase.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.QueryUseCase; -import dev.caskeleton.sample.portfolio.application.port.WorkLogSummaryQueryPort; -import dev.caskeleton.sample.portfolio.application.query.ListRecentWorkLogSummariesQuery; -import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; -import java.util.List; -import org.springframework.stereotype.Service; - -/** - * Read-model (projection) query use case. Even a projection read goes through a {@link - * QueryUseCase} bean, runs inside {@link TransactionPort#inRead}, and is still {@link - * RepositoryAccess#READ_REPOSITORY} ("projection vs aggregate" is the return shape, orthogonal to - * the repository access level). See README. - */ -@Service -@UseCaseCapability( - transactionMode = TransactionMode.READ_ONLY, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public class ListRecentWorkLogSummariesUseCase - implements QueryUseCase> { - - private final WorkLogSummaryQueryPort queryPort; - private final TransactionPort tx; - - public ListRecentWorkLogSummariesUseCase(WorkLogSummaryQueryPort queryPort, TransactionPort tx) { - this.queryPort = queryPort; - this.tx = tx; - } - - @Override - public List handle(ListRecentWorkLogSummariesQuery q) { - return tx.inRead(() -> queryPort.findRecentSummaries(q.limit())); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListWorkLogsUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListWorkLogsUseCase.java deleted file mode 100644 index e9e4039..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/ListWorkLogsUseCase.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.QueryUseCase; -import dev.caskeleton.sample.portfolio.application.query.ListWorkLogsQuery; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import org.springframework.stereotype.Service; - -@Service -@UseCaseCapability( - transactionMode = TransactionMode.READ_ONLY, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public class ListWorkLogsUseCase implements QueryUseCase { - - private final WorkLogRepository repository; - private final TransactionPort tx; - - public ListWorkLogsUseCase(WorkLogRepository repository, TransactionPort tx) { - this.repository = repository; - this.tx = tx; - } - - @Override - public WorkLogPage handle(ListWorkLogsQuery q) { - return tx.inRead( - () -> repository.findPage(q.page(), q.size(), q.sortField(), q.ascending(), q.category())); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/UpdateWorkLogUseCase.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/UpdateWorkLogUseCase.java deleted file mode 100644 index 2a9c18a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/application/worklog/UpdateWorkLogUseCase.java +++ /dev/null @@ -1,76 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import dev.caskeleton.application.capability.Idempotency; -import dev.caskeleton.application.capability.RepositoryAccess; -import dev.caskeleton.application.capability.UseCaseCapability; -import dev.caskeleton.application.security.RequiresPermission; -import dev.caskeleton.application.transaction.TransactionMode; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.application.usecase.CommandUseCase; -import dev.caskeleton.sample.portfolio.application.command.UpdateWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; -import org.springframework.stereotype.Service; - -@Service -@RequiresPermission("worklog:write") -@UseCaseCapability( - transactionMode = TransactionMode.WRITE, - idempotency = Idempotency.IDEMPOTENT, - repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) -public class UpdateWorkLogUseCase implements CommandUseCase { - - private final WorkLogRepository repository; - private final TransactionPort tx; - - public UpdateWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { - this.repository = repository; - this.tx = tx; - } - - @Override - public WorkLog handle(UpdateWorkLogCommand cmd) { - return tx.inWrite( - () -> { - WorkLog w = - repository - .findById(cmd.id()) - .orElseThrow(() -> new WorkLogNotFoundException(cmd.id())); - if (cmd.title().hasValue()) { - w.rename(cmd.title().value()); - } - if (cmd.summary().hasValue()) { - w.updateSummary(cmd.summary().value()); - } else if (cmd.summary().isExplicitNull()) { - w.updateSummary(null); - } - if (cmd.content().hasValue()) { - w.updateContent(cmd.content().value()); - } else if (cmd.content().isExplicitNull()) { - w.updateContent(null); - } - if (cmd.category().hasValue()) { - w.recategorize(cmd.category().value()); - } - if (cmd.status().hasValue()) { - switch (cmd.status().value()) { - case IN_PROGRESS -> w.startProgress(); - case CLOSED -> w.close(); - case OPEN -> { - if (w.status() != WorkLogStatus.OPEN) { - throw new WorkLogInvariantException( - WorkLogInvariantException.Reason.INVALID_STATUS_TRANSITION); - } - } - default -> - throw new IllegalStateException( - "unexpected work log status: " + cmd.status().value()); - } - } - return repository.save(w); - }); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/context/SampleDomainContextConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/context/SampleDomainContextConfig.java deleted file mode 100644 index 90f9894..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/context/SampleDomainContextConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.context; - -import dev.caskeleton.shared.concurrency.DomainContextPropagator; -import dev.caskeleton.shared.concurrency.ThreadLocalDomainContextPropagator; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Sample-local substitute for {@code app-bootstrap}'s {@code DomainContextConfig}: provides the - * {@link DomainContextPropagator} bean (default {@code THREAD_LOCAL} strategy) that the scanned - * {@code DomainContextAuditContextPort} requires. Disposable. See README. - */ -@Configuration -public class SampleDomainContextConfig { - - @Bean - public DomainContextPropagator domainContextPropagator() { - return new ThreadLocalDomainContextPropagator(); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencyConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencyConfig.java deleted file mode 100644 index 869990d..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencyConfig.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.idempotency; - -import dev.caskeleton.application.idempotency.IdempotencyExecutor; -import dev.caskeleton.application.idempotency.IdempotencyStorePort; -import java.time.Clock; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.annotation.EnableScheduling; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code IdempotencyConfig}: supplies the {@code - * Clock}, assembles the {@code IdempotencyExecutor}, and enables scheduling for the idempotency - * reaper. Disposable. See README. - */ -@Configuration -@EnableScheduling -@EnableConfigurationProperties(SampleIdempotencySettings.class) -public class SampleIdempotencyConfig { - - @Bean - public Clock systemClock() { - return Clock.systemUTC(); - } - - @Bean - public IdempotencyExecutor idempotencyExecutor( - IdempotencyStorePort store, Clock clock, SampleIdempotencySettings properties) { - return new IdempotencyExecutor(store, clock, properties.ttl()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencySettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencySettings.java deleted file mode 100644 index 08e215f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/idempotency/SampleIdempotencySettings.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.idempotency; - -import java.time.Duration; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code IdempotencySettings}, bound from {@code - * ca-skeleton.idempotency.*}. TTL must be positive and ≤ 72h; reaper interval defaults to 10m. - * Disposable. See README. - */ -@Validated -@ConfigurationProperties(prefix = "ca-skeleton.idempotency") -public record SampleIdempotencySettings(Duration ttl, Duration reaperInterval) { - - private static final Duration MAX_TTL = Duration.ofDays(3); - - public SampleIdempotencySettings { - if (ttl == null) { - ttl = Duration.ofHours(24); - } - if (ttl.isZero() || ttl.isNegative()) { - throw new IllegalArgumentException( - "APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be positive, was " + ttl); - } - if (ttl.compareTo(MAX_TTL) > 0) { - throw new IllegalArgumentException( - "APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be <= 72h (D6), was " + ttl); - } - if (reaperInterval == null || reaperInterval.isZero() || reaperInterval.isNegative()) { - reaperInterval = Duration.ofMinutes(10); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/logging/SamplePseudonymizationConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/logging/SamplePseudonymizationConfig.java deleted file mode 100644 index 3fe9484..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/logging/SamplePseudonymizationConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.logging; - -import dev.caskeleton.adapter.outbound.identifier.HmacUserPrincipalPseudonymizer; -import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; -import dev.caskeleton.sample.portfolio.bootstrap.settings.SamplePrivacySettings; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code PseudonymizationConfig}: supplies the {@code - * UserPrincipalPseudonymizerPort} that adapter-web's {@code RequestLoggingFilter} injects. - * Disposable. See README. - */ -@Configuration -@EnableConfigurationProperties(SamplePrivacySettings.class) -public class SamplePseudonymizationConfig { - - @Bean - @ConditionalOnMissingBean - UserPrincipalPseudonymizerPort userPrincipalPseudonymizer(SamplePrivacySettings privacySettings) { - return new HmacUserPrincipalPseudonymizer(privacySettings.saltBytes()); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/management/SampleManagementSecurityConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/management/SampleManagementSecurityConfig.java deleted file mode 100644 index 46fdbed..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/management/SampleManagementSecurityConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.management; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; - -/** - * Sample-local actuator chain. The standalone sample is intentionally public so the demo can be - * inspected without a configured IdP; production actuator posture remains in app-bootstrap. - */ -@Configuration -@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) -public class SampleManagementSecurityConfig { - - /** Actuator security chain ordered at {@code 0} to run BEFORE the main app chain. */ - @Bean - @Order(0) - public SecurityFilterChain sampleActuatorSecurityFilterChain(HttpSecurity http) throws Exception { - http.securityMatcher(EndpointRequest.toAnyEndpoint()) - .csrf(csrf -> csrf.disable()) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - return http.build(); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/metrics/SampleMetricsContractConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/metrics/SampleMetricsContractConfig.java deleted file mode 100644 index 696718c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/metrics/SampleMetricsContractConfig.java +++ /dev/null @@ -1,87 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.metrics; - -import dev.caskeleton.shared.metrics.ForbiddenMetricTags; -import io.micrometer.core.instrument.Meter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.config.MeterFilter; -import io.micrometer.core.instrument.config.MeterFilterReply; -import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; -import jakarta.annotation.PostConstruct; -import java.time.Duration; -import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.annotation.Configuration; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code MetricsContractConfig}: installs the - * cardinality and SLO-distribution {@code MeterFilter}s onto the {@link MeterRegistry} before any - * meters register. Disposable. See README. - */ -@Configuration -public class SampleMetricsContractConfig { - - private static final Logger log = LoggerFactory.getLogger(SampleMetricsContractConfig.class); - - private final ObjectProvider meterRegistryProvider; - - public SampleMetricsContractConfig(ObjectProvider meterRegistryProvider) { - this.meterRegistryProvider = meterRegistryProvider; - } - - @PostConstruct - void installFilters() { - MeterRegistry registry = meterRegistryProvider.getIfAvailable(); - if (registry == null) { - log.debug("sample metrics-contract: no MeterRegistry available; MeterFilter install skipped"); - return; - } - registry.config().meterFilter(new CardinalityFilter()).meterFilter(new DistributionFilter()); - } - - /** Deny meters with high-cardinality tag keys. */ - private static final class CardinalityFilter implements MeterFilter { - @Override - public MeterFilterReply accept(Meter.Id id) { - for (Tag tag : id.getTags()) { - if (ForbiddenMetricTags.isForbidden(tag.getKey())) { - return MeterFilterReply.DENY; - } - } - return MeterFilterReply.NEUTRAL; - } - } - - /** SLO-driven histogram config for owned timer names. */ - private static final class DistributionFilter implements MeterFilter { - - private static final Set SLO_DRIVEN_TIMERS = - Set.of( - "http.server.requests", - "http.server.requests.latency", - "dependency.client.requests", - "db.query.duration", - "jvm.gc.pause"); - - @Override - public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) { - if (!SLO_DRIVEN_TIMERS.contains(id.getName())) { - return config; - } - return DistributionStatisticConfig.builder() - .percentilesHistogram(true) - .percentiles(0.5, 0.9, 0.95, 0.99) - .serviceLevelObjectives( - (double) Duration.ofMillis(100).toNanos(), - (double) Duration.ofMillis(500).toNanos(), - (double) Duration.ofSeconds(1).toNanos(), - (double) Duration.ofSeconds(5).toNanos()) - .minimumExpectedValue((double) Duration.ofMillis(1).toNanos()) - .maximumExpectedValue((double) Duration.ofSeconds(10).toNanos()) - .build() - .merge(config); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java deleted file mode 100644 index d52864f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import dev.caskeleton.application.storage.ObjectStoragePort; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; -import dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** Explicit legacy-use-case assembly; endpoint controllers are mode-conditional components. */ -@Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(PosterImageApiSettings.class) -public class PosterImageApiConfig { - - @Bean - @ConditionalOnProperty(prefix = "app.poster-image.api", name = "mode", havingValue = "legacy") - // This bean alone owns the active whole-byte compatibility wiring during migration. - @SuppressWarnings("removal") - public UploadPosterImageUseCase legacyUploadPosterImageUseCase( - PosterRepository repository, - ObjectStoragePort objectStorage, - PosterEventPublisher events, - TransactionPort transactions) { - return new UploadPosterImageUseCase(repository, objectStorage, events, transactions); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiSettings.java deleted file mode 100644 index 47e2134..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageApiSettings.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** Mutually exclusive image endpoint mode. */ -@ConfigurationProperties(prefix = "app.poster-image.api") -public record PosterImageApiSettings(Mode mode) { - - public PosterImageApiSettings { - mode = mode == null ? Mode.DISABLED : mode; - } - - public enum Mode { - DISABLED, - LEGACY, - PUBLICATION - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfig.java deleted file mode 100644 index 8759692..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfig.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import dev.caskeleton.sample.portfolio.adapter.outbound.identifier.HmacPosterImageIdempotencyScopeDigester; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyKeyEpochPort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigesterPort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.Path; -import java.nio.file.attribute.PosixFilePermission; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Properties; -import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** Explicit external-file key-ring composition for the canonical publication profile. */ -@Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(PosterImageIdempotencyKeyRingSettings.class) -public class PosterImageIdempotencyConfig { - - private static final long MAXIMUM_KEY_RING_BYTES = 16 * 1024; - - @Bean - @ConditionalOnProperty( - prefix = "app.poster-image.idempotency", - name = "enabled", - havingValue = "true") - public PosterImageIdempotencyScopeDigesterPort posterImageIdempotencyScopeDigester( - PosterImageIdempotencyKeyRingSettings settings, PosterImageIdempotencyKeyEpochPort epoch) { - Map keys = load(settings.keyRingPath()); - if (!keys.containsKey(settings.bootstrapActiveKeyVersion())) { - throw new IllegalArgumentException("bootstrap-active key is absent from key ring"); - } - return new HmacPosterImageIdempotencyScopeDigester(epoch::activeVersionForReservation, keys); - } - - @Bean - @ConditionalOnProperty( - prefix = "app.poster-image.idempotency", - name = "enabled", - havingValue = "true") - public PosterImageSanitizedIdempotencyContextFactory - posterImageSanitizedIdempotencyContextFactory( - PosterImageIdempotencyScopeDigesterPort digester) { - return new PosterImageSanitizedIdempotencyContextFactory(digester); - } - - private static Map load(Path configured) { - Path path = configured.toAbsolutePath().normalize(); - try { - if (Files.isSymbolicLink(path) - || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) - || Files.size(path) > MAXIMUM_KEY_RING_BYTES) { - throw invalid(); - } - rejectBroadPermissions(path); - Properties properties = new Properties(); - try (InputStream input = Files.newInputStream(path)) { - properties.load(input); - } - Map keys = new LinkedHashMap<>(); - for (String name : properties.stringPropertyNames()) { - if (!name.startsWith("key.")) { - throw invalid(); - } - String version = name.substring("key.".length()); - if (!version.matches("[a-z0-9][a-z0-9._-]{0,63}")) { - throw invalid(); - } - byte[] decoded = Base64.getDecoder().decode(properties.getProperty(name)); - if (decoded.length < 32 || decoded.length > 128) { - throw invalid(); - } - keys.put(version, decoded); - } - if (keys.isEmpty()) { - throw invalid(); - } - return Map.copyOf(keys); - } catch (IOException | IllegalArgumentException exception) { - throw new IllegalStateException( - "Poster image idempotency key ring could not be loaded safely", exception); - } - } - - private static void rejectBroadPermissions(Path path) throws IOException { - try { - Set permissions = Files.getPosixFilePermissions(path); - if (permissions.contains(PosixFilePermission.GROUP_WRITE) - || permissions.contains(PosixFilePermission.OTHERS_WRITE) - || permissions.contains(PosixFilePermission.OTHERS_READ) - || permissions.contains(PosixFilePermission.OTHERS_EXECUTE)) { - throw invalid(); - } - } catch (UnsupportedOperationException ignored) { - // Non-POSIX platforms still receive no-follow, regular-file, and size checks. - } - } - - private static IllegalArgumentException invalid() { - return new IllegalArgumentException("Poster image idempotency key ring is invalid"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyKeyRingSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyKeyRingSettings.java deleted file mode 100644 index 5fdb29a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyKeyRingSettings.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import java.nio.file.Path; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** Disabled-by-default external key-ring location; key material is never an inline property. */ -@ConfigurationProperties(prefix = "app.poster-image.idempotency") -public record PosterImageIdempotencyKeyRingSettings( - boolean enabled, Path keyRingPath, String bootstrapActiveKeyVersion) { - - public PosterImageIdempotencyKeyRingSettings { - if (enabled - && (keyRingPath == null - || bootstrapActiveKeyVersion == null - || !bootstrapActiveKeyVersion.matches("[a-z0-9][a-z0-9._-]{0,63}"))) { - throw new IllegalArgumentException( - "enabled Poster image idempotency requires key-ring path and active version"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfig.java deleted file mode 100644 index 95524c5..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageObjectStorageConfig.java +++ /dev/null @@ -1,156 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import dev.caskeleton.application.idempotency.IdempotencyExecutor; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; -import dev.caskeleton.application.objectstorage.port.ObjectRetirementPort; -import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.application.event.PosterImagePublicationEventPublisher; -import dev.caskeleton.sample.portfolio.application.poster.AbortPosterImageUploadUseCase; -import dev.caskeleton.sample.portfolio.application.poster.GetPosterImagePublicationStatusUseCase; -import dev.caskeleton.sample.portfolio.application.poster.PublishPosterImageUseCase; -import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageRetirementUseCase; -import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageUploadUseCase; -import dev.caskeleton.sample.portfolio.application.poster.RetirePosterImageUseCase; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageAttachmentCompareAndSetPort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationPolicy; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import java.time.Clock; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** Explicit canonical sample composition; absent from disabled and legacy-only profiles. */ -@Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(PosterImagePublicationSettings.class) -@ConditionalOnProperty( - prefix = "app.poster-image.publication", - name = "enabled", - havingValue = "true") -public class PosterImageObjectStorageConfig { - - @Bean - public PosterImagePublicationPolicy posterImagePublicationPolicy( - PosterImagePublicationSettings settings) { - return new PosterImagePublicationPolicy(settings.maximumBytes()); - } - - @Bean - public PublishPosterImageUseCase publishPosterImageUseCase( - PosterImageUploadIntentStorePort intents, - PosterImageSanitizedIdempotencyContextFactory sanitized, - PosterImageOperationIdFactory ids, - IdempotencyExecutor idempotency, - TransactionPort transactions, - StagedObjectPublicationPort publications, - ObjectPublicationHandoffPort handoffs, - PosterImagePublicationEventPublisher events, - PosterImagePublicationPolicy policy, - PosterImagePublicationSettings settings, - Clock clock) { - return new PublishPosterImageUseCase( - intents, - sanitized, - ids, - idempotency, - transactions, - publications, - handoffs, - events, - policy, - ObjectDestinationId.of(settings.destination()), - ObjectOperationEpoch.of(settings.operationEpoch()), - settings.intentTtl(), - settings.handoffLease(), - clock); - } - - @Bean - public GetPosterImagePublicationStatusUseCase getPosterImagePublicationStatusUseCase( - PosterImageUploadIntentStorePort intents, - TransactionPort transactions, - PosterImagePublicationSettings settings) { - return new GetPosterImagePublicationStatusUseCase( - intents, - transactions, - ObjectDestinationId.of(settings.destination()), - ObjectOperationEpoch.of(settings.operationEpoch())); - } - - @Bean - public ReconcilePosterImageUploadUseCase reconcilePosterImageUploadUseCase( - PosterImageUploadIntentStorePort uploads, - PosterImageRetirementIntentStorePort retirements, - PosterImageAttachmentCompareAndSetPort attachments, - PosterImageOperationIdFactory ids, - PosterImagePublicationEventPublisher events, - TransactionPort transactions, - StagedObjectPublicationPort publications, - ObjectPublicationHandoffPort handoffs, - PosterImagePublicationSettings settings, - Clock clock) { - return new ReconcilePosterImageUploadUseCase( - uploads, - retirements, - attachments, - ids, - events, - transactions, - publications, - handoffs, - ObjectDestinationId.of(settings.destination()), - ObjectOperationEpoch.of(settings.operationEpoch()), - settings.retirementRetention(), - clock); - } - - @Bean - public AbortPosterImageUploadUseCase abortPosterImageUploadUseCase( - PosterImageUploadIntentStorePort intents, - TransactionPort transactions, - ObjectPublicationHandoffPort handoffs, - StagedObjectPublicationPort publications, - PosterImagePublicationSettings settings, - Clock clock) { - return new AbortPosterImageUploadUseCase( - intents, - transactions, - handoffs, - publications, - ObjectDestinationId.of(settings.destination()), - ObjectOperationEpoch.of(settings.operationEpoch()), - clock); - } - - @Bean - public RetirePosterImageUseCase retirePosterImageUseCase( - PosterImageRetirementIntentStorePort retirements, - PosterImageUploadIntentStorePort uploads, - PosterImageOperationIdFactory ids, - TransactionPort transactions, - Clock clock) { - return new RetirePosterImageUseCase(retirements, uploads, ids, transactions, clock); - } - - @Bean - public ReconcilePosterImageRetirementUseCase reconcilePosterImageRetirementUseCase( - PosterImageRetirementIntentStorePort retirements, - TransactionPort transactions, - ObjectRetirementPort objects, - PosterImagePublicationSettings settings, - Clock clock) { - return new ReconcilePosterImageRetirementUseCase( - retirements, - transactions, - objects, - ObjectDestinationId.of(settings.destination()), - ObjectOperationEpoch.of(settings.operationEpoch()), - clock); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImagePublicationSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImagePublicationSettings.java deleted file mode 100644 index ec99f77..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImagePublicationSettings.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import java.time.Duration; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** Bounded canonical publication and reconciliation settings. */ -@ConfigurationProperties(prefix = "app.poster-image.publication") -public record PosterImagePublicationSettings( - boolean enabled, - String destination, - String operationEpoch, - long maximumBytes, - Duration intentTtl, - Duration handoffLease, - Duration retirementRetention) { - - public PosterImagePublicationSettings { - destination = destination == null || destination.isBlank() ? "poster" : destination; - operationEpoch = - operationEpoch == null || operationEpoch.isBlank() ? "poster-image-v1" : operationEpoch; - maximumBytes = maximumBytes == 0 ? 10L * 1024 * 1024 : maximumBytes; - intentTtl = intentTtl == null ? Duration.ofHours(24) : intentTtl; - handoffLease = handoffLease == null ? Duration.ofMinutes(2) : handoffLease; - retirementRetention = retirementRetention == null ? Duration.ofDays(7) : retirementRetention; - if (maximumBytes < 1 - || intentTtl.isZero() - || intentTtl.isNegative() - || intentTtl.compareTo(Duration.ofHours(72)) > 0 - || handoffLease.isZero() - || handoffLease.isNegative() - || handoffLease.compareTo(Duration.ofHours(1)) > 0 - || retirementRetention.isNegative()) { - throw new IllegalArgumentException("Poster image publication settings are invalid"); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfig.java deleted file mode 100644 index 3de564b..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfig.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageRetirementUseCase; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** Explicit worker composition; disabled mode creates no job, thread, or provider call. */ -@Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(PosterImageRetirementSettings.class) -@ConditionalOnProperty( - prefix = "app.poster-image.retirement", - name = "enabled", - havingValue = "true") -public class PosterImageRetirementConfig { - - @Bean - public PosterImageRetirementJob posterImageRetirementJob( - ReconcilePosterImageRetirementUseCase useCase, PosterImageRetirementSettings settings) { - return new PosterImageRetirementJob(useCase, settings); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementJob.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementJob.java deleted file mode 100644 index e23d00a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementJob.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.outbound.CallBudget; -import dev.caskeleton.sample.portfolio.application.command.ReconcilePosterImageRetirementCommand; -import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageRetirementUseCase; -import org.springframework.scheduling.annotation.Scheduled; - -/** Bounded scheduler adapter; all DB/object behavior remains behind the application use case. */ -public final class PosterImageRetirementJob { - - private final ReconcilePosterImageRetirementUseCase useCase; - private final PosterImageRetirementSettings settings; - - public PosterImageRetirementJob( - ReconcilePosterImageRetirementUseCase useCase, PosterImageRetirementSettings settings) { - this.useCase = useCase; - this.settings = settings; - } - - @Scheduled( - fixedDelayString = "${app.poster-image.retirement.fixed-delay:30s}", - initialDelayString = "${app.poster-image.retirement.initial-delay:30s}") - public int runOnce() { - int handled = 0; - for (int index = 0; index < settings.batchSize(); index++) { - var result = - useCase.handle( - new ReconcilePosterImageRetirementCommand( - settings.claimantDigest(), - settings.claimLease(), - settings.retryBackoff(), - CallBudget.fromNow(settings.operationTimeout()), - CancellationView.never())); - if (!result.workFound()) { - break; - } - handled++; - } - return handled; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementSettings.java deleted file mode 100644 index 2fd3716..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementSettings.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import java.time.Duration; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** Bounded, disabled-by-default logical retirement worker settings. */ -@ConfigurationProperties(prefix = "app.poster-image.retirement") -public record PosterImageRetirementSettings( - boolean enabled, - String claimantDigest, - int batchSize, - Duration claimLease, - Duration retryBackoff, - Duration operationTimeout, - Duration fixedDelay, - Duration initialDelay, - Duration shutdownGrace) { - - public PosterImageRetirementSettings { - claimantDigest = claimantDigest == null ? "" : claimantDigest; - batchSize = batchSize == 0 ? 25 : batchSize; - claimLease = claimLease == null ? Duration.ofMinutes(2) : claimLease; - retryBackoff = retryBackoff == null ? Duration.ofMinutes(1) : retryBackoff; - operationTimeout = operationTimeout == null ? Duration.ofSeconds(30) : operationTimeout; - fixedDelay = fixedDelay == null ? Duration.ofSeconds(30) : fixedDelay; - initialDelay = initialDelay == null ? Duration.ofSeconds(30) : initialDelay; - shutdownGrace = shutdownGrace == null ? Duration.ofSeconds(30) : shutdownGrace; - if (enabled && !claimantDigest.matches("[0-9a-f]{64}")) { - throw new IllegalArgumentException( - "enabled Poster image retirement requires a claimant digest"); - } - if (batchSize < 1 - || batchSize > 500 - || invalid(claimLease, Duration.ofHours(1)) - || retryBackoff.isNegative() - || retryBackoff.compareTo(Duration.ofDays(1)) > 0 - || invalid(operationTimeout, Duration.ofMinutes(5)) - || invalid(fixedDelay, Duration.ofHours(1)) - || initialDelay.isNegative() - || initialDelay.compareTo(Duration.ofHours(1)) > 0 - || invalid(shutdownGrace, Duration.ofMinutes(5))) { - throw new IllegalArgumentException("Poster image retirement settings are invalid"); - } - } - - private static boolean invalid(Duration value, Duration maximum) { - return value.isZero() || value.isNegative() || value.compareTo(maximum) > 0; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java deleted file mode 100644 index dac991c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/persistence/SamplePostgreSqlPersistenceConfig.java +++ /dev/null @@ -1,62 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.persistence; - -import dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig; -import dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping; -import dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository; -import dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository; -import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlIdempotencyClaimRepository; -import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlLocalTimeoutConfigurer; -import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository; -import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping; -import dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer; -import jakarta.persistence.EntityManager; -import org.springframework.boot.flyway.autoconfigure.FlywayConfigurationCustomizer; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; -import org.springframework.jdbc.core.JdbcOperations; - -/** - * Sample-local replacement for {@code PostgreSqlPersistenceConfig}. Exists to break a - * Flyway/EntityManager initialization cycle by registering the {@code - * FlywayConfigurationCustomizer} as a {@code static @Bean}. Disposable. See README. - */ -@Configuration(proxyBeanMethods = false) -@Import(PersistenceJpaConfig.class) -public class SamplePostgreSqlPersistenceConfig { - - /** - * {@code static @Bean} (called without instantiating the owning class, so no - * {@code @PersistenceContext} injection during Flyway init). Sets both migration locations: - * production {@code db/migration/postgresql} + sample {@code db/sample-migration}. {@code - * locations(...)} replaces rather than appends, so this — not {@code spring.flyway.locations} in - * application.yml — is the effective source of truth. See README. - */ - @Bean - public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() { - return configuration -> - configuration.locations( - "classpath:db/migration/postgresql", "classpath:db/sample-migration"); - } - - @Bean - public OutboxClaimRepository outboxClaimRepository(EntityManager entityManager) { - return new PostgreSqlOutboxClaimRepository(entityManager); - } - - @Bean - public IdempotencyClaimRepository idempotencyClaimRepository(EntityManager entityManager) { - return new PostgreSqlIdempotencyClaimRepository(entityManager); - } - - @Bean - public SqlStateErrorMapping postgreSqlSqlStateErrorMapping() { - return new PostgreSqlSqlStateErrorMapping(); - } - - @Bean - public TransactionLocalTimeoutConfigurer transactionLocalTimeoutConfigurer( - JdbcOperations jdbcOperations) { - return new PostgreSqlLocalTimeoutConfigurer(jdbcOperations); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/scan/TestEnclosedConfigurationFilter.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/scan/TestEnclosedConfigurationFilter.java deleted file mode 100644 index 6060871..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/scan/TestEnclosedConfigurationFilter.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.scan; - -import org.springframework.core.type.classreading.MetadataReader; -import org.springframework.core.type.classreading.MetadataReaderFactory; -import org.springframework.core.type.filter.TypeFilter; - -/** - * Component-scan exclusion filter that rejects {@code @Configuration} classes nested inside test - * classes (binary name contains {@code $} and the enclosing class name ends with {@code Test}), - * preventing {@code BeanDefinitionOverrideException} during the full-context boot. Disposable. See - * README. - */ -public class TestEnclosedConfigurationFilter implements TypeFilter { - - @Override - public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) { - String className = metadataReader.getClassMetadata().getClassName(); - int dollarIndex = className.lastIndexOf('$'); - if (dollarIndex < 0) { - return false; // top-level class — not a nested class - } - String enclosingName = className.substring(0, dollarIndex); - // Strip any further nesting to reach the top-level class name segment. - int lastDot = enclosingName.lastIndexOf('.'); - String simpleEnclosingName = - (lastDot >= 0) ? enclosingName.substring(lastDot + 1) : enclosingName; - return simpleEnclosingName.endsWith("Test"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfig.java deleted file mode 100644 index e15af51..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.security; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; - -/** - * Sample-only HTTP security posture. The WorkLog sample has no login surface, so every demo URL is - * reachable without an Authorization header while production modules continue to use adapter-web's - * JWT and method-security configuration. - */ -@Configuration -@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) -public class SamplePublicAccessSecurityConfig { - - @Bean - @Order(-100) - public SecurityFilterChain samplePublicAccessSecurityFilterChain(HttpSecurity http) - throws Exception { - http.securityMatcher("/**") - .csrf(csrf -> csrf.disable()) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - return http.build(); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/settings/SamplePrivacySettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/settings/SamplePrivacySettings.java deleted file mode 100644 index f9d419c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/settings/SamplePrivacySettings.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.settings; - -import java.nio.charset.StandardCharsets; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code PrivacySettings}, bound from {@code - * ca-skeleton.privacy.*}. Supplies the HMAC salt; a blank salt falls back to a dev sentinel so - * local/test runs start. Disposable. See README. - */ -@ConfigurationProperties(prefix = "ca-skeleton.privacy") -public record SamplePrivacySettings(String pseudonymizationSalt) { - - private static final Logger log = LoggerFactory.getLogger(SamplePrivacySettings.class); - - static final String DEV_SENTINEL_SALT = "__LOCAL_DEV_pseudonymization_salt"; - - public SamplePrivacySettings { - if (pseudonymizationSalt == null || pseudonymizationSalt.isBlank()) { - log.warn( - "APP_PRIVACY_PSEUDONYMIZATION_SALT is blank; using a dev sentinel salt. " - + "Set a real secret-manager value before production."); - pseudonymizationSalt = DEV_SENTINEL_SALT; - } - } - - /** The salt as UTF-8 bytes for the HMAC key. */ - public byte[] saltBytes() { - return pseudonymizationSalt.getBytes(StandardCharsets.UTF_8); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleMicrometerSpanErrorRecorder.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleMicrometerSpanErrorRecorder.java deleted file mode 100644 index 9cb62c8..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleMicrometerSpanErrorRecorder.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.tracing; - -import dev.caskeleton.shared.tracing.SpanErrorRecorder; -import io.micrometer.tracing.Span; -import io.micrometer.tracing.Tracer; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code MicrometerSpanErrorRecorder}: records an - * exception on the current span with an {@code error.code} tag. Disposable. See README. - */ -public final class SampleMicrometerSpanErrorRecorder implements SpanErrorRecorder { - - private final Tracer tracer; - - public SampleMicrometerSpanErrorRecorder(Tracer tracer) { - this.tracer = tracer; - } - - @Override - public void recordException(Throwable error, String errorCode) { - if (error == null) { - return; - } - Span span = tracer.currentSpan(); - if (span == null) { - return; - } - span.error(error); - if (errorCode != null) { - span.tag("error.code", errorCode); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingConfig.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingConfig.java deleted file mode 100644 index a8e16ba..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingConfig.java +++ /dev/null @@ -1,83 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.tracing; - -import dev.caskeleton.shared.tracing.SpanErrorRecorder; -import io.micrometer.core.instrument.Gauge; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.tracing.Tracer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code TracingConfig}: registers the {@link - * SpanErrorRecorder} bean and the sampling-rate gauge. Disposable. See README. - */ -@Configuration -@EnableConfigurationProperties(SampleTracingSettings.class) -public class SampleTracingConfig { - - private static final Logger log = LoggerFactory.getLogger(SampleTracingConfig.class); - - private final SampleTracingSettings properties; - private final Environment environment; - private final ObjectProvider meterRegistryProvider; - - public SampleTracingConfig( - SampleTracingSettings properties, - Environment environment, - ObjectProvider meterRegistryProvider) { - this.properties = properties; - this.environment = environment; - this.meterRegistryProvider = meterRegistryProvider; - } - - /** Registers the {@code tracing.sampling.rate} gauge on startup. */ - @Bean - public SampleTracingSamplingRateGaugeRegistrar sampleTracingSamplingRateGaugeRegistrar() { - String activeProfile = resolveActiveProfile(); - double effectiveRate = - new SampleTracingSampleRateResolver().resolve(activeProfile, properties.sampleRate()); - registerGauge(activeProfile, effectiveRate); - return new SampleTracingSamplingRateGaugeRegistrar(activeProfile, effectiveRate); - } - - /** Real {@link SpanErrorRecorder} bean when a Tracer is present; NOOP otherwise. */ - @Bean - @ConditionalOnMissingBean(SpanErrorRecorder.class) - SpanErrorRecorder sampleSpanErrorRecorder(ObjectProvider tracerProvider) { - Tracer tracer = tracerProvider.getIfAvailable(); - if (tracer == null) { - return SpanErrorRecorder.NOOP; - } - return new SampleMicrometerSpanErrorRecorder(tracer); - } - - private String resolveActiveProfile() { - String[] profiles = environment.getActiveProfiles(); - return (profiles != null && profiles.length > 0) ? profiles[0] : "local"; - } - - private void registerGauge(String activeProfile, double effectiveRate) { - MeterRegistry registry = meterRegistryProvider.getIfAvailable(); - if (registry == null) { - return; - } - try { - Gauge.builder("tracing.sampling.rate", () -> effectiveRate) - .tag("profile", activeProfile != null ? activeProfile : "unknown") - .description("Effective distributed-tracing sample rate for the active profile") - .register(registry); - } catch (RuntimeException ex) { - log.warn("sample tracing: failed to register gauge tracing.sampling.rate", ex); - } - } - - /** Value holder exposing the active profile and resolved rate for test inspection. */ - public record SampleTracingSamplingRateGaugeRegistrar( - String activeProfile, double effectiveSampleRate) {} -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSampleRateResolver.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSampleRateResolver.java deleted file mode 100644 index be612fa..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSampleRateResolver.java +++ /dev/null @@ -1,51 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.tracing; - -import java.util.Locale; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code TracingSampleRateResolver}: applies the - * per-profile default sample rate (prod=0.01, staging=0.10, dev/local=1.0). Disposable. See README. - */ -final class SampleTracingSampleRateResolver { - - /** - * Returns the per-profile default sample rate (D6 SSOT). - * - * @param profile Spring active profile name; {@code null}/blank treated as "anything else" - * @return effective default rate for the profile - */ - static double defaultRateForProfile(String profile) { - if (profile == null) { - return 1.0; - } - return switch (profile.trim().toLowerCase(Locale.ROOT)) { - case "prod" -> 0.01; - case "staging" -> 0.10; - case "dev" -> 1.0; - case "local" -> 1.0; - default -> 1.0; - }; - } - - /** - * Resolves the effective sample rate, letting a configured override win over the per-profile - * default. - * - * @param profile active Spring profile - * @param configuredRate raw string from {@code APP_TRACING_SAMPLE_RATE}; may be blank - * @return effective sample rate in [0.0, 1.0] - */ - double resolve(String profile, String configuredRate) { - if (configuredRate != null && !configuredRate.isBlank()) { - try { - double v = Double.parseDouble(configuredRate.trim()); - if (v >= 0.0 && v <= 1.0) { - return v; - } - } catch (NumberFormatException ignored) { - // fall through to profile default - } - } - return defaultRateForProfile(profile); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSamplingEnvironmentPostProcessor.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSamplingEnvironmentPostProcessor.java deleted file mode 100644 index a31e9eb..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSamplingEnvironmentPostProcessor.java +++ /dev/null @@ -1,38 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.tracing; - -import java.util.Map; -import org.springframework.boot.EnvironmentPostProcessor; -import org.springframework.boot.SpringApplication; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.MapPropertySource; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code TracingSamplingEnvironmentPostProcessor}: - * bridges the resolved sample rate to {@code management.tracing.sampling.probability} so the OTel - * sampler matches the gauge. Disposable. See README. - */ -public class SampleTracingSamplingEnvironmentPostProcessor implements EnvironmentPostProcessor { - - private static final String NATIVE_KEY = "management.tracing.sampling.probability"; - private static final String CA_SAMPLE_RATE_KEY = "ca-skeleton.tracing.sample-rate"; - - private final SampleTracingSampleRateResolver resolver = new SampleTracingSampleRateResolver(); - - @Override - public void postProcessEnvironment( - ConfigurableEnvironment environment, SpringApplication application) { - // User-explicit setting takes priority. - if (environment.containsProperty(NATIVE_KEY)) { - return; - } - String[] profiles = environment.getActiveProfiles(); - String profile = (profiles != null && profiles.length > 0) ? profiles[0] : "local"; - String configured = environment.getProperty(CA_SAMPLE_RATE_KEY); - double rate = resolver.resolve(profile, configured); - environment - .getPropertySources() - .addLast( - new MapPropertySource( - "sampleTracingSamplingBridge", Map.of(NATIVE_KEY, String.valueOf(rate)))); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSettings.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSettings.java deleted file mode 100644 index f176358..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/tracing/SampleTracingSettings.java +++ /dev/null @@ -1,76 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.tracing; - -import java.net.URI; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -/** - * Sample-local copy of {@code app-bootstrap}'s {@code TracingSettings}, bound from {@code - * ca-skeleton.tracing.*}. Blank sampleRate = per-profile default; non-blank must be a float in - * [0.0, 1.0]; blank otlpEndpoint = exporter off, non-blank must be a valid URL. Disposable. See - * README. - */ -@ConfigurationProperties(prefix = "ca-skeleton.tracing") -@Validated -public record SampleTracingSettings(boolean enabled, String sampleRate, Exporter exporter) { - - /** Nested properties for the exporter seam. */ - public record Exporter(String otlpEndpoint) { - - public Exporter { - if (otlpEndpoint == null) { - otlpEndpoint = ""; - } - } - - /** {@code true} when an exporter endpoint is configured (non-blank). */ - public boolean isConfigured() { - return otlpEndpoint != null && !otlpEndpoint.isBlank(); - } - } - - /** Compact canonical constructor — validation: float_between_0_and_1 + url_or_empty. */ - public SampleTracingSettings { - if (sampleRate == null) { - sampleRate = ""; - } - if (exporter == null) { - exporter = new Exporter(""); - } - // blank = "per-profile default" signal - if (!sampleRate.isBlank()) { - validateSampleRate(sampleRate); - } - validateOtlpEndpoint(exporter.otlpEndpoint()); - } - - private static void validateSampleRate(String raw) { - try { - double v = Double.parseDouble(raw.trim()); - if (v < 0.0 || v > 1.0) { - throw new IllegalStateException( - "APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0]; got: " + raw); - } - } catch (NumberFormatException ex) { - throw new IllegalStateException( - "APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0]; got: " + raw, ex); - } - } - - private static void validateOtlpEndpoint(String endpoint) { - if (endpoint == null || endpoint.isBlank()) { - return; - } - try { - URI uri = URI.create(endpoint.trim()); - String scheme = uri.getScheme(); - if (scheme == null || scheme.isBlank()) { - throw new IllegalStateException( - "OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL with a scheme; got: " + endpoint); - } - } catch (IllegalArgumentException ex) { - throw new IllegalStateException( - "OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL or empty; got: " + endpoint, ex); - } - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/LegacyPosterImageAttachment.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/LegacyPosterImageAttachment.java deleted file mode 100644 index a819a0b..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/LegacyPosterImageAttachment.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -/** Existing raw-key attachment retained only for the approved dual-read window. */ -public record LegacyPosterImageAttachment(String key) implements PosterImageAttachment { - - public LegacyPosterImageAttachment { - if (key == null || key.isBlank() || key.length() > 512) { - throw new IllegalArgumentException("legacy poster image key is invalid"); - } - } - - @Override - public String compatibilityValue() { - return key; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java deleted file mode 100644 index 9e14d56..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/Poster.java +++ /dev/null @@ -1,185 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import dev.caskeleton.domain.stereotype.AggregateRoot; -import java.util.Objects; - -/** - * A promotional poster. Aggregate root for the Poster aggregate; framework-neutral POJO (no - * Spring/JPA). State changes only through intention-revealing methods (no public setters), and - * invariant violations surface as {@link PosterInvariantException}. - * - *

    The distinctive rule: a poster cannot be {@link #publish() published} until an image has been - * {@link #attachImage(String) attached} — this is what ties the object-storage upload to a domain - * invariant. The id is server-assigned (minted by a {@code PosterIdFactory} in the use case, never - * by the domain); {@code version} is {@code null} until the store persists it. Mirrors {@code - * WorkLog}. - */ -@AggregateRoot -public class Poster { - - private final PosterId id; - private String title; - private String caption; - private PosterImageAttachment imageAttachment; - private PosterStatus status; - - /** Optimistic-lock version; {@code null} until the store persists it. */ - private final Long version; - - private Poster( - PosterId id, - String title, - String caption, - PosterImageAttachment imageAttachment, - PosterStatus status, - Long version) { - this.id = Objects.requireNonNull(id, "id is required"); - this.title = requireValidTitle(title); - this.caption = caption == null ? "" : caption; - this.imageAttachment = imageAttachment; - this.status = Objects.requireNonNull(status, "status is required"); - this.version = version; - } - - /** - * Builds a new poster with a server-assigned identifier. Starts in {@link PosterStatus#DRAFT} - * with no image; version is {@code null} until the store persists it. - */ - public static Poster create(PosterId id, String title, String caption) { - return new Poster(id, title, caption, null, PosterStatus.DRAFT, null); - } - - /** Rehydrates a persisted poster (no invariant re-derivation beyond construction checks). */ - public static Poster rehydrate( - PosterId id, - String title, - String caption, - String imageKey, - PosterStatus status, - Long version) { - return new Poster( - id, - title, - caption, - imageKey == null ? null : new LegacyPosterImageAttachment(imageKey), - status, - version); - } - - /** - * Additive V8 dual read. Exactly one compatibility column may be populated; pending work never - * enters the aggregate. - */ - public static Poster rehydrate( - PosterId id, - String title, - String caption, - String imageKey, - String imageReference, - PosterStatus status, - Long version) { - if (imageKey != null && imageReference != null) { - throw new IllegalArgumentException( - "poster cannot contain both legacy key and published reference"); - } - PosterImageAttachment attachment = - imageReference != null - ? new PublishedPosterImageAttachment(PosterImageReference.of(imageReference)) - : imageKey == null ? null : new LegacyPosterImageAttachment(imageKey); - return new Poster(id, title, caption, attachment, status, version); - } - - public void rename(String newTitle) { - this.title = requireValidTitle(newTitle); - } - - public void changeCaption(String newCaption) { - this.caption = newCaption == null ? "" : newCaption; - } - - /** Records the object-storage key of the uploaded image. */ - public void attachImage(String key) { - this.imageAttachment = new LegacyPosterImageAttachment(key); - } - - /** Canonical V8 writer: only a publication-finalized opaque reference can be attached. */ - public void attachPublishedImage(PosterImageReference reference) { - this.imageAttachment = new PublishedPosterImageAttachment(reference); - } - - /** {@code DRAFT → PUBLISHED}. Requires an attached image (the domain invariant). */ - public void publish() { - if (imageAttachment == null) { - throw new PosterInvariantException(PosterInvariantException.Reason.IMAGE_REQUIRED); - } - transitionTo(PosterStatus.PUBLISHED); - } - - public void archive() { - transitionTo(PosterStatus.ARCHIVED); - } - - private void transitionTo(PosterStatus next) { - Objects.requireNonNull(next, "status is required"); - if (status == next) { - return; - } - if (!status.canTransitionTo(next)) { - throw new PosterInvariantException(PosterInvariantException.Reason.INVALID_STATUS_TRANSITION); - } - this.status = next; - } - - /** - * Enforces the title invariant on the sole construction/mutation path. A null or blank title is - * client-facing validation, so it surfaces as a domain invariant ({@link - * PosterInvariantException} with {@code TITLE_BLANK}) — which the web layer maps to a 400 — - * rather than an {@code NullPointerException} (which would read as a server bug / 500). - * Server-controlled refs (id, status) still use {@link Objects#requireNonNull} because a null - * there is a genuine programming error, not client input. - */ - private static String requireValidTitle(String title) { - if (title == null || title.isBlank()) { - throw new PosterInvariantException(PosterInvariantException.Reason.TITLE_BLANK); - } - return title; - } - - public PosterId id() { - return id; - } - - public String title() { - return title; - } - - public String caption() { - return caption; - } - - public String imageKey() { - return imageAttachment == null ? null : imageAttachment.compatibilityValue(); - } - - public String imageReference() { - return imageAttachment instanceof PublishedPosterImageAttachment published - ? published.reference().value() - : null; - } - - public PosterImageAttachment imageAttachment() { - return imageAttachment; - } - - public boolean hasImage() { - return imageAttachment != null; - } - - public PosterStatus status() { - return status; - } - - public Long version() { - return version; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterArchived.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterArchived.java deleted file mode 100644 index d2dc600..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterArchived.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import dev.caskeleton.domain.stereotype.DomainEvent; -import java.util.Objects; - -/** Domain event: a {@link Poster} was archived. Transport-free; carries domain data only. */ -@DomainEvent -public record PosterArchived(PosterId posterId) { - - public PosterArchived { - Objects.requireNonNull(posterId, "posterId is required"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterCreated.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterCreated.java deleted file mode 100644 index 011eb60..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterCreated.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import dev.caskeleton.domain.stereotype.DomainEvent; -import java.util.Objects; - -/** - * Domain event: a {@link Poster} was created. Transport-free — carries domain data only, no - * broker/wire/HTTP type. The application boundary turns it into an integration event. Mirrors - * {@code WorkLogReserved}. - */ -@DomainEvent -public record PosterCreated(PosterId posterId, String title) { - - public PosterCreated { - Objects.requireNonNull(posterId, "posterId is required"); - Objects.requireNonNull(title, "title is required"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterDeleted.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterDeleted.java deleted file mode 100644 index ee4725e..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterDeleted.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import dev.caskeleton.domain.stereotype.DomainEvent; -import java.util.Objects; - -/** Domain event: a {@link Poster} was deleted. Transport-free; carries domain data only. */ -@DomainEvent -public record PosterDeleted(PosterId posterId) { - - public PosterDeleted { - Objects.requireNonNull(posterId, "posterId is required"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterId.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterId.java deleted file mode 100644 index c45b818..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterId.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import dev.caskeleton.domain.identifier.ResourceId; -import dev.caskeleton.domain.stereotype.ValueObject; -import java.util.regex.Pattern; - -/** - * Resource identifier for a {@link Poster}: a 36-character canonical UUID (RFC 9562 UUIDv7). Pure - * value object — validates the canonical form only; case normalisation happens at the web boundary. - * Mirrors {@code WorkLogId}. - */ -@ValueObject -public record PosterId(String value) implements ResourceId { - - /** Canonical UUID form (RFC 9562): 8-4-4-4-12 hexadecimal groups. */ - private static final Pattern PATTERN = - Pattern.compile( - "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); - - public PosterId { - if (value == null || !PATTERN.matcher(value).matches()) { - throw new IllegalArgumentException("Invalid PosterId format: " + value); - } - } - - public static PosterId of(String value) { - return new PosterId(value); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdFactory.java deleted file mode 100644 index 4548f52..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdFactory.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import dev.caskeleton.domain.identifier.IdFactory; - -/** - * Domain port that mints {@link PosterId}s. Implemented by an infrastructure adapter; injected into - * the create use case. Mirrors {@code WorkLogIdFactory}. - */ -public interface PosterIdFactory extends IdFactory {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttached.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttached.java deleted file mode 100644 index d4a4c01..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttached.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import dev.caskeleton.domain.stereotype.DomainEvent; -import java.util.Objects; - -/** - * Domain event: an image was attached to a {@link Poster}. Transport-free; carries domain data - * only. - */ -@DomainEvent -public record PosterImageAttached(PosterId posterId, String imageKey) { - - public PosterImageAttached { - Objects.requireNonNull(posterId, "posterId is required"); - Objects.requireNonNull(imageKey, "imageKey is required"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttachment.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttachment.java deleted file mode 100644 index 3134d5c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageAttachment.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -/** Explicit compatibility-window image attachment; pending work never enters the aggregate. */ -public sealed interface PosterImageAttachment - permits LegacyPosterImageAttachment, PublishedPosterImageAttachment { - - String compatibilityValue(); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReference.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReference.java deleted file mode 100644 index 9c668cb..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReference.java +++ /dev/null @@ -1,46 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; - -/** Bounded opaque object-storage published reference with its canonical checksum. */ -public record PosterImageReference(String value) { - - public PosterImageReference { - if (value == null || value.length() > 96) { - throw invalid(); - } - String[] parts = value.split("\\.", -1); - if (parts.length != 4 - || !"osr1".equals(parts[0]) - || !parts[1].matches("[0-9a-hjkmnp-tv-z]{12}") - || !parts[2].matches("[0-9a-hjkmnp-tv-z]{26}") - || !parts[3].matches("[0-9a-f]{10}")) { - throw invalid(); - } - String payload = parts[0] + "." + parts[1] + "." + parts[2]; - if (!sha256Hex(payload).substring(0, 10).equals(parts[3])) { - throw invalid(); - } - } - - public static PosterImageReference of(String value) { - return new PosterImageReference(value); - } - - private static String sha256Hex(String value) { - try { - return HexFormat.of() - .formatHex( - MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 must be available", exception); - } - } - - private static IllegalArgumentException invalid() { - return new IllegalArgumentException("poster image reference is invalid"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterInvariantException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterInvariantException.java deleted file mode 100644 index 371f827..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterInvariantException.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -/** - * Raised when a {@link Poster} aggregate invariant is violated. Carries a safe, noun-form {@link - * Reason} and knows nothing about error codes, HTTP status, or logging — the application/web layer - * translates {@code reason()}. Mirrors {@code WorkLogInvariantException}. - */ -public final class PosterInvariantException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - /** Safe, client-presentable reason for an invariant violation (noun form). */ - public enum Reason { - TITLE_BLANK, - INVALID_STATUS_TRANSITION, - IMAGE_REQUIRED - } - - private final Reason reason; - - public PosterInvariantException(Reason reason) { - super("Poster invariant violated: " + reason); - this.reason = reason; - } - - public Reason reason() { - return reason; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPage.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPage.java deleted file mode 100644 index 84a0b59..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPage.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import java.util.List; - -/** - * One page of posters plus the total element count across all pages. {@code total} feeds the - * response {@code meta.page.total}; {@code items} is never {@code null}. Mirrors {@code - * WorkLogPage}. - */ -public record PosterPage(List items, long total) { - - public PosterPage { - items = items == null ? List.of() : List.copyOf(items); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPublished.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPublished.java deleted file mode 100644 index 7120c65..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterPublished.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import dev.caskeleton.domain.stereotype.DomainEvent; -import java.util.Objects; - -/** Domain event: a {@link Poster} was published. Transport-free; carries domain data only. */ -@DomainEvent -public record PosterPublished(PosterId posterId) { - - public PosterPublished { - Objects.requireNonNull(posterId, "posterId is required"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterRepository.java deleted file mode 100644 index ec9ff4a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import java.util.Optional; - -/** Outbound port for Poster persistence. Implemented in adapter-persistence. */ -public interface PosterRepository { - Poster save(Poster poster); - - Optional findById(PosterId id); - - /** - * Returns one page of posters plus the total count. {@code sortField} / {@code status} may be - * {@code null} (unsorted / unfiltered); the total is computed against the same filter. Mirrors - * {@code WorkLogRepository#findPage}. - */ - PosterPage findPage( - int page, int size, PosterSortField sortField, boolean ascending, PosterStatus status); - - boolean existsById(PosterId id); - - boolean existsByTitle(String title); - - void deleteById(PosterId id); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterSortField.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterSortField.java deleted file mode 100644 index b06ca6c..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterSortField.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -/** - * Allow-listed sortable fields for the poster list endpoint. A closed enum (not an open string - * passed to the ORM) is what keeps the {@code ?sort=field,direction} contract safe. Mirrors {@code - * WorkLogSortField}. - */ -public enum PosterSortField { - TITLE("title"), - STATUS("status"); - - private final String property; - - PosterSortField(String property) { - this.property = property; - } - - /** Persistence property name this logical field maps to. */ - public String property() { - return property; - } - - /** Resolves a client-supplied sort field name, or {@code null} if not sortable. */ - public static PosterSortField fromRequest(String field) { - for (PosterSortField f : values()) { - if (f.property.equalsIgnoreCase(field)) { - return f; - } - } - return null; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterStatus.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterStatus.java deleted file mode 100644 index aaa7dd4..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PosterStatus.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -/** - * Lifecycle state of a {@link Poster}. Transitions are enforced by the aggregate (see {@link - * #canTransitionTo}): {@code DRAFT → PUBLISHED → ARCHIVED}, with {@code DRAFT → ARCHIVED} allowed - * and {@code ARCHIVED} terminal. - */ -public enum PosterStatus { - /** Created but not yet publicly visible. */ - DRAFT, - /** Publicly visible. */ - PUBLISHED, - /** Retired; terminal state. */ - ARCHIVED; - - /** Whether a direct transition from this state to {@code target} is permitted. */ - public boolean canTransitionTo(PosterStatus target) { - return switch (this) { - case DRAFT -> target == PUBLISHED || target == ARCHIVED; - case PUBLISHED -> target == ARCHIVED; - case ARCHIVED -> false; - }; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PublishedPosterImageAttachment.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PublishedPosterImageAttachment.java deleted file mode 100644 index 2eb1b3f..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/poster/PublishedPosterImageAttachment.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import java.util.Objects; - -/** Canonical READY attachment containing only an opaque published reference. */ -public record PublishedPosterImageAttachment(PosterImageReference reference) - implements PosterImageAttachment { - - public PublishedPosterImageAttachment { - Objects.requireNonNull(reference, "reference must be non-null"); - } - - @Override - public String compatibilityValue() { - return reference.value(); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/OutboxEventIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/OutboxEventIdFactory.java deleted file mode 100644 index 12e74e9..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/OutboxEventIdFactory.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -/** - * Domain port that mints opaque string identifiers for outbox events. Kept distinct from {@link - * WorkLogIdFactory} so the aggregate id type does not constrain the event id shape; the returned - * value is used as both {@code eventId} and {@code idempotencyKey}. See README. - */ -public interface OutboxEventIdFactory { - - /** - * Mints a new, globally unique event identifier. - * - * @return a non-null, non-blank string suitable for use as a UUIDv7 event id - */ - String newEventId(); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/Period.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/Period.java deleted file mode 100644 index 0ff1c39..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/Period.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import dev.caskeleton.domain.stereotype.ValueObject; -import java.time.LocalDate; -import java.util.Objects; - -/** Work period; {@code end == null} means ongoing. */ -@ValueObject -public record Period(LocalDate start, LocalDate end) { - - public Period { - Objects.requireNonNull(start, "start is required"); - if (end != null && end.isBefore(start)) { - throw new IllegalArgumentException("end must not be before start"); - } - } - - public boolean isOngoing() { - return end == null; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/RepoStats.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/RepoStats.java deleted file mode 100644 index 355a193..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/RepoStats.java +++ /dev/null @@ -1,4 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -/** Normalized repository statistics produced by the outbound ACL. */ -public record RepoStats(String fullName, int stars, String lastPushedIso) {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkCategory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkCategory.java deleted file mode 100644 index 5f00fad..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkCategory.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -/** Engineering work categories surfaced on the portfolio board. */ -public enum WorkCategory { - INFRASTRUCTURE, - DATABASE, - BACKEND, - PLATFORM -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLog.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLog.java deleted file mode 100644 index a5cd586..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLog.java +++ /dev/null @@ -1,243 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import dev.caskeleton.domain.stereotype.AggregateRoot; -import java.util.List; -import java.util.Objects; - -/** - * A portfolio board entry describing one piece of engineering work. Aggregate root for the WorkLog - * aggregate; framework-neutral POJO (no Spring/JPA). State changes only through intention-revealing - * methods (no public setters), and invariant violations surface as {@link - * WorkLogInvariantException}. See README for the design rationale. - */ -@AggregateRoot -public class WorkLog { - - private static final WorkLogOwner DEFAULT_OWNER = WorkLogOwner.of("sample-owner"); - - private final WorkLogId id; - private final WorkLogOwner owner; - private String title; - private WorkCategory category; - private WorkLogStatus status; - private String summary; - private String content; - private List techStack; - private List links; - private Period period; - - /** Optimistic-lock version; {@code null} until the store persists it. See README. */ - private final Long version; - - private WorkLog( - WorkLogId id, - WorkLogOwner owner, - String title, - WorkCategory category, - WorkLogStatus status, - String summary, - String content, - List techStack, - List links, - Period period, - Long version) { - this.id = Objects.requireNonNull(id, "id is required"); - this.owner = Objects.requireNonNull(owner, "owner is required"); - this.title = requireValidTitle(title); - this.category = Objects.requireNonNull(category, "category is required"); - this.status = Objects.requireNonNull(status, "status is required"); - this.summary = summary == null ? "" : summary; - this.content = content == null ? "" : content; - this.techStack = techStack == null ? List.of() : List.copyOf(techStack); - this.links = links == null ? List.of() : List.copyOf(links); - this.period = Objects.requireNonNull(period, "period is required"); - this.version = version; - } - - /** - * Builds a new WorkLog with a server-assigned identifier (minted by a {@link WorkLogIdFactory} in - * the use case, never by the domain). Version is {@code null} until the store persists it. See - * README. - */ - public static WorkLog create( - WorkLogId id, - String title, - WorkCategory category, - String summary, - String content, - List techStack, - List links, - Period period) { - return create(id, DEFAULT_OWNER, title, category, summary, content, techStack, links, period); - } - - public static WorkLog create( - WorkLogId id, - WorkLogOwner owner, - String title, - WorkCategory category, - String summary, - String content, - List techStack, - List links, - Period period) { - return new WorkLog( - id, - owner, - title, - category, - WorkLogStatus.OPEN, - summary, - content, - techStack, - links, - period, - null); - } - - public static WorkLog rehydrate( - WorkLogId id, - String title, - WorkCategory category, - String summary, - String content, - List techStack, - List links, - Period period, - Long version) { - return rehydrate( - id, - DEFAULT_OWNER, - title, - category, - WorkLogStatus.OPEN, - summary, - content, - techStack, - links, - period, - version); - } - - public static WorkLog rehydrate( - WorkLogId id, - WorkLogOwner owner, - String title, - WorkCategory category, - WorkLogStatus status, - String summary, - String content, - List techStack, - List links, - Period period, - Long version) { - return new WorkLog( - id, owner, title, category, status, summary, content, techStack, links, period, version); - } - - public void rename(String newTitle) { - ensureEditable(); - this.title = requireValidTitle(newTitle); - } - - /** - * Enforces the title invariant on the sole construction/mutation path: required (null → {@link - * NullPointerException}) and non-blank (whitespace-only → {@link WorkLogInvariantException} with - * {@code TITLE_BLANK}). See README. - */ - private static String requireValidTitle(String title) { - Objects.requireNonNull(title, "title is required"); - if (title.isBlank()) { - throw new WorkLogInvariantException(WorkLogInvariantException.Reason.TITLE_BLANK); - } - return title; - } - - public void recategorize(WorkCategory newCategory) { - ensureEditable(); - this.category = Objects.requireNonNull(newCategory, "category is required"); - } - - public void updateSummary(String newSummary) { - ensureEditable(); - this.summary = newSummary == null ? "" : newSummary; - } - - public void updateContent(String newContent) { - ensureEditable(); - this.content = newContent == null ? "" : newContent; - } - - public void startProgress() { - transitionTo(WorkLogStatus.IN_PROGRESS); - } - - public void close() { - transitionTo(WorkLogStatus.CLOSED); - } - - private void transitionTo(WorkLogStatus next) { - Objects.requireNonNull(next, "status is required"); - if (status == next) { - return; - } - boolean allowed = - (status == WorkLogStatus.OPEN && next == WorkLogStatus.IN_PROGRESS) - || (status == WorkLogStatus.IN_PROGRESS && next == WorkLogStatus.CLOSED); - if (!allowed) { - throw new WorkLogInvariantException( - WorkLogInvariantException.Reason.INVALID_STATUS_TRANSITION); - } - this.status = next; - } - - private void ensureEditable() { - if (status == WorkLogStatus.CLOSED) { - throw new WorkLogInvariantException(WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION); - } - } - - public WorkLogId id() { - return id; - } - - public WorkLogOwner owner() { - return owner; - } - - public String title() { - return title; - } - - public WorkCategory category() { - return category; - } - - public WorkLogStatus status() { - return status; - } - - public String summary() { - return summary; - } - - public String content() { - return content; - } - - public List techStack() { - return techStack; - } - - public List links() { - return links; - } - - public Period period() { - return period; - } - - public Long version() { - return version; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogId.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogId.java deleted file mode 100644 index dcbaf4a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogId.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import dev.caskeleton.domain.identifier.ResourceId; -import dev.caskeleton.domain.stereotype.ValueObject; -import java.util.regex.Pattern; - -/** - * Resource identifier for a {@link WorkLog}: a 36-character canonical UUID (RFC 9562 UUIDv7). Pure - * value object — validates the canonical form only; case normalisation happens at the web boundary. - * See README. - */ -@ValueObject -public record WorkLogId(String value) implements ResourceId { - - /** Canonical UUID form (RFC 9562): 8-4-4-4-12 hexadecimal groups. */ - private static final Pattern PATTERN = - Pattern.compile( - "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); - - public WorkLogId { - if (value == null || !PATTERN.matcher(value).matches()) { - throw new IllegalArgumentException("Invalid WorkLogId format: " + value); - } - } - - public static WorkLogId of(String value) { - return new WorkLogId(value); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdFactory.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdFactory.java deleted file mode 100644 index bb44e17..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdFactory.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import dev.caskeleton.domain.identifier.IdFactory; - -/** - * Domain port that mints {@link WorkLogId}s. Implemented by an infrastructure adapter; injected - * into the create use case. See README. - */ -public interface WorkLogIdFactory extends IdFactory {} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantException.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantException.java deleted file mode 100644 index 6e7d025..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantException.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -/** - * Raised when a {@link WorkLog} aggregate invariant is violated. Carries a safe, noun-form {@link - * Reason} and knows nothing about error codes, HTTP status, or logging — the application/web layer - * translates {@code reason()}. See README. - */ -public final class WorkLogInvariantException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - /** Safe, client-presentable reason for an invariant violation (noun form). */ - public enum Reason { - TITLE_BLANK, - INVALID_STATUS_TRANSITION, - CLOSED_WORKLOG_MUTATION - } - - private final Reason reason; - - public WorkLogInvariantException(Reason reason) { - super("WorkLog invariant violated: " + reason); - this.reason = reason; - } - - public Reason reason() { - return reason; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogOwner.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogOwner.java deleted file mode 100644 index 39ebf87..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogOwner.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import dev.caskeleton.domain.stereotype.ValueObject; -import java.util.Objects; - -@ValueObject -public record WorkLogOwner(String value) { - - public static WorkLogOwner of(String value) { - String normalized = Objects.requireNonNull(value, "owner is required").trim(); - if (normalized.isBlank()) { - throw new IllegalArgumentException("owner is required"); - } - return new WorkLogOwner(normalized); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogPage.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogPage.java deleted file mode 100644 index ec592df..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogPage.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import java.util.List; - -/** - * One page of WorkLogs plus the total element count across all pages. {@code total} feeds the - * response {@code meta.page.total}; {@code items} is never {@code null}. - */ -public record WorkLogPage(List items, long total) { - - public WorkLogPage { - items = items == null ? List.of() : List.copyOf(items); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogRepository.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogRepository.java deleted file mode 100644 index 4c0c808..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import java.util.Optional; - -/** Outbound port for WorkLog persistence. Implemented in adapter-persistence. */ -public interface WorkLogRepository { - WorkLog save(WorkLog workLog); - - Optional findById(WorkLogId id); - - /** - * Returns one page of WorkLogs plus the total count. {@code sortField} / {@code category} may be - * {@code null} (unsorted / unfiltered); the total is computed against the same filter. See - * README. - */ - WorkLogPage findPage( - int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category); - - boolean existsById(WorkLogId id); - - void deleteById(WorkLogId id); -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogReserved.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogReserved.java deleted file mode 100644 index 445d665..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogReserved.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import dev.caskeleton.domain.stereotype.DomainEvent; -import java.time.LocalDate; -import java.util.Objects; - -/** - * Domain event: a {@link WorkLog} period was reserved. Transport-free — carries domain data only, - * no broker/wire/HTTP type. The application boundary turns it into an integration event. See - * README. - */ -@DomainEvent -public record WorkLogReserved(WorkLogId workLogId, WorkCategory category, LocalDate periodStart) { - - public WorkLogReserved { - Objects.requireNonNull(workLogId, "workLogId is required"); - Objects.requireNonNull(category, "category is required"); - Objects.requireNonNull(periodStart, "periodStart is required"); - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogSortField.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogSortField.java deleted file mode 100644 index f6b41a3..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogSortField.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -/** - * Allow-listed sortable fields for the WorkLog list endpoint. A closed enum (not an open string - * passed to the ORM) is what keeps the {@code ?sort=field,direction} contract safe. See README. - */ -public enum WorkLogSortField { - TITLE("title"), - PERIOD_START("periodStart"); - - private final String property; - - WorkLogSortField(String property) { - this.property = property; - } - - /** Persistence property name this logical field maps to. */ - public String property() { - return property; - } - - /** Resolves a client-supplied sort field name, or {@code null} if not sortable. */ - public static WorkLogSortField fromRequest(String field) { - for (WorkLogSortField f : values()) { - if (f.property.equalsIgnoreCase(field)) { - return f; - } - } - return null; - } -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogStatus.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogStatus.java deleted file mode 100644 index be7ee51..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogStatus.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -public enum WorkLogStatus { - OPEN, - IN_PROGRESS, - CLOSED -} diff --git a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/package-info.java b/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/package-info.java deleted file mode 100644 index 5a7843a..0000000 --- a/src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/package-info.java +++ /dev/null @@ -1,2 +0,0 @@ -/** Sample fixture module. Production modules must not depend on this package. */ -package dev.caskeleton.sample.portfolio; diff --git a/src/sample-portfolio/src/main/resources/META-INF/spring.factories b/src/sample-portfolio/src/main/resources/META-INF/spring.factories deleted file mode 100644 index f660ca1..0000000 --- a/src/sample-portfolio/src/main/resources/META-INF/spring.factories +++ /dev/null @@ -1,2 +0,0 @@ -org.springframework.boot.EnvironmentPostProcessor=\ -dev.caskeleton.sample.portfolio.bootstrap.tracing.SampleTracingSamplingEnvironmentPostProcessor diff --git a/src/sample-portfolio/src/main/resources/application.yml b/src/sample-portfolio/src/main/resources/application.yml deleted file mode 100644 index 06f582b..0000000 --- a/src/sample-portfolio/src/main/resources/application.yml +++ /dev/null @@ -1,323 +0,0 @@ -# ============================================================================= -# sample-portfolio standalone application configuration. -# Mirrors app-bootstrap/application.yml for the settings that scanned beans require. -# Values are driven by env vars so the same src/.env file used by app-bootstrap works here. -# ============================================================================= - -spring: - autoconfigure: - # SampleManagementSecurityConfig provides the actuator SecurityFilterChain directly. - # ManagementWebSecurityAutoConfiguration is excluded because it expects an HttpSecurity - # prototype bean supplied by SpringSecurityFilterChainBuilder, which is absent in narrow - # @SpringBootConfiguration test slices that exclude SecurityAutoConfiguration. Excluding - # the auto-config here prevents it from being activated when such test slices load - # application.yml (which enables management endpoint exposure and triggers the auto-config). - exclude: org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration - application: - name: ${APP_NAME:sample-portfolio} - mvc: - problemdetails: - # D1/D5: RFC 7807 ProblemDetail rejected in favour of custom envelope. Pin OFF. - enabled: false - web: - error: - include-stacktrace: ${APP_SERVER_ERROR_INCLUDE_STACKTRACE:never} - include-message: ${APP_SERVER_ERROR_INCLUDE_MESSAGE:never} - profiles: - active: ${SPRING_PROFILES_ACTIVE:local} - datasource: - url: ${APP_DATASOURCE_URL:jdbc:postgresql://localhost:5432/ca_skeleton} - username: ${APP_DATASOURCE_USERNAME:ca_skeleton} - password: ${APP_DATASOURCE_PASSWORD:ca_skeleton} - driver-class-name: ${APP_DATASOURCE_DRIVER:org.postgresql.Driver} - hikari: - maximum-pool-size: ${APP_DATASOURCE_POOL_MAX_SIZE:10} - minimum-idle: ${APP_DATASOURCE_POOL_MIN_IDLE:2} - connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT:30000} - idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT:600000} - max-lifetime: ${APP_DATASOURCE_POOL_MAX_LIFETIME:1800000} - keepalive-time: 120000 - leak-detection-threshold: 30000 - validation-timeout: 3000 - initialization-fail-timeout: 1 - flyway: - baseline-on-migrate: false - out-of-order: true - clean-disabled: true - # NOTE: this property is overridden at runtime by the static - # SamplePostgreSqlPersistenceConfig.postgreSqlFlywayLocationCustomizer @Bean, which - # sets both "classpath:db/migration/postgresql" (the legacy production timeline) and - # "classpath:db/sample-migration" (V2 work_log, V7 poster). The FlywayConfigurationCustomizer - # replaces whatever is declared here, so this entry is documentation-only. - locations: classpath:db/migration/postgresql,classpath:db/sample-migration - jpa: - hibernate: - ddl-auto: ${APP_DATASOURCE_DDL_AUTO:update} - show-sql: ${APP_DATASOURCE_SHOW_SQL:false} - open-in-view: ${APP_DATASOURCE_OPEN_IN_VIEW:false} - properties: - hibernate: - format_sql: ${APP_DATASOURCE_FORMAT_SQL:false} - # spring.security.oauth2.resourceserver.jwt is intentionally absent. - # The custom JwtDecoderConfig (adapter-web) builds its JwtDecoder from - # ca-skeleton.security.issuer-uri via SecuritySettings — not from Spring Boot's - # OAuth2ResourceServer auto-config. Declaring issuer-uri here would activate - # OAuth2SecurityFilterChainConfiguration, which requires HttpSecurity and therefore - # SecurityAutoConfiguration — breaking narrow @WebMvcTest slices that exclude Security. - main: - banner-mode: ${SPRING_BANNER_MODE:off} - lazy-initialization: ${SPRING_MAIN_LAZY_INITIALIZATION:false} - log-startup-info: ${SPRING_MAIN_LOG_STARTUP_INFO:true} - threads: - virtual: - enabled: ${SPRING_THREADS_VIRTUAL_ENABLED:false} - jackson: - deserialization: - fail-on-unknown-properties: ${SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES:true} - fail-on-null-for-primitives: ${SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES:true} - fail-on-ignored-properties: ${SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES:true} - datatype: - enum: - read-unknown-enum-values-as-null: ${SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL:false} - datetime: - write-dates-as-timestamps: ${SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS:false} - lifecycle: - timeout-per-shutdown-phase: ${APP_SERVER_SHUTDOWN_TIMEOUT:30s} - -# --------------------------------------------------------------------------- -# Actuator / management -# --------------------------------------------------------------------------- -management: - server: - port: ${MANAGEMENT_SERVER_PORT:9001} - endpoints: - web: - exposure: - include: health,prometheus,info,loggers - exclude: env,configprops,heapdump,threaddump,shutdown - endpoint: - health: - show-details: when-authorized - probes: - enabled: true - group: - liveness: - include: livenessState - readiness: - # 'db' contributor is omitted here; narrow test slices that exclude DataSource - # auto-config do not have a 'db' health contributor and HealthEndpointGroupMembershipValidator - # would throw NoSuchHealthContributorException. Add 'db' via application-local.yml / - # environment-specific config when a real database is present. - include: readinessState - startup: - include: readinessState - shutdown: - access: none - info: - build: - enabled: true - -server: - port: ${APP_SERVER_PORT:8080} - shutdown: ${APP_SERVER_SHUTDOWN:graceful} - forward-headers-strategy: ${APP_SERVER_FORWARD_HEADERS_STRATEGY:framework} - tomcat: - threads: - max: ${APP_SERVER_TOMCAT_MAX_THREADS:200} - min-spare: ${APP_SERVER_TOMCAT_MIN_SPARE_THREADS:10} - accept-count: ${APP_SERVER_TOMCAT_ACCEPT_COUNT:100} - max-connections: ${APP_SERVER_TOMCAT_MAX_CONNECTIONS:8192} - connection-timeout: ${APP_SERVER_TOMCAT_CONNECTION_TIMEOUT:20s} - compression: - enabled: ${APP_SERVER_COMPRESSION_ENABLED:true} - min-response-size: ${APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE:1024} -logging: - level: - root: ${APP_LOG_LEVEL_ROOT:INFO} - dev.caskeleton: ${APP_LOG_LEVEL_APP:DEBUG} - org.springframework: ${APP_LOG_LEVEL_SPRING:INFO} - org.springframework.web: ${APP_LOG_LEVEL_WEB:INFO} - org.hibernate.SQL: ${APP_LOG_LEVEL_SQL:WARN} - -# --------------------------------------------------------------------------- -# ca-skeleton module-scoped settings. -# These are bound by @ConfigurationProperties in the respective modules. -# --------------------------------------------------------------------------- -ca-skeleton: - capabilities: - cache: - bindings: - default: ${APP_CACHE_CANONICAL_DEFAULT_PROVIDER:disabled} - regions: - default: - key-hmac-secret-reference: secret://environment/APP_CACHE_REDIS_KEY_HMAC_SECRET - namespace-application: ${APP_NAME:sample-portfolio} - namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} - semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} - hash-key-version: 1 - key-version: 1 - policy-revision: canonical-default-r1 - positive-soft-ttl: ${APP_CACHE_REDIS_POSITIVE_SOFT_TTL:240s} - positive-hard-ttl: ${APP_CACHE_DEFAULT_TTL:300s} - negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} - ttl-jitter: ${APP_CACHE_REDIS_TTL_JITTER:0.10} - maximum-value-bytes: 61440 - l1: - enabled: ${APP_CACHE_REDIS_L1_ENABLED:false} - maximum-entries: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES:10000} - maximum-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES:67108864} - maximum-entry-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES:1048576} - time-to-live: ${APP_CACHE_REDIS_L1_TTL:30s} - generation-recheck-interval: ${APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL:5s} - invalidation-queue-capacity: ${APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY:1024} - bootstrap: - app-name: ${APP_NAME:sample-portfolio} - runtime: - error-detail-exposure-enabled: ${APP_ERROR_DETAIL_EXPOSURE_ENABLED:false} - log-body-capture-enabled: ${APP_LOG_BODY_CAPTURE_ENABLED:false} - multi-instance-enabled: ${APP_MULTI_INSTANCE_ENABLED:false} - lock: - wait-time: 3s - lease-ttl: 30s - presentation: - api-base-path: ${PRESENTATION_API_BASE_PATH:/api} - rate-limit: - enabled: ${APP_RATE_LIMIT_ENABLED:false} - default-policy-id: ${APP_RATE_LIMIT_DEFAULT_POLICY_ID:api-default} - hash-key-version: ${APP_RATE_LIMIT_HASH_KEY_VERSION:1} - caller-deadline-budget: 2s - client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only} - idempotency: - ttl: ${APP_IDEMPOTENCY_TTL:24h} - reaper-interval: 10m - security: - issuer-uri: ${APP_SECURITY_JWT_ISSUER:http://localhost:8080/auth/realms/sample} - audience: ${APP_SECURITY_JWT_AUDIENCE:} - public-paths: ${SECURITY_PUBLIC_PATHS:/api/healthcheck} - authz: - role-permissions: - user: worklog:read,worklog:write,poster:write - admin: worklog:read,worklog:write,worklog:close,poster:write,poster:close - cors: - enabled: ${APP_SECURITY_CORS_ENABLED:true} - allowed-origins: ${APP_SECURITY_CORS_ORIGINS:http://localhost:3000} - allowed-methods: ${APP_SECURITY_CORS_ALLOWED_METHODS:} - allowed-headers: ${APP_SECURITY_CORS_ALLOWED_HEADERS:*} - allow-credentials: ${APP_SECURITY_CORS_ALLOW_CREDENTIALS:true} - max-age-seconds: ${APP_SECURITY_CORS_MAX_AGE:3600} - logging: - file: - enabled: ${APP_LOG_FILE_ENABLED:false} - path: ${APP_LOG_FILE_PATH:logs/sample-portfolio.json} - max-size: ${APP_LOG_FILE_MAX_SIZE:100MB} - max-history: ${APP_LOG_FILE_MAX_HISTORY:14} - total-size-cap: ${APP_LOG_FILE_TOTAL_SIZE_CAP:3GB} - async: - enabled: ${APP_LOG_ASYNC_ENABLED:true} - queue-size: ${APP_LOG_ASYNC_QUEUE_SIZE:512} - discarding-threshold: ${APP_LOG_ASYNC_DISCARDING_THRESHOLD:20} - json: - timezone: ${APP_LOG_JSON_TIMEZONE:UTC} - timestamp-pattern: ${APP_LOG_JSON_TIMESTAMP_PATTERN:"yyyy-MM-dd'T'HH:mm:ss.SSSXXX"} - include-caller-data: ${APP_LOG_JSON_INCLUDE_CALLER_DATA:false} - logger-name-length: ${APP_LOG_JSON_LOGGER_NAME_LENGTH:0} - sampling-rate: ${APP_LOG_SAMPLING_RATE:1.0} - privacy: - pseudonymization-salt: ${APP_PRIVACY_PSEUDONYMIZATION_SALT:} - tracing: - enabled: ${APP_TRACING_ENABLED:true} - sample-rate: ${APP_TRACING_SAMPLE_RATE:} - exporter: - otlp-endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:} - # Outbox relay disabled for lean demo (WorkLog demo does not need active relay). - outbox: - relay-enabled: false - poll-interval: PT5S - batch-size: 20 - in-flight-timeout: PT5M - reaper-interval: PT10M - published-retention: P7D - -# --------------------------------------------------------------------------- -# Optional integration adapter toggles (all disabled for lean demo). -# --------------------------------------------------------------------------- -app: - poster-image: - api: - mode: disabled - idempotency: - enabled: false - publication: - enabled: false - cache: - redis: - enabled: ${APP_CACHE_REDIS_ENABLED:false} - client-mode: ${APP_CACHE_REDIS_CLIENT_MODE:managed} - host: ${APP_CACHE_REDIS_HOST:} - port: ${APP_CACHE_REDIS_PORT:6379} - password: ${APP_CACHE_REDIS_PASSWORD:} - key-hmac-secret: ${APP_CACHE_REDIS_KEY_HMAC_SECRET:} - command-timeout: ${APP_CACHE_REDIS_COMMAND_TIMEOUT:2s} - maximum-queued-commands: ${APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS:8} - maximum-in-flight-bytes: ${APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES:16777216} - positive-ttl: ${APP_CACHE_DEFAULT_TTL:300s} - negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} - namespace-application: ${APP_NAME:ca-skeleton} - namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} - semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} - maximum-value-bytes: ${APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES:1048576} - l1: - enabled: ${APP_CACHE_REDIS_L1_ENABLED:false} - maximum-entries: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES:10000} - maximum-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES:67108864} - maximum-entry-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES:1048576} - time-to-live: ${APP_CACHE_REDIS_L1_TTL:30s} - generation-recheck-interval: ${APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL:5s} - invalidation-queue-capacity: ${APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY:1024} - messaging: - broker: ${APP_MESSAGING_BROKER:} - kafka: - brokers: ${APP_MESSAGING_KAFKA_BROKERS:} - notification: - slack: - provider: ${APP_NOTIFICATION_SLACK_PROVIDER:} - email: - provider: ${APP_NOTIFICATION_EMAIL_PROVIDER:} - outbound: - http: - connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT:2s} - read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT:5s} - global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT:10s} - maximum-in-flight-calls: ${APP_OUTBOUND_HTTP_MAXIMUM_IN_FLIGHT_CALLS:128} - retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false} - retry: - max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3} - initial-backoff: ${APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF:100ms} - backoff-multiplier: ${APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER:2.0} - circuit-breaker-enabled: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED:false} - circuit-breaker: - failure-rate-threshold: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD:50} - sliding-window-size: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE:100} - minimum-number-of-calls: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS:100} - wait-duration-in-open-state: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE:60s} - permitted-calls-in-half-open: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN:10} - response-size-limit: ${APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT:10MB} - ---- -spring: - config: - activate: - on-profile: local - -# Temporary sample-only compatibility seam. The production capability stays disabled until the -# sample consumer migrates from the deprecated byte[] contract. -app: - poster-image: - api: - mode: legacy - object-storage: - enabled: false - legacy: - enabled: true - backend: filesystem - base-path: ./.data/objectstorage diff --git a/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json b/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json deleted file mode 100644 index d73485e..0000000 --- a/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:dev-caskeleton:contracts:messaging:portfolio.worklog.reserved:v1", - "title": "WorkLogReserved payload v1", - "type": "object", - "required": [ - "workLogId" - ], - "properties": { - "workLogId": { - "type": "string", - "minLength": 1, - "maxLength": 160, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" - } - }, - "unevaluatedProperties": false -} diff --git a/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256 b/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256 deleted file mode 100644 index d1e0dce..0000000 --- a/src/sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256 +++ /dev/null @@ -1 +0,0 @@ -7264efd4e2531e6fd00010bb3deca96af2f1d0408d0021d014feaf90190f2eac diff --git a/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql deleted file mode 100644 index ae950f7..0000000 --- a/src/sample-portfolio/src/main/resources/db/sample-migration/V2__work_log.sql +++ /dev/null @@ -1,65 +0,0 @@ --- feature-persistence-auditing-contract (D3/D6) — sample WorkLog aggregate schema, the --- reference table that carries the audit-metadata columns. --- --- The four audit columns (created_at / updated_at / created_by / updated_by) are the --- deliverable of this branch: they map the adapter-persistence AuditableEntity --- @MappedSuperclass that WorkLogEntity inherits (D6 — a domain aggregate persistence --- entity opts in; the infra/immutable idempotency_record table in V1 does NOT, D6). The --- domain WorkLog model carries none of these fields (D2); WorkLogRepositoryAdapter stamps --- them via the Clock bean (D4) and AuditContextPort actor with "system" fallback (D5). --- --- created_* are immutable after INSERT (the entity maps them updatable=false); updated_* --- move on every modification. version (optimistic-lock) is owned by --- feature-persistence-failure-baseline / feature-transaction-concurrency-contract, not by --- this audit contract (D3 out-of-scope), and is mapped here only as the aggregate's column. --- --- Location: db/sample-migration (NOT the default db/migration). Flyway scans --- `classpath:db/migration` recursively across every classpath root; sample-portfolio is --- absent from the Gradle runtime classpath but present on IDE/test classpaths --- (app-bootstrap testImplementation). Keeping this file in db/migration made the resolved --- migration set differ per launcher against the same long-lived dev DB — once production --- V3 was applied without V2, Flyway validation failed in BOTH directions (resolved-not- --- applied from the IDE, applied-not-resolved from Gradle; out-of-order=false is pinned by --- FLYWAY-C5). The sibling location keeps every launcher resolving only the production --- migrations (V1, V3+). To actually run this file, add --- `spring.flyway.locations: classpath:db/migration/postgresql,classpath:db/sample-migration` --- in a sample-enabled deployment; local dev relies on ddl-auto=update for the sample --- schema instead. - -CREATE TABLE work_log ( - id uuid NOT NULL, - owner varchar(256) NOT NULL, - title varchar(255), - category varchar(255), - status varchar(32) NOT NULL, - summary varchar(500), - content varchar(10000), - period_start date, - period_end date, - version bigint, - -- audit metadata (feature-persistence-auditing-contract D3) - created_at timestamptz NOT NULL, - updated_at timestamptz NOT NULL, - created_by varchar(256) NOT NULL, - updated_by varchar(256) NOT NULL, - CONSTRAINT pk_work_log PRIMARY KEY (id) -); - --- @ElementCollection tables (WorkLogEntity techStack / links). These are owned-value --- tables, not aggregates, so they carry no audit columns (D6). -CREATE TABLE work_log_tech_stack ( - work_log_id uuid NOT NULL, - tech varchar(255), - CONSTRAINT fk_work_log_tech_stack_work_log - FOREIGN KEY (work_log_id) REFERENCES work_log (id) -); - -CREATE TABLE work_log_link ( - work_log_id uuid NOT NULL, - url varchar(255), - CONSTRAINT fk_work_log_link_work_log - FOREIGN KEY (work_log_id) REFERENCES work_log (id) -); - -CREATE INDEX ix_work_log_tech_stack_work_log_id ON work_log_tech_stack (work_log_id); -CREATE INDEX ix_work_log_link_work_log_id ON work_log_link (work_log_id); diff --git a/src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster.sql deleted file mode 100644 index 1822400..0000000 --- a/src/sample-portfolio/src/main/resources/db/sample-migration/V7__poster.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Sample Poster aggregate schema (second sample aggregate, alongside work_log in V2). --- --- Demonstrates the object-storage + domain-invariant collaboration: image_key holds the --- object-storage key of the uploaded image, and the domain forbids publishing a poster that --- has none. Like work_log, this is a domain aggregate entity, so it opts into the four audit --- columns from the adapter-persistence AuditableEntity @MappedSuperclass (PosterEntity inherits --- it); the domain Poster model carries none of them — PosterRepositoryAdapter stamps them via --- the Clock bean and AuditContextPort actor ("system" fallback). created_* are immutable after --- INSERT; updated_* move on every modification. version is the optimistic-lock column. --- --- Location: db/sample-migration (a sibling of db/migration/postgresql). The sample --- application.yml activates both locations, so the merged Flyway timeline is V1..V7. --- V7 intentionally follows the production capability-registry adoption at V6. - -CREATE TABLE poster ( - id uuid NOT NULL, - title varchar(255) NOT NULL, - caption varchar(2000), - image_key varchar(512), - status varchar(32) NOT NULL, - version bigint, - -- audit metadata (feature-persistence-auditing-contract D3) - created_at timestamptz NOT NULL, - updated_at timestamptz NOT NULL, - created_by varchar(256) NOT NULL, - updated_by varchar(256) NOT NULL, - CONSTRAINT pk_poster PRIMARY KEY (id) -); - -CREATE INDEX ix_poster_status ON poster (status); -CREATE INDEX ix_poster_title ON poster (title); diff --git a/src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql b/src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql deleted file mode 100644 index c9f3be3..0000000 --- a/src/sample-portfolio/src/main/resources/db/sample-migration/V8__poster_image_publication.sql +++ /dev/null @@ -1,128 +0,0 @@ --- Additive, forward-only Poster image publication state. V8 follows the V7 Poster schema. --- --- image_key remains for the bounded legacy dual-read window. New READY writes use only the --- opaque image_reference. Intent tables deliberately have no cascading Poster foreign key: --- publication and retirement evidence must survive aggregate deletion and process restarts. - -ALTER TABLE poster - ADD COLUMN image_reference varchar(96); - -ALTER TABLE poster - ADD CONSTRAINT ck_poster_single_image_locator - CHECK (image_key IS NULL OR image_reference IS NULL); - -CREATE TABLE poster_image_upload_intent ( - id uuid NOT NULL, - poster_id uuid NOT NULL, - operation_destination varchar(64) NOT NULL, - operation_epoch varchar(64) NOT NULL, - operation_id varchar(64) NOT NULL, - hmac_key_version varchar(64) NOT NULL, - idempotency_scope_digest varchar(43) NOT NULL, - request_fingerprint varchar(64) NOT NULL, - expected_poster_version bigint NOT NULL, - content_length bigint NOT NULL, - content_sha256 varchar(44) NOT NULL, - media_type varchar(127) NOT NULL, - state varchar(32) NOT NULL, - stage_handle varchar(96), - exact_version varchar(96), - handoff_claimant_digest varchar(64), - handoff_fence bigint, - handoff_expires_at timestamptz, - published_reference varchar(96), - published_version varchar(96), - replaced_reference varchar(96), - retry_at timestamptz, - expires_at timestamptz NOT NULL, - attempt_count integer NOT NULL DEFAULT 0, - last_error_code varchar(64), - created_at timestamptz NOT NULL, - updated_at timestamptz NOT NULL, - revision bigint NOT NULL DEFAULT 0, - CONSTRAINT pk_poster_image_upload_intent PRIMARY KEY (id), - CONSTRAINT uq_poster_image_upload_operation - UNIQUE (operation_destination, operation_epoch, operation_id), - CONSTRAINT uq_poster_image_upload_scope - UNIQUE (hmac_key_version, idempotency_scope_digest), - CONSTRAINT ck_poster_image_upload_content_length CHECK (content_length >= 0), - CONSTRAINT ck_poster_image_upload_expected_version CHECK (expected_poster_version >= 0), - CONSTRAINT ck_poster_image_upload_attempt_count CHECK (attempt_count >= 0), - CONSTRAINT ck_poster_image_upload_revision CHECK (revision >= 0), - CONSTRAINT ck_poster_image_upload_handoff - CHECK ( - (handoff_claimant_digest IS NULL - AND handoff_fence IS NULL - AND handoff_expires_at IS NULL) - OR - (handoff_claimant_digest IS NOT NULL - AND handoff_fence > 0 - AND handoff_expires_at IS NOT NULL) - ), - CONSTRAINT ck_poster_image_upload_stage - CHECK ( - (stage_handle IS NULL AND exact_version IS NULL) - OR - (stage_handle IS NOT NULL AND exact_version IS NOT NULL) - ), - CONSTRAINT ck_poster_image_upload_publication - CHECK ( - (published_reference IS NULL AND published_version IS NULL) - OR - (published_reference IS NOT NULL AND published_version IS NOT NULL) - ) -); - -CREATE INDEX ix_poster_image_upload_claim - ON poster_image_upload_intent (state, retry_at, handoff_expires_at); -CREATE INDEX ix_poster_image_upload_expiry - ON poster_image_upload_intent (expires_at); -CREATE INDEX ix_poster_image_upload_poster - ON poster_image_upload_intent (poster_id, created_at); - -CREATE TABLE poster_image_retirement_intent ( - id uuid NOT NULL, - poster_id uuid, - operation_id varchar(64) NOT NULL, - image_reference varchar(96) NOT NULL, - exact_version varchar(96) NOT NULL, - state varchar(32) NOT NULL, - claimant_digest varchar(64), - claim_fence bigint, - claim_expires_at timestamptz, - retain_until timestamptz NOT NULL, - retry_at timestamptz, - attempt_count integer NOT NULL DEFAULT 0, - last_error_code varchar(64), - created_at timestamptz NOT NULL, - updated_at timestamptz NOT NULL, - revision bigint NOT NULL DEFAULT 0, - CONSTRAINT pk_poster_image_retirement_intent PRIMARY KEY (id), - CONSTRAINT uq_poster_image_retirement_operation UNIQUE (operation_id), - CONSTRAINT uq_poster_image_retirement_exact - UNIQUE (image_reference, exact_version), - CONSTRAINT ck_poster_image_retirement_attempt_count CHECK (attempt_count >= 0), - CONSTRAINT ck_poster_image_retirement_revision CHECK (revision >= 0), - CONSTRAINT ck_poster_image_retirement_claim - CHECK ( - (claimant_digest IS NULL AND claim_fence IS NULL AND claim_expires_at IS NULL) - OR - (claimant_digest IS NOT NULL - AND claim_fence > 0 - AND claim_expires_at IS NOT NULL) - ) -); - -CREATE INDEX ix_poster_image_retirement_claim - ON poster_image_retirement_intent (state, retry_at, claim_expires_at); -CREATE INDEX ix_poster_image_retirement_retention - ON poster_image_retirement_intent (retain_until); - -CREATE TABLE poster_image_idempotency_key_epoch ( - epoch_id varchar(64) NOT NULL, - active_key_version varchar(64) NOT NULL, - updated_at timestamptz NOT NULL, - revision bigint NOT NULL DEFAULT 0, - CONSTRAINT pk_poster_image_idempotency_key_epoch PRIMARY KEY (epoch_id), - CONSTRAINT ck_poster_image_idempotency_epoch_revision CHECK (revision >= 0) -); diff --git a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java deleted file mode 100644 index eaea8b6..0000000 --- a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageIdempotencyRotationQualificationTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.caskeleton.sample.portfolio.qualification; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.adapter.outbound.identifier.HmacPosterImageIdempotencyScopeDigester; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; - -class PosterImageIdempotencyRotationQualificationTest { - - @Test - void overlapFirstActivationKeepsOldDigestDiscoverable() { - var active = new AtomicReference<>("key-v1"); - var keys = new LinkedHashMap(); - keys.put("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); - keys.put("key-v2", "abcdef0123456789abcdef0123456789".getBytes(StandardCharsets.UTF_8)); - var digester = new HmacPosterImageIdempotencyScopeDigester(active::get, keys); - - var before = digester.digest("tenant", "principal", "use-case", "client-key"); - active.set("key-v2"); - var after = digester.digest("tenant", "principal", "use-case", "client-key"); - - assertThat(before.keyVersion()).isEqualTo("key-v1"); - assertThat(after.keyVersion()).isEqualTo("key-v2"); - assertThat(digester.retainedDigests("tenant", "principal", "use-case", "client-key")) - .contains(before, after); - } -} diff --git a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java deleted file mode 100644 index 877612a..0000000 --- a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageRetirementQualificationTest.java +++ /dev/null @@ -1,110 +0,0 @@ -package dev.caskeleton.sample.portfolio.qualification; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.time.OffsetDateTime; -import java.util.UUID; -import org.flywaydb.core.Flyway; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.postgresql.PostgreSQLContainer; - -@Testcontainers -class PosterImageRetirementQualificationTest { - - @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16.4-alpine"); - - @BeforeEach - void migrateFreshDatabase() { - Flyway.configure() - .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) - .locations("classpath:db/migration/postgresql", "classpath:db/sample-migration") - .cleanDisabled(false) - .load() - .clean(); - Flyway.configure() - .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) - .locations("classpath:db/migration/postgresql", "classpath:db/sample-migration") - .load() - .migrate(); - } - - @Test - void exactRetirementWorkSurvivesPosterDeletionWithoutCascade() throws Exception { - UUID posterId = UUID.randomUUID(); - UUID intentId = UUID.randomUUID(); - try (Connection connection = connection()) { - insertPoster(connection, posterId); - try (PreparedStatement insert = - connection.prepareStatement( - """ - insert into poster_image_retirement_intent ( - id, poster_id, operation_id, image_reference, exact_version, - state, retain_until, attempt_count, created_at, updated_at, revision - ) values (?, ?, ?, ?, ?, 'PENDING', ?, 0, ?, ?, 0) - """)) { - insert.setObject(1, intentId); - insert.setObject(2, posterId); - insert.setString(3, "retire-operation-1"); - insert.setString(4, "osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); - insert.setString(5, "osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); - OffsetDateTime now = OffsetDateTime.parse("2026-07-29T00:00:00Z"); - insert.setObject(6, now); - insert.setObject(7, now); - insert.setObject(8, now); - assertThat(insert.executeUpdate()).isEqualTo(1); - } - try (PreparedStatement delete = - connection.prepareStatement("delete from poster where id = ?")) { - delete.setObject(1, posterId); - assertThat(delete.executeUpdate()).isEqualTo(1); - } - try (PreparedStatement select = - connection.prepareStatement( - "select poster_id, state from poster_image_retirement_intent where id = ?")) { - select.setObject(1, intentId); - try (ResultSet result = select.executeQuery()) { - assertThat(result.next()).isTrue(); - assertThat(result.getObject(1, UUID.class)).isEqualTo(posterId); - assertThat(result.getString(2)).isEqualTo("PENDING"); - } - } - } - } - - private Connection connection() throws Exception { - return DriverManager.getConnection( - POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); - } - - private static void insertPoster(Connection connection, UUID posterId) throws Exception { - try (PreparedStatement statement = - connection.prepareStatement( - """ - insert into poster ( - id, title, caption, image_reference, status, version, - created_at, updated_at, created_by, updated_by - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """)) { - OffsetDateTime now = OffsetDateTime.parse("2026-07-29T00:00:00Z"); - statement.setObject(1, posterId); - statement.setString(2, "canonical"); - statement.setString(3, "retirement qualification"); - statement.setString(4, "osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); - statement.setString(5, "DRAFT"); - statement.setLong(6, 0); - statement.setObject(7, now); - statement.setObject(8, now); - statement.setString(9, "qualification"); - statement.setString(10, "qualification"); - assertThat(statement.executeUpdate()).isEqualTo(1); - } - } -} diff --git a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java b/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java deleted file mode 100644 index 90ccf4f..0000000 --- a/src/sample-portfolio/src/posterImageMigrationTest/java/dev/caskeleton/sample/portfolio/qualification/PosterImageV8MigrationQualificationTest.java +++ /dev/null @@ -1,176 +0,0 @@ -package dev.caskeleton.sample.portfolio.qualification; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.time.OffsetDateTime; -import java.util.List; -import org.flywaydb.core.Flyway; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.postgresql.PostgreSQLContainer; - -@Testcontainers -class PosterImageV8MigrationQualificationTest { - - @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16.4-alpine"); - - @BeforeEach - void cleanDatabase() { - flyway(null).clean(); - } - - @Test - void freshTimelineCreatesAdditiveLocatorFreeIntentSchema() throws Exception { - assertThat(flyway(null).migrate().targetSchemaVersion).isEqualTo("8"); - - try (Connection connection = connection()) { - assertThat(columns(connection, "poster")).contains("image_key", "image_reference"); - assertThat(columns(connection, "poster_image_upload_intent")) - .contains( - "idempotency_scope_digest", - "request_fingerprint", - "stage_handle", - "handoff_fence", - "published_reference", - "revision") - .doesNotContain( - "idempotency_key", - "principal", - "tenant", - "payload", - "bucket", - "path", - "presigned_url"); - assertThat(columns(connection, "poster_image_retirement_intent")) - .contains("image_reference", "exact_version", "retain_until"); - assertThat(foreignKeys(connection, "poster_image_upload_intent")).isEmpty(); - assertThat(foreignKeys(connection, "poster_image_retirement_intent")).isEmpty(); - } - } - - @Test - void v7LegacyRowsAndOldBinaryProjectionSurviveV8Upgrade() throws Exception { - flyway("7").migrate(); - try (Connection connection = connection()) { - oldBinaryInsert(connection, "legacy-before-upgrade"); - } - - flyway(null).migrate(); - - try (Connection connection = connection()) { - assertThat( - scalar( - connection, "select image_key from poster where title = 'legacy-before-upgrade'")) - .isEqualTo("posters/legacy/image"); - assertThat( - scalar( - connection, - "select image_reference from poster" + " where title = 'legacy-before-upgrade'")) - .isNull(); - - oldBinaryInsert(connection, "legacy-after-upgrade"); - try (PreparedStatement update = - connection.prepareStatement("update poster set caption = ? where title = ?")) { - update.setString(1, "old binary update"); - update.setString(2, "legacy-after-upgrade"); - assertThat(update.executeUpdate()).isEqualTo(1); - } - assertThat( - scalar(connection, "select caption from poster where title = 'legacy-after-upgrade'")) - .isEqualTo("old binary update"); - } - } - - private Flyway flyway(String target) { - var configuration = - Flyway.configure() - .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) - .locations("classpath:db/migration/postgresql", "classpath:db/sample-migration") - .cleanDisabled(false); - if (target != null) { - configuration.target(target); - } - return configuration.load(); - } - - private Connection connection() throws Exception { - return DriverManager.getConnection( - POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); - } - - private static List columns(Connection connection, String table) throws Exception { - try (PreparedStatement statement = - connection.prepareStatement( - "select column_name from information_schema.columns" - + " where table_schema = 'public' and table_name = ?" - + " order by ordinal_position")) { - statement.setString(1, table); - try (ResultSet result = statement.executeQuery()) { - java.util.ArrayList columns = new java.util.ArrayList<>(); - while (result.next()) { - columns.add(result.getString(1)); - } - return List.copyOf(columns); - } - } - } - - private static List foreignKeys(Connection connection, String table) throws Exception { - try (PreparedStatement statement = - connection.prepareStatement( - """ - select constraint_name - from information_schema.table_constraints - where table_schema = 'public' - and table_name = ? - and constraint_type = 'FOREIGN KEY' - """)) { - statement.setString(1, table); - try (ResultSet result = statement.executeQuery()) { - java.util.ArrayList constraints = new java.util.ArrayList<>(); - while (result.next()) { - constraints.add(result.getString(1)); - } - return List.copyOf(constraints); - } - } - } - - private static void oldBinaryInsert(Connection connection, String title) throws Exception { - try (PreparedStatement statement = - connection.prepareStatement( - """ - insert into poster ( - id, title, caption, image_key, status, version, - created_at, updated_at, created_by, updated_by - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """)) { - statement.setObject(1, java.util.UUID.randomUUID()); - statement.setString(2, title); - statement.setString(3, "legacy"); - statement.setString(4, "posters/legacy/image"); - statement.setString(5, "DRAFT"); - statement.setLong(6, 0); - statement.setObject(7, OffsetDateTime.parse("2026-07-29T00:00:00Z")); - statement.setObject(8, OffsetDateTime.parse("2026-07-29T00:00:00Z")); - statement.setString(9, "old-binary"); - statement.setString(10, "old-binary"); - assertThat(statement.executeUpdate()).isEqualTo(1); - } - } - - private static String scalar(Connection connection, String sql) throws Exception { - try (PreparedStatement statement = connection.prepareStatement(sql); - ResultSet result = statement.executeQuery()) { - assertThat(result.next()).isTrue(); - return result.getString(1); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SampleApplicationContextTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SampleApplicationContextTest.java deleted file mode 100644 index ae33a29..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SampleApplicationContextTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package dev.caskeleton.sample.portfolio; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.ApplicationContext; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.ActiveProfiles; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.postgresql.PostgreSQLContainer; - -/** - * Boot-loop driver for the LEAN sample-portfolio standalone startup. - * - *

    Boots a full {@link SamplePortfolioApplication} context using the {@code test} Spring profile - * and a real Testcontainers Postgres. Asserts that the full migration set — including the sample's - * work_log table (V2 from {@code db/sample-migration}) — is applied on standalone boot. This guards - * against {@link - * dev.caskeleton.sample.portfolio.bootstrap.persistence.SamplePostgreSqlPersistenceConfig} - * accidentally dropping sample migrations when it configures Flyway locations. - * - *

    Testcontainers is enabled via {@code @ActiveProfiles("test")} combined with a disposable - * Postgres container whose connection details override the placeholder datasource URL defined in - * {@code application-test.yml}. - */ -@SpringBootTest( - classes = SamplePortfolioApplication.class, - webEnvironment = SpringBootTest.WebEnvironment.NONE) -@ActiveProfiles("test") -@Testcontainers(disabledWithoutDocker = true) -class SampleApplicationContextTest { - - @Container @ServiceConnection - static final PostgreSQLContainer POSTGRES = PostgreSqlTestContainer.create(); - - @Autowired ApplicationContext context; - - @Autowired JdbcTemplate jdbcTemplate; - - @Test - void contextLoads() { - assertThat(context).isNotNull(); - } - - /** - * Asserts that the work_log table (V2 — {@code db/sample-migration/V2__work_log.sql}) was applied - * by Flyway on standalone boot. Without this assertion the bug where {@link - * dev.caskeleton.sample.portfolio.bootstrap.persistence.SamplePostgreSqlPersistenceConfig} - * overwrites the configured locations (dropping {@code db/sample-migration}) would be invisible - * to this test. - */ - @Test - void workLogTableIsMigrated() { - Integer count = - jdbcTemplate.queryForObject( - "SELECT count(*) FROM information_schema.tables " - + "WHERE table_schema = 'public' AND table_name = 'work_log'", - Integer.class); - assertThat(count) - .as("work_log table must exist — V2 sample-migration must be applied") - .isEqualTo(1); - } - - /** - * Asserts that {@code flyway_schema_history} records V2 as a successfully applied migration - * script (description contains "work_log" case-insensitively). - */ - @Test - void flywaySchemaHistoryContainsV2WorkLogMigration() { - Integer applied = - jdbcTemplate.queryForObject( - "SELECT count(*) FROM flyway_schema_history " - + "WHERE version = '2' AND success = true", - Integer.class); - assertThat(applied) - .as("flyway_schema_history must contain a successful V2 migration") - .isEqualTo(1); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SamplePortfolioTestApplication.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SamplePortfolioTestApplication.java deleted file mode 100644 index e9b365b..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/SamplePortfolioTestApplication.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.caskeleton.sample.portfolio; - -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; - -/** - * Minimal {@code @SpringBootConfiguration} bootstrap so {@code @WebMvcTest} has an entry point when - * sample-portfolio runs its own tests (the real application lives in {@code app-bootstrap}, which - * is not on sample-portfolio's test classpath). - * - *

    No {@code @ComponentScan}: each {@code @WebMvcTest} declares the controller it tests via the - * {@code controllers} attribute and explicitly {@code @Import}s the advice / configuration beans it - * needs, keeping the test slice tight and avoiding accidental loading of persistence / outbound - * adapters whose JPA beans would fail without a database. - */ -@SpringBootConfiguration -@EnableAutoConfiguration -public class SamplePortfolioTestApplication {} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java deleted file mode 100644 index 62d9b0b..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/contract/OpenApiDriftContractTest.java +++ /dev/null @@ -1,172 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.contract; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.json.JsonMapper; -import dev.caskeleton.adapter.inbound.web.config.OpenApiContractConfig; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; -import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -import org.springframework.boot.resttestclient.TestRestTemplate; -import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; -import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; -import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.context.annotation.Import; -import org.springframework.http.ResponseEntity; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -/** - * Gate #10 (OpenAPI drift) of the contract-verification suite — the release-blocking drift gate - * that {@code OpenApiSnapshotTest} (feature-api-contract-baseline D10, the producer) defers to this - * branch. Boots the same datasource-free slice and compares the runtime springdoc {@code - * /v3/api-docs} against a committed, canonicalized snapshot resource; any drift between the actual - * response surface and the committed contract fails the build - * (feature-contract-verification-test-suite §5, §테스트 계약 "OpenAPI snapshot과 실제 response envelope가 - * drift되면 build 실패"). - * - *

    Mechanism = committed-snapshot equality (the chosen alternative; semantic additive/breaking - * classification stays the feature-api-compatibility-deprecation-contract layer). Both sides are - * canonicalized (map keys sorted, pretty-printed) so the diff reflects a real surface change, not - * springdoc emission ordering. Regenerate the baseline after an intentional API change with {@code - * ./gradlew openapiCheckSnapshot -PapproveOpenApiChange} (sets {@code - * -Dopenapi.snapshot.write=true}). - */ -@SpringBootTest( - classes = OpenApiDriftContractTest.OpenApiDriftTestApp.class, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = "management.server.port=0") -@AutoConfigureTestRestTemplate -class OpenApiDriftContractTest { - - private static final String SNAPSHOT_RESOURCE = "/openapi/worklogs-openapi-snapshot.json"; - private static final String SNAPSHOT_SOURCE_PATH = - "src/test/resources/openapi/worklogs-openapi-snapshot.json"; - private static final String WRITE_FLAG = "openapi.snapshot.write"; - - /** Sorts map keys so the snapshot is stable regardless of springdoc emission order. */ - private static final ObjectMapper CANONICAL = - JsonMapper.builder() - .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) - .enable(SerializationFeature.INDENT_OUTPUT) - .build(); - - @LocalServerPort int port; - - @Autowired TestRestTemplate rest; - - // The controller is the OpenAPI source; its collaborators are mocked so the slice needs no - // datasource / security (mirrors OpenApiSnapshotTest). - @MockitoBean CreateWorkLogUseCase createUseCase; - @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; - @MockitoBean UpdateWorkLogUseCase updateUseCase; - @MockitoBean DeleteWorkLogUseCase deleteUseCase; - @MockitoBean GetWorkLogUseCase getUseCase; - @MockitoBean ListWorkLogsUseCase listUseCase; - @MockitoBean GetRepoStatsUseCase repoStatsUseCase; - - @Test - void runtimeOpenapiDocMatchesCommittedSnapshot() throws IOException { - ResponseEntity response = - rest.getForEntity("http://localhost:" + port + "/v3/api-docs", String.class); - assertThat(response.getStatusCode().is2xxSuccessful()) - .as("springdoc must serve the generated OpenAPI document") - .isTrue(); - - String canonical = canonicalize(response.getBody()); - - if (Boolean.getBoolean(WRITE_FLAG)) { - writeSnapshot(canonical); - return; // refresh mode: baseline (re)generated; no assertion - } - - String committed = readCommittedSnapshot(); - if (committed == null) { - fail( - "No committed OpenAPI snapshot on the classpath (" - + SNAPSHOT_RESOURCE - + "). Generate the " - + "baseline with: ./gradlew openapiCheckSnapshot -PapproveOpenApiChange"); - } - - assertThat(canonical) - .as( - "the runtime springdoc /v3/api-docs drifted from the committed OpenAPI snapshot — if " - + "this change is intended, regenerate with " - + "./gradlew openapiCheckSnapshot -PapproveOpenApiChange") - .isEqualTo(canonicalize(committed)); - } - - @SuppressWarnings("unchecked") - private static String canonicalize(String json) throws IOException { - Object tree = CANONICAL.readValue(json, Object.class); - // Drop the environment-specific `servers` block: springdoc fills it with the - // RANDOM_PORT test URL (http://localhost:), which is harness noise. The drift - // gate tracks the API surface (paths / components / schemas), not the runtime base URL. - if (tree instanceof Map map) { - ((Map) map).remove("servers"); - } - return CANONICAL.writeValueAsString(tree); - } - - private String readCommittedSnapshot() throws IOException { - try (InputStream in = getClass().getResourceAsStream(SNAPSHOT_RESOURCE)) { - return in == null ? null : new String(in.readAllBytes(), StandardCharsets.UTF_8); - } - } - - private static void writeSnapshot(String canonical) throws IOException { - Path target = moduleDir().resolve(SNAPSHOT_SOURCE_PATH); - Files.createDirectories(target.getParent()); - Files.writeString(target, canonical + System.lineSeparator()); - } - - /** The sample-portfolio module dir (Gradle runs tests with the module dir as the working dir). */ - private static Path moduleDir() { - for (Path dir = Paths.get("").toAbsolutePath(); dir != null; dir = dir.getParent()) { - if (Files.isRegularFile(dir.resolve("build.gradle")) - && Files.isDirectory(dir.resolve("src/test/java/dev/caskeleton/sample/portfolio"))) { - return dir; - } - } - throw new IllegalStateException( - "sample-portfolio module dir not found from " + Paths.get("").toAbsolutePath()); - } - - @SpringBootConfiguration - @EnableAutoConfiguration( - exclude = { - SecurityAutoConfiguration.class, - UserDetailsServiceAutoConfiguration.class, - ServletWebSecurityAutoConfiguration.class, - OAuth2ResourceServerAutoConfiguration.class, - DataSourceAutoConfiguration.class, - HibernateJpaAutoConfiguration.class - }) - @Import({WorkLogController.class, OpenApiContractConfig.class}) - static class OpenApiDriftTestApp {} -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsControllerWireTest.java deleted file mode 100644 index bda2f3b..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/OperationsControllerWireTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.jayway.jsonpath.JsonPath; -import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; -import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; -import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.operation.SampleOperationStore; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; - -/** - * feature-api-contract-baseline D17 — LRO wire contract: {@code POST /worklogs:export} returns 202 - * + {@code Location} + envelope {@code data.{operationId,statusUrl}} with a PENDING status, and - * {@code GET /operations/{id}} returns the operation with a status from the 5-value enum (here - * terminal SUCCEEDED). - */ -@WebMvcTest( - controllers = OperationsController.class, - excludeAutoConfiguration = SecurityAutoConfiguration.class) -@AutoConfigureMockMvc(addFilters = false) -@Import({ - OperationsController.class, - SampleOperationStore.class, - GlobalExceptionHandler.class, - EnvelopeBodyAdvice.class -}) -class OperationsControllerWireTest { - - @Autowired MockMvc mvc; - @MockitoBean PresentationSettings presentationSettings; - - @Test - void exportReturns202LocationPendingThenPollReachesTerminalStatus() throws Exception { - when(presentationSettings.apiBasePath()).thenReturn(""); - - MvcResult accepted = - mvc.perform(post("/worklogs:export")) - .andExpect(status().isAccepted()) - .andExpect(header().exists("Location")) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.operationId").isNotEmpty()) - .andExpect(jsonPath("$.data.statusUrl").isNotEmpty()) - .andExpect(jsonPath("$.data.status").value("PENDING")) - .andReturn(); - - String operationId = - JsonPath.read(accepted.getResponse().getContentAsString(), "$.data.operationId"); - - mvc.perform(get("/operations/" + operationId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.operationId").value(operationId)) - .andExpect(jsonPath("$.data.status").value("SUCCEEDED")) - .andExpect(jsonPath("$.data.result.exportedCount").value(0)); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java deleted file mode 100644 index 9d5672d..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterControllerWireTest.java +++ /dev/null @@ -1,246 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; -import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; -import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; -import dev.caskeleton.application.storage.StoredObject; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.error.DomainExceptionHandler; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.json.PosterIdSerializer; -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.application.poster.ArchivePosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.CreatePosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.DeletePosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.GetPosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.ListPostersUseCase; -import dev.caskeleton.sample.portfolio.application.poster.PublishPosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.UpdatePosterUseCase; -import dev.caskeleton.sample.portfolio.application.poster.UploadPosterImageUseCase; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterInvariantException; -import dev.caskeleton.sample.portfolio.domain.poster.PosterPage; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; -import java.net.URI; -import java.util.List; -import java.util.Locale; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@WebMvcTest( - controllers = {PosterController.class, LegacyPosterImageController.class}, - excludeAutoConfiguration = SecurityAutoConfiguration.class) -@AutoConfigureMockMvc(addFilters = false) -@Import({ - PosterController.class, - LegacyPosterImageController.class, - GlobalExceptionHandler.class, - DomainExceptionHandler.class, - EnvelopeBodyAdvice.class, - JacksonNullableConfig.class, - PosterIdSerializer.class -}) -@TestPropertySource( - properties = { - "spring.jackson.deserialization.fail-on-unknown-properties=true", - "spring.jackson.deserialization.fail-on-null-for-primitives=true", - "app.poster-image.api.mode=legacy" - }) -class PosterControllerWireTest { - - private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; - - @Autowired MockMvc mvc; - @MockitoBean CreatePosterUseCase createUseCase; - @MockitoBean UpdatePosterUseCase updateUseCase; - @MockitoBean UploadPosterImageUseCase uploadImageUseCase; - @MockitoBean PublishPosterUseCase publishUseCase; - @MockitoBean ArchivePosterUseCase archiveUseCase; - @MockitoBean DeletePosterUseCase deleteUseCase; - @MockitoBean GetPosterUseCase getUseCase; - @MockitoBean ListPostersUseCase listUseCase; - - private Poster poster(String title, PosterStatus status, String imageKey) { - return Poster.rehydrate(PosterId.of(ID), title, "7월 한정", imageKey, status, 0L); - } - - // ---- list ---- - - @Test - void listIsWrappedInEnvelopeWithPageMeta() throws Exception { - when(listUseCase.handle(any())) - .thenReturn(new PosterPage(List.of(poster("여름 세일", PosterStatus.DRAFT, null)), 1)); - mvc.perform(get("/posters")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data[0].id").value(ID)) - .andExpect(jsonPath("$.data[0].title").value("여름 세일")) - .andExpect(jsonPath("$.meta.page.total").value(1)); - } - - @Test - void emptyListIsDataArrayNotNullWithZeroTotal() throws Exception { - when(listUseCase.handle(any())).thenReturn(new PosterPage(List.of(), 0)); - mvc.perform(get("/posters")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(0)) - .andExpect(jsonPath("$.meta.page.total").value(0)); - } - - @Test - void sizeOverCapIs400Validation() throws Exception { - mvc.perform(get("/posters").param("size", "101")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); - } - - @Test - void nativeSortSyntaxIsEchoedInMeta() throws Exception { - when(listUseCase.handle(any())) - .thenReturn(new PosterPage(List.of(poster("a", PosterStatus.DRAFT, null)), 1)); - mvc.perform(get("/posters").param("sort", "title,desc")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.meta.page.sort").value("title,desc")); - } - - // ---- get ---- - - @Test - void getReturnsPoster() throws Exception { - when(getUseCase.handle(any())) - .thenReturn(poster("여름 세일", PosterStatus.PUBLISHED, "posters/x/image")); - mvc.perform(get("/posters/" + ID)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.id").value(ID)) - .andExpect(jsonPath("$.data.status").value("PUBLISHED")) - .andExpect(jsonPath("$.data.imageKey").value("posters/x/image")); - } - - @Test - void getMissingReturns404PosterNotFound() throws Exception { - when(getUseCase.handle(any())).thenThrow(new PosterNotFoundException(PosterId.of(ID))); - mvc.perform(get("/posters/" + ID)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.error.code").value("POSTER_NOT_FOUND")); - } - - @Test - void getWithMalformedIdReturns400BadParameter() throws Exception { - mvc.perform(get("/posters/not-a-valid-uuid")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("BAD_PARAMETER")); - } - - @Test - void getAcceptsMixedCaseUuidPathAndNormalizes() throws Exception { - when(getUseCase.handle(any())).thenReturn(poster("여름 세일", PosterStatus.DRAFT, null)); - mvc.perform(get("/posters/" + ID.toUpperCase(Locale.ROOT))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.id").value(ID)); - } - - // ---- create / update ---- - - @Test - void createReturns201WithBody() throws Exception { - when(createUseCase.handle(any())).thenReturn(poster("여름 세일", PosterStatus.DRAFT, null)); - mvc.perform( - post("/posters") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"여름 세일\",\"caption\":\"7월 한정\"}")) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.title").value("여름 세일")); - } - - @Test - void createWithBlankTitleFailsValidation() throws Exception { - mvc.perform( - post("/posters").contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); - } - - @Test - void patchReplacesTitle() throws Exception { - when(updateUseCase.handle(any())).thenReturn(poster("새 제목", PosterStatus.DRAFT, null)); - mvc.perform( - patch("/posters/" + ID) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"새 제목\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.title").value("새 제목")); - } - - // ---- lifecycle: publish (the object-storage domain invariant) / archive ---- - - @Test - void publishWithoutImageReturns400ImageRequired() throws Exception { - when(publishUseCase.handle(any())) - .thenThrow(new PosterInvariantException(PosterInvariantException.Reason.IMAGE_REQUIRED)); - mvc.perform(post("/posters/" + ID + "/publish")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("POSTER_IMAGE_REQUIRED")); - } - - @Test - void publishReturnsPublishedPoster() throws Exception { - when(publishUseCase.handle(any())) - .thenReturn(poster("여름 세일", PosterStatus.PUBLISHED, "posters/x/image")); - mvc.perform(post("/posters/" + ID + "/publish")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("PUBLISHED")); - } - - @Test - void archiveReturnsArchivedPoster() throws Exception { - when(archiveUseCase.handle(any())) - .thenReturn(poster("여름 세일", PosterStatus.ARCHIVED, "posters/x/image")); - mvc.perform(post("/posters/" + ID + "/archive")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("ARCHIVED")); - } - - // ---- image upload (multipart) ---- - - @Test - // This single receipt assertion remains while the whole-byte migration is active. - @SuppressWarnings("removal") - void uploadImageReturnsStoredObjectReceipt() throws Exception { - when(uploadImageUseCase.handle(any())) - .thenReturn( - new StoredObject("posters/" + ID + "/image", 3L, "image/png", URI.create("file:///x"))); - MockMultipartFile file = - new MockMultipartFile("file", "banner.png", "image/png", new byte[] {1, 2, 3}); - mvc.perform(multipart("/posters/" + ID + "/image").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.key").value("posters/" + ID + "/image")) - .andExpect(jsonPath("$.data.contentType").value("image/png")); - } - - // ---- delete ---- - - @Test - void deleteReturns204() throws Exception { - mvc.perform(delete("/posters/" + ID)).andExpect(status().isNoContent()); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java deleted file mode 100644 index f71271c..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/PosterImagePublicationControllerWireTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectDigest; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.PosterImagePublicationStatusResponse; -import dev.caskeleton.sample.portfolio.application.poster.GetPosterImagePublicationStatusUseCase; -import dev.caskeleton.sample.portfolio.application.poster.PublishPosterImageUseCase; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationReservation; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationStatus; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.nio.charset.StandardCharsets; -import java.util.Set; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.mock.web.MockMultipartFile; - -class PosterImagePublicationControllerWireTest { - - private static final PosterId POSTER = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - private static final ObjectOperationId OPERATION = - ObjectOperationId.of("0190bd6e-7c3e-7abc-8def-012345678901"); - - @Test - void acceptedAndReadyResponsesExposeNoStorageLocator() { - PublishPosterImageUseCase publish = mock(PublishPosterImageUseCase.class); - GetPosterImagePublicationStatusUseCase status = - mock(GetPosterImagePublicationStatusUseCase.class); - when(publish.handle(any())) - .thenReturn(new PosterImagePublicationReservation(POSTER, OPERATION)); - ObjectContentIdentity content = - new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))); - when(status.handle(any())) - .thenReturn( - new PosterImagePublicationStatus( - POSTER, - OPERATION, - PosterImageUploadIntentState.READY, - PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"), - content, - ObjectMediaType.of("image/png"))); - PosterImagePublicationController controller = - new PosterImagePublicationController(publish, status); - var principal = new AuthenticatedPrincipal("principal-a", "a@example.test", Set.of("user")); - var file = - new MockMultipartFile( - "file", "poster.png", "image/png", "hello".getBytes(StandardCharsets.UTF_8)); - - var accepted = - controller.publish( - POSTER.value(), "client-key", 0, content.fullDigest().base64Value(), file, principal); - PosterImagePublicationStatusResponse ready = - controller.status(POSTER.value(), OPERATION.value(), principal); - - assertThat(accepted.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); - assertThat(accepted.getHeaders().getLocation().toString()) - .isEqualTo("/posters/" + POSTER.value() + "/imagePublications/" + OPERATION.value()); - assertThat(ready.reference()).startsWith("osr1."); - assertThat(ready.toString()) - .doesNotContain("s3://", "file://", "bucket", "presigned", "providerVersion"); - } - - @Test - void missingIdempotencyKeyIsRejectedBeforeUseCaseLookup() { - PosterImagePublicationController controller = - new PosterImagePublicationController( - mock(PublishPosterImageUseCase.class), - mock(GetPosterImagePublicationStatusUseCase.class)); - - assertThatIllegalArgumentException() - .isThrownBy( - () -> - controller.publish( - POSTER.value(), - " ", - 0, - ObjectDigest.sha256(new byte[] {1}).base64Value(), - new MockMultipartFile("file", "x.png", "image/png", new byte[] {1}), - new AuthenticatedPrincipal("principal-a", null, Set.of()))); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogControllerWireTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogControllerWireTest.java deleted file mode 100644 index 43f6b91..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/controller/WorkLogControllerWireTest.java +++ /dev/null @@ -1,447 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.controller; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; -import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; -import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.error.DomainExceptionHandler; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.json.WorkLogIdSerializer; -import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; -import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.domain.worklog.Period; -import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; -import java.time.LocalDate; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@WebMvcTest( - controllers = WorkLogController.class, - excludeAutoConfiguration = SecurityAutoConfiguration.class) -@AutoConfigureMockMvc(addFilters = false) -@Import({ - WorkLogController.class, - GlobalExceptionHandler.class, - DomainExceptionHandler.class, - EnvelopeBodyAdvice.class, - JacksonNullableConfig.class, - WorkLogIdSerializer.class -}) -@TestPropertySource( - properties = { - "spring.jackson.deserialization.fail-on-unknown-properties=true", - "spring.jackson.deserialization.fail-on-null-for-primitives=true", - "spring.jackson.deserialization.fail-on-ignored-properties=true", - "spring.jackson.datatype.enum.read-unknown-enum-values-as-null=false" - }) -class WorkLogControllerWireTest { - - private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; // D19 fixture - - @Autowired MockMvc mvc; - @MockitoBean CreateWorkLogUseCase createUseCase; - @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; - @MockitoBean UpdateWorkLogUseCase updateUseCase; - @MockitoBean DeleteWorkLogUseCase deleteUseCase; - @MockitoBean GetWorkLogUseCase getUseCase; - @MockitoBean ListWorkLogsUseCase listUseCase; - @MockitoBean GetRepoStatsUseCase repoStatsUseCase; - - private WorkLog sample(String title) { - // version 0 → ETag W/"0" at the boundary - return WorkLog.rehydrate( - WorkLogId.of(ID), - title, - WorkCategory.DATABASE, - "s", - "c", - List.of("postgres"), - List.of("https://example.com"), - new Period(LocalDate.of(2025, 1, 1), null), - 0L); - } - - // ---- D7/D18/D20/D21 list ---- - - @Test - void listIsWrappedInEnvelopeWithPageMeta() throws Exception { - when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(sample("DB 튜닝")), 1)); - mvc.perform(get("/worklogs")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data[0].title").value("DB 튜닝")) - .andExpect(jsonPath("$.data[0].id").value(ID)) - .andExpect(jsonPath("$.meta.page.number").value(0)) - .andExpect(jsonPath("$.meta.page.size").value(20)) - .andExpect(jsonPath("$.meta.page.total").value(1)); - } - - @Test - void emptyListIsDataArrayNotNullWithZeroTotal() throws Exception { - when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(), 0)); - mvc.perform(get("/worklogs")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(0)) - .andExpect(jsonPath("$.meta.page.total").value(0)); - } - - @Test - void sizeOverCapIs400ValidationWithFieldAndCode() throws Exception { - mvc.perform(get("/worklogs").param("size", "101")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")) - .andExpect(jsonPath("$.error.details.field").value("size")) - .andExpect(jsonPath("$.error.details.code").value("SIZE_EXCEEDS_MAX")); - } - - @Test - void nativeSortSyntaxIsAcceptedAndEchoedInMeta() throws Exception { - when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(sample("a")), 1)); - mvc.perform(get("/worklogs").param("sort", "title,desc")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.meta.page.sort").value("title,desc")); - } - - @Test - void jsonapiPrefixSortSyntaxIsRejected400() throws Exception { - mvc.perform(get("/worklogs").param("sort", "-title")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); - } - - @Test - void flatCategoryFilterIsAccepted() throws Exception { - when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(sample("a")), 1)); - mvc.perform(get("/worklogs").param("category", "DATABASE")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.length()").value(1)); - } - - // ---- D15 conditional requests ---- - - @Test - void getEmitsEtagHeader() throws Exception { - when(getUseCase.handle(any())).thenReturn(sample("DB 튜닝")); - mvc.perform(get("/worklogs/" + ID)) - .andExpect(status().isOk()) - .andExpect(header().string("ETag", "W/\"0\"")) - .andExpect(jsonPath("$.data.id").value(ID)); - } - - @Test - void getWithMatchingIfNoneMatchReturns304NoBody() throws Exception { - when(getUseCase.handle(any())).thenReturn(sample("DB 튜닝")); - mvc.perform(get("/worklogs/" + ID).header("If-None-Match", "W/\"0\"")) - .andExpect(status().isNotModified()); - } - - @Test - void patchWithStaleIfMatchReturns412() throws Exception { - when(getUseCase.handle(any())).thenReturn(sample("current")); - mvc.perform( - patch("/worklogs/" + ID) - .header("If-Match", "W/\"99\"") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"x\"}")) - .andExpect(status().isPreconditionFailed()) - .andExpect(jsonPath("$.error.code").value("PRECONDITION_FAILED")); - } - - @Test - void patchWithMatchingIfMatchAppliesUpdate() throws Exception { - when(getUseCase.handle(any())).thenReturn(sample("current")); - when(updateUseCase.handle(any())).thenReturn(sample("new title")); - mvc.perform( - patch("/worklogs/" + ID) - .header("If-Match", "W/\"0\"") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"new title\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.title").value("new title")); - } - - // ---- D13 HEAD mirrors GET ---- - - @Test - void headOnGetEndpointIsSupportedNot405() throws Exception { - // D13: a GET-supporting endpoint MUST also answer HEAD (Spring MVC mirrors it), - // i.e. 200 — never 405/404. (Body stripping is the container's job and is not - // reproduced by MockMvc, so we assert the mirrored status, not an empty body.) - when(getUseCase.handle(any())).thenReturn(sample("DB 튜닝")); - mvc.perform(head("/worklogs/" + ID)).andExpect(status().isOk()); - } - - // ---- D21 filter DSL is not parsed (silently ignored) ---- - - @Test - void filterDslIsIgnoredNotParsed() throws Exception { - when(listUseCase.handle(any())).thenReturn(new WorkLogPage(List.of(sample("a")), 1)); - mvc.perform( - get("/worklogs").param("filter", "status==OPEN")) // RSQL/DSL → unknown param, ignored - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.length()").value(1)); - } - - // ---- D3 Idempotency-Key accepted (server-tolerant) on POST ---- - - @Test - void postAcceptsIdempotencyKeyHeader() throws Exception { - when(createUseCase.handle(any())).thenReturn(sample("a")); - mvc.perform( - post("/worklogs") - .header("Idempotency-Key", "key-abc") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"title\":\"a\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)); - } - - // ---- existing id / validation / mapping behaviours (paths renamed to /worklogs) ---- - - @Test - void getMissingReturns404WorklogNotFound() throws Exception { - when(getUseCase.handle(any())).thenThrow(new WorkLogNotFoundException(WorkLogId.of(ID))); - mvc.perform(get("/worklogs/" + ID)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.error.code").value("WORKLOG_NOT_FOUND")); - } - - @Test - void getWithMalformedIdReturns400BadParameter() throws Exception { - mvc.perform(get("/worklogs/not-a-valid-uuid")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("BAD_PARAMETER")); - } - - @Test - void getAcceptsMixedCaseUuidPathAndNormalizesD3() throws Exception { - when(getUseCase.handle(any())).thenReturn(sample("DB 튜닝")); - mvc.perform(get("/worklogs/" + ID.toUpperCase(Locale.ROOT))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.id").value(ID)); - } - - @Test - void createWithBlankTitleFailsValidation() throws Exception { - mvc.perform( - post("/worklogs") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"title\":\"\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); - } - - @Test - void createWithUnknownFieldIsRejectedB1() throws Exception { - mvc.perform( - post("/worklogs") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"title\":\"x\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\",\"oops\":\"y\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")) - .andExpect(jsonPath("$.error.details.cause").value("UnrecognizedPropertyException")); - } - - @Test - void createWithUnmappableLinkRoutesToMappingFailedB3() throws Exception { - mvc.perform( - post("/worklogs") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"title\":\"x\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\",\"links\":[\"not a uri\"]}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("MAPPING_FAILED")); - } - - @Test - void patchReplacesOnlyPresentFieldsB2() throws Exception { - when(updateUseCase.handle(any())).thenReturn(sample("new title")); - mvc.perform( - patch("/worklogs/" + ID) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"new title\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.title").value("new title")) - .andExpect(jsonPath("$.data.id").value(ID)); - } - - @Test - void patchCanAdvanceStatusAndResponseExposesMinimumModel() throws Exception { - WorkLog started = sample("status"); - started.startProgress(); - when(updateUseCase.handle(any())).thenReturn(started); - - mvc.perform( - patch("/worklogs/" + ID) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\":\"IN_PROGRESS\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("IN_PROGRESS")) - .andExpect(jsonPath("$.data.owner").doesNotExist()); - } - - @Test - void closedWorklogUpdateReturnsConflictEnvelope() throws Exception { - when(updateUseCase.handle(any())) - .thenThrow( - new WorkLogInvariantException( - WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION)); - - mvc.perform( - patch("/worklogs/" + ID) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"late edit\"}")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.error.code").value("WORKLOG_CONFLICT")); - } - - @Test - void patchWithExplicitNullSummaryIsAcceptedB2() throws Exception { - when(updateUseCase.handle(any())).thenReturn(sample("t")); - mvc.perform( - patch("/worklogs/" + ID) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"summary\":null}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)); - } - - @Test - void repoStatsEndpointReturnsDomainViaEnvelope() throws Exception { - when(repoStatsUseCase.handle(any())) - .thenReturn(new RepoStats("owner/repo", 42, "2025-01-01T00:00:00Z")); - mvc.perform(get("/worklogs/repoStats").param("repoUrl", "https://github.com/owner/repo")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.stars").value(42)); - } - - @Test - void createCapturesAuthenticatedPrincipalAsOwner() throws Exception { - TestingAuthenticationToken token = - new TestingAuthenticationToken( - new AuthenticatedPrincipal("sub-7", "u@example.com", Set.of("user")), "n/a"); - token.setAuthenticated(true); - SecurityContextHolder.getContext().setAuthentication(token); - ArgumentCaptor captor = - ArgumentCaptor.forClass(CreateWorkLogCommand.class); - when(createUseCase.handle(captor.capture())).thenReturn(sample("t")); - try { - mvc.perform( - post("/worklogs") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"title\":\"t\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) - .andExpect(status().isOk()); - assertThat(captor.getValue().owner()).isEqualTo("sub-7"); - } finally { - SecurityContextHolder.clearContext(); - } - } - - @Test - void createResponseDoesNotExposeOwner() throws Exception { - // privacy: owner is the raw principal id; it must not leak in the response payload - // (pseudonymization is delegated to feature-data-retention-privacy-contract). - when(createUseCase.handle(any())).thenReturn(sample("t")); - mvc.perform( - post("/worklogs") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"title\":\"t\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.owner").doesNotExist()); - } - - // ---- D23 atomic batch create ---- - - @Test - void batchCreateAllOkReturnsArray() throws Exception { - when(batchCreateUseCase.handle(any())).thenReturn(List.of(sample("a"), sample("b"))); - mvc.perform( - post("/worklogs:batchCreate") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"requests\":[" - + "{\"title\":\"a\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}," - + "{\"title\":\"b\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}]}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.length()").value(2)); - } - - @Test - void batchOverSizeCapIs400() throws Exception { - String item = "{\"title\":\"a\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}"; - String oversized = - "{\"requests\":[" - + IntStream.rangeClosed(1, WorkLogController.MAX_BATCH_SIZE + 1) - .mapToObj(i -> item) - .collect(Collectors.joining(",")) - + "]}"; - mvc.perform( - post("/worklogs:batchCreate") - .contentType(MediaType.APPLICATION_JSON) - .content(oversized)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")); - } - - @Test - void batchCreateIsAtomicOneBadItemFailsWholeBatch() throws Exception { - // a single unmappable link fails mapping before any create → whole batch 400, no partial - // success (D23) - mvc.perform( - post("/worklogs:batchCreate") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"requests\":[" - + "{\"title\":\"a\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}," - + "{\"title\":\"b\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\",\"links\":[\"bad uri\"]}]}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.error.code").value("MAPPING_FAILED")); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/date/DateHeaderContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/date/DateHeaderContractTest.java deleted file mode 100644 index 884fca0..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/date/DateHeaderContractTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.date; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -import org.springframework.boot.resttestclient.TestRestTemplate; -import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; -import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; -import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * feature-api-contract-baseline D24 — every response carries a {@code Date} header. The servlet - * container (Tomcat) emits it by default and nothing in the skeleton disables it; this boots a real - * container and checks the header across a success (200) and an error (404) response — the - * status-matrix that a mock MVC test cannot exercise (MockMvc never goes through Tomcat). - */ -@SpringBootTest( - classes = DateHeaderContractTest.DateProbeApp.class, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = "management.server.port=0") -@AutoConfigureTestRestTemplate -class DateHeaderContractTest { - - @LocalServerPort int port; - - @Autowired TestRestTemplate rest; - - @Test - void successResponseCarriesADateHeader() { - ResponseEntity response = - rest.getForEntity("http://localhost:" + port + "/__date/ok", String.class); - assertThat(response.getStatusCode().value()).isEqualTo(200); - assertThat(response.getHeaders().getFirst("Date")).isNotBlank(); - } - - @Test - void errorResponseAlsoCarriesADateHeader() { - ResponseEntity response = - rest.getForEntity("http://localhost:" + port + "/__date/does-not-exist", String.class); - assertThat(response.getStatusCode().value()).isEqualTo(404); - assertThat(response.getHeaders().getFirst("Date")).isNotBlank(); - } - - @SpringBootConfiguration - @EnableAutoConfiguration( - exclude = { - SecurityAutoConfiguration.class, - UserDetailsServiceAutoConfiguration.class, - ServletWebSecurityAutoConfiguration.class, - OAuth2ResourceServerAutoConfiguration.class, - DataSourceAutoConfiguration.class, - HibernateJpaAutoConfiguration.class - }) - static class DateProbeApp { - - @RestController - static class DateProbeController { - @GetMapping("/__date/ok") - String ok() { - return "ok"; - } - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/BasicPolymorphicTypeValidatorAllowlistTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/BasicPolymorphicTypeValidatorAllowlistTest.java deleted file mode 100644 index a8c2422..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/BasicPolymorphicTypeValidatorAllowlistTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; -import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; -import org.junit.jupiter.api.Test; - -/** - * B5 alternative pattern: {@link BasicPolymorphicTypeValidator} allowlist. - * - *

    The branch note records two acceptable polymorphic-deserialization shapes: - * - *

      - *
    1. {@code @JsonTypeInfo(use = NAME)} + {@code @JsonSubTypes} on a sealed interface — see - * {@link SamplePolymorphicRequest} + {@link SamplePolymorphicRequestTest}. - *
    2. {@link BasicPolymorphicTypeValidator} allowlist registered on the {@link - * ObjectMapper} — this test. - *
    - * - *

    Either shape blocks the CVE-2019-14379 RCE vector by refusing to deserialize subtypes that are - * not on the allowlist. We pin both because the second is the choice when the polymorphic root - * cannot carry annotations (e.g., third-party types). - */ -class BasicPolymorphicTypeValidatorAllowlistTest { - - interface Animal {} - - public static final class Cat implements Animal { - public String name; - - public Cat() {} - - public Cat(String name) { - this.name = name; - } - } - - public static final class Dog implements Animal { - public String name; - - public Dog() {} - - public Dog(String name) { - this.name = name; - } - } - - public static final class ForbiddenAnimal implements Animal { - public String name; - } - - private ObjectMapper allowlistMapper() { - // Allowlist only Cat and Dog as polymorphic subtypes of Animal. The validator - // refuses every other subclass by default — the security guarantee. - PolymorphicTypeValidator ptv = - BasicPolymorphicTypeValidator.builder() - .allowIfSubType(Cat.class) - .allowIfSubType(Dog.class) - .build(); - - ObjectMapper mapper = new ObjectMapper(); - mapper.activateDefaultTyping( - ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); - return mapper; - } - - @Test - void allowlistedCatDeserializes() throws Exception { - ObjectMapper mapper = allowlistMapper(); - String json = - """ - ["dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.BasicPolymorphicTypeValidatorAllowlistTest$Cat", - {"name":"Whiskers"}] - """; - - Animal animal = mapper.readValue(json, Animal.class); - - assertThat(animal).isInstanceOf(Cat.class); - assertThat(((Cat) animal).name).isEqualTo("Whiskers"); - } - - @Test - void allowlistedDogDeserializes() throws Exception { - ObjectMapper mapper = allowlistMapper(); - String json = - """ - ["dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.BasicPolymorphicTypeValidatorAllowlistTest$Dog", - {"name":"Rex"}] - """; - - Animal animal = mapper.readValue(json, Animal.class); - - assertThat(animal).isInstanceOf(Dog.class); - assertThat(((Dog) animal).name).isEqualTo("Rex"); - } - - @Test - void nonAllowlistedSubtypeIsRejected() { - ObjectMapper mapper = allowlistMapper(); - String json = - """ - ["dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request.BasicPolymorphicTypeValidatorAllowlistTest$ForbiddenAnimal", - {"name":"trojan"}] - """; - - assertThatThrownBy(() -> mapper.readValue(json, Animal.class)) - .as( - "B5: a type id outside the BasicPolymorphicTypeValidator allowlist must be " - + "refused — without this guarantee, CVE-2019-14379-class gadget chains " - + "would be deserializable") - .isInstanceOf(JsonMappingException.class); - } - - @Test - void arbitraryJdkClassIsRejectedEvenWithoutExplicitAttack() { - ObjectMapper mapper = allowlistMapper(); - // java.io.File is a classic gadget-class candidate. Any non-allowlisted - // class must be refused regardless of whether it would actually exploit. - String json = - """ - ["java.io.File", "/tmp/evil"] - """; - - assertThatThrownBy(() -> mapper.readValue(json, Animal.class)) - .as("B5: arbitrary JDK classes must be refused by the allowlist") - .isInstanceOf(JsonMappingException.class); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequestTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequestTest.java deleted file mode 100644 index 6592831..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/request/SamplePolymorphicRequestTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.request; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; -import org.junit.jupiter.api.Test; - -/** - * B5: prove that the {@code @JsonTypeInfo} + {@code @JsonSubTypes} allowlist accepts each declared - * subtype, rejects unknown discriminator values, and rejects payloads missing the discriminator - * entirely. - * - *

    Without this test the sealed/allowlist pattern is "documented but not pinned" — a future - * maintainer removing one annotation would not see the regression until a security review. - */ -class SamplePolymorphicRequestTest { - - private final ObjectMapper mapper = new ObjectMapper(); - - @Test - void allowlistedTextSubtypeDeserializes() throws Exception { - String json = - """ - {"kind":"text","title":"hello","body":"world"} - """; - - SamplePolymorphicRequest req = mapper.readValue(json, SamplePolymorphicRequest.class); - - assertThat(req).isInstanceOf(SamplePolymorphicRequest.Text.class); - assertThat(((SamplePolymorphicRequest.Text) req).title()).isEqualTo("hello"); - assertThat(((SamplePolymorphicRequest.Text) req).body()).isEqualTo("world"); - } - - @Test - void allowlistedImageSubtypeDeserializes() throws Exception { - String json = - """ - {"kind":"image","title":"avatar","imageUrl":"https://example.test/a.png"} - """; - - SamplePolymorphicRequest req = mapper.readValue(json, SamplePolymorphicRequest.class); - - assertThat(req).isInstanceOf(SamplePolymorphicRequest.Image.class); - assertThat(((SamplePolymorphicRequest.Image) req).imageUrl()) - .isEqualTo("https://example.test/a.png"); - } - - @Test - void unknownSubtypeIdIsRejected() { - String json = - """ - {"kind":"video","title":"x","url":"y"} - """; - - assertThatThrownBy(() -> mapper.readValue(json, SamplePolymorphicRequest.class)) - .as( - "B5: discriminator outside the @JsonSubTypes allowlist must throw, " - + "preventing arbitrary-class deserialization") - .isInstanceOf(InvalidTypeIdException.class); - } - - @Test - void missingDiscriminatorIsRejected() { - String json = - """ - {"title":"hello","body":"world"} - """; - - assertThatThrownBy(() -> mapper.readValue(json, SamplePolymorphicRequest.class)) - .as( - "B5: payloads without the type discriminator must not silently bind " - + "to a default subtype") - .isInstanceOf(JsonMappingException.class); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/BulkEnvelopeTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/BulkEnvelopeTest.java deleted file mode 100644 index 0bad86f..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/dto/response/BulkEnvelopeTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.shared.response.BulkEnvelope; -import dev.caskeleton.shared.response.BulkItemResult; -import dev.caskeleton.shared.response.ResponseMeta; -import java.util.List; -import org.assertj.core.api.InstanceOfAssertFactories; -import org.assertj.core.groups.Tuple; -import org.junit.jupiter.api.Test; - -/** - * B8: pin the three bulk-endpoint envelope shapes. - * - *

    The branch note's failure condition explicitly forbids {@code success: true} on any - * partial-failure response. The "all failed" case and the "partial" case must take the same - * envelope branch ({@code BATCH_PARTIAL_FAILURE}) so callers always inspect {@code details[]} when - * {@code success == false}. - */ -class BulkEnvelopeTest { - - @Test - void allSuccessProducesSuccessTrueEnvelope() { - BulkEnvelope env = - BulkEnvelope.allOk(List.of("a", "b", "c"), new ResponseMeta("req-1", "trace-1", "corr-1")); - - assertThat(env.success()).isTrue(); - assertThat(env.error()).as("success envelope must not carry error info").isNull(); - assertThat(env.results()).containsExactly("a", "b", "c"); - assertThat(env.meta().traceId()).isEqualTo("trace-1"); - } - - @Test - void partialFailureProducesBatchPartialFailureCodeWithDetails() { - BulkEnvelope env = - BulkEnvelope.partial( - List.of( - BulkItemResult.ok(0, "id-1"), - BulkItemResult.error(1, "DUPLICATE_EMAIL", "email already taken"), - BulkItemResult.ok(2, "id-3")), - new ResponseMeta("req-2", "trace-2", "corr-2")); - - assertThat(env.success()).as("partial failure must NOT report success = true").isFalse(); - assertThat(env.results()).as("partial envelope must carry details[], not results[]").isNull(); - assertThat(env.error().code()).isEqualTo("BATCH_PARTIAL_FAILURE"); - assertThat(env.error().retryable()) - .as("BATCH_PARTIAL_FAILURE is per-item; not a transient retryable signal") - .isFalse(); - assertThat(env.error().details()) - .asInstanceOf(InstanceOfAssertFactories.list(BulkItemResult.class)) - .extracting(BulkItemResult::index, BulkItemResult::status) - .containsExactly(Tuple.tuple(0, "ok"), Tuple.tuple(1, "error"), Tuple.tuple(2, "ok")); - } - - @Test - void allFailuresTakeTheSameBranchAsPartial() { - BulkEnvelope env = - BulkEnvelope.partial( - List.of( - BulkItemResult.error(0, "VALIDATION_FAILED", "name blank"), - BulkItemResult.error(1, "VALIDATION_FAILED", "name blank")), - new ResponseMeta("req-3", "trace-3", "corr-3")); - - assertThat(env.success()).isFalse(); - assertThat(env.error().code()).isEqualTo("BATCH_PARTIAL_FAILURE"); - assertThat(env.error().details()) - .as( - "all-failure case must use the same envelope shape as partial — " - + "callers should not have to handle a third variant") - .asInstanceOf(InstanceOfAssertFactories.list(BulkItemResult.class)) - .hasSize(2) - .allMatch(d -> "error".equals(d.status())); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java deleted file mode 100644 index 8c60557..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/DomainExceptionHandlerTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.application.exception.PosterNotFoundException; -import dev.caskeleton.sample.portfolio.application.exception.PosterTitleAlreadyExistsException; -import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterInvariantException; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; -import dev.caskeleton.shared.response.Envelope; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -class DomainExceptionHandlerTest { - - private final DomainExceptionHandler handler = new DomainExceptionHandler(); - - @Test - void worklogNotFoundMapsTo404Envelope() { - String id = "0190bd6e-7c3e-7abc-8def-0123456789ab"; - ResponseEntity> response = - handler.handleWorkLogNotFound(new WorkLogNotFoundException(WorkLogId.of(id))); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody().success()).isFalse(); - assertThat(response.getBody().error().code()).isEqualTo("WORKLOG_NOT_FOUND"); - assertThat(response.getBody().error().message()).isEqualTo("Work log was not found"); - assertThat(response.getBody().error().toString()).doesNotContain(id); - } - - @Test - void domainConflictMessagesExposeNoExceptionDiagnostics() { - ResponseEntity> workLog = - handler.handleWorkLogInvariant( - new WorkLogInvariantException( - WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION)); - ResponseEntity> poster = - handler.handlePosterInvariant( - new PosterInvariantException( - PosterInvariantException.Reason.INVALID_STATUS_TRANSITION)); - - assertThat(workLog.getBody()).isNotNull(); - assertThat(workLog.getBody().error().message()) - .isEqualTo("Work log conflicts with the current state"); - assertThat(workLog.getBody().error().message()).doesNotContain("CLOSED_WORKLOG_MUTATION"); - assertThat(poster.getBody()).isNotNull(); - assertThat(poster.getBody().error().message()) - .isEqualTo("Poster is not valid in the current state"); - assertThat(poster.getBody().error().message()).doesNotContain("INVALID_STATUS_TRANSITION"); - } - - @Test - void posterIdentifiersAndDuplicateTitlesDoNotReachPublicMessages() { - String id = "0190bd6e-7c3e-7abc-8def-fedcba987654"; - String title = "SECRET_CUSTOMER_PROJECT_TITLE"; - - ResponseEntity> notFound = - handler.handlePosterNotFound(new PosterNotFoundException(PosterId.of(id))); - ResponseEntity> duplicate = - handler.handlePosterTitleConflict(new PosterTitleAlreadyExistsException(title)); - - assertThat(notFound.getBody()).isNotNull(); - assertThat(notFound.getBody().error().message()).isEqualTo("Poster was not found"); - assertThat(notFound.getBody().error().toString()).doesNotContain(id); - assertThat(duplicate.getBody()).isNotNull(); - assertThat(duplicate.getBody().error().message()).isEqualTo("Poster title is already in use"); - assertThat(duplicate.getBody().error().toString()).doesNotContain(title); - } - - @Test - void posterImageInvariantUsesFixedCorrectiveMessage() { - ResponseEntity> response = - handler.handlePosterInvariant( - new PosterInvariantException(PosterInvariantException.Reason.IMAGE_REQUIRED)); - - assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody().error().code()).isEqualTo("POSTER_IMAGE_REQUIRED"); - assertThat(response.getBody().error().message()).isEqualTo("Poster image is required"); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeRegistryMappingTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeRegistryMappingTest.java deleted file mode 100644 index cfd43c3..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeRegistryMappingTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.yaml.snakeyaml.Yaml; - -/** Keeps sample-owned error-code registry checks inside the sample fixture module. */ -class PortfolioErrorCodeRegistryMappingTest { - - private static final String REPOSITORY_ROOT_PROPERTY = "ca.repository.root"; - private static final String REGISTRY_PATH = "docs/registries/error-codes.yaml"; - private static Map registryHttpStatusByCode; - - @BeforeAll - static void loadRegistry() throws Exception { - Path registry = requireRegistry(System.getProperty(REPOSITORY_ROOT_PROPERTY)); - registryHttpStatusByCode = new LinkedHashMap<>(); - try (InputStream in = Files.newInputStream(registry)) { - Map root = new Yaml().load(in); - @SuppressWarnings("unchecked") - List> errors = (List>) root.get("errors"); - for (Map row : errors) { - registryHttpStatusByCode.put( - (String) row.get("code"), ((Number) row.get("http_status")).intValue()); - } - } - } - - @Test - void sampleErrorCodesPresentInTheRegistryMatchTheirHttpStatus() { - for (PortfolioErrorCode code : PortfolioErrorCode.values()) { - Integer registryStatus = registryHttpStatusByCode.get(code.code()); - if (registryStatus != null) { - assertThat(code.httpStatus()) - .as("sample enum %s httpStatus must match error-codes.yaml", code.code()) - .isEqualTo(registryStatus); - } - } - } - - @Test - void missingRepositoryRootFailsClosedInsteadOfSkippingTheRegistryContract() { - assertThatThrownBy(() -> requireRegistry(null)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("ca.repository.root"); - } - - private static Path requireRegistry(String configuredRoot) { - if (configuredRoot == null || configuredRoot.isBlank()) { - throw new IllegalStateException( - "Required system property '" + REPOSITORY_ROOT_PROPERTY + "' is missing or blank"); - } - - final Path repositoryRoot; - try { - repositoryRoot = Path.of(configuredRoot).toRealPath(); - } catch (InvalidPathException | IOException exception) { - throw new IllegalStateException( - "Configured repository root is not resolvable: " + configuredRoot, exception); - } - if (!Files.isRegularFile(repositoryRoot.resolve("src/settings.gradle")) - || !Files.isRegularFile(repositoryRoot.resolve("src/config/architecture/modules.json"))) { - throw new IllegalStateException( - "Configured repository root is missing architecture sentinels: " + repositoryRoot); - } - - Path registry = repositoryRoot.resolve(REGISTRY_PATH).normalize(); - if (!registry.startsWith(repositoryRoot) || !Files.isRegularFile(registry)) { - throw new IllegalStateException( - "Required tracked registry is missing or not a regular file: " + REGISTRY_PATH); - } - try { - Path realRegistry = registry.toRealPath(); - if (!realRegistry.startsWith(repositoryRoot)) { - throw new IllegalStateException( - "Tracked registry symlink escapes repository root: " + REGISTRY_PATH); - } - return realRegistry; - } catch (IOException exception) { - throw new IllegalStateException( - "Required tracked registry is not resolvable: " + REGISTRY_PATH, exception); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeTest.java deleted file mode 100644 index d1c14e6..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/error/PortfolioErrorCodeTest.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.error; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.shared.error.Category; -import org.junit.jupiter.api.Test; - -class PortfolioErrorCodeTest { - - @Test - void worklogNotFoundIsNotFoundCategory404() { - assertThat(PortfolioErrorCode.WORKLOG_NOT_FOUND.category()).isEqualTo(Category.NOT_FOUND); - assertThat(PortfolioErrorCode.WORKLOG_NOT_FOUND.httpStatus()).isEqualTo(404); - assertThat(PortfolioErrorCode.WORKLOG_NOT_FOUND.retryable()).isFalse(); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcE2ETest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcE2ETest.java deleted file mode 100644 index 9f4581a..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcE2ETest.java +++ /dev/null @@ -1,174 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.filter; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; -import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; -import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; -import dev.caskeleton.adapter.inbound.web.filter.RequestLoggingFilter; -import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.error.DomainExceptionHandler; -import java.net.URI; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.slf4j.MDC; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; -import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -import org.springframework.boot.resttestclient.TestRestTemplate; -import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; -import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; -import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * B6 end-to-end pin. Boots a real Tomcat with {@code spring.threads.virtual.enabled=true}, sends an - * HTTP request to a self-contained {@link ContextProbeController} probe endpoint, and verifies - * that: - * - *

      - *
    • the request actually runs on a virtual carrier ({@code virtualThread = true} in the - * response); - *
    • the {@code requestId} the inbound filter set on MDC is visible to the controller (i.e., the - * filter ran on the same virtual thread and the MDC value propagated through the filter chain - * into the handler); - *
    • when the client supplies {@code X-Request-Id}, the filter honours it and the controller - * sees the same value. - *
    - * - *

    Auto-configuration is narrowed to skip security so the demo endpoint is reachable without a - * JWT — security is owned by a sibling branch and is not the contract under test here. - */ -@SpringBootTest( - classes = VirtualThreadMdcE2ETest.TestBootstrap.class, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = { - "spring.threads.virtual.enabled=true", - "spring.jackson.deserialization.fail-on-unknown-properties=true", - "management.server.port=0" - }) -@AutoConfigureTestRestTemplate -class VirtualThreadMdcE2ETest { - - private static final ParameterizedTypeReference> ENVELOPE_TYPE = - new ParameterizedTypeReference<>() {}; - - @LocalServerPort int port; - - @Autowired TestRestTemplate http; - - @Test - void filterPropagatesRequestIdToControllerUnderVirtualThreads() { - ResponseEntity> response = - http.exchange( - URI.create("http://localhost:" + port + "/test-probe/context"), - HttpMethod.GET, - null, - ENVELOPE_TYPE); - - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - @SuppressWarnings("unchecked") - Map body = (Map) response.getBody().get("data"); - assertThat(body).as("envelope.data must be the controller payload").isNotNull(); - - assertThat(body.get("virtualThread")) - .as( - "Tomcat must dispatch on a virtual thread when " - + "spring.threads.virtual.enabled=true") - .isEqualTo("true"); - - assertThat(body.get("requestId")) - .as( - "requestId set by RequestLoggingFilter must reach the controller — " - + "B6 propagation on a virtual carrier") - .isNotNull() - .isNotEqualTo("null"); - - String requestIdHeader = response.getHeaders().getFirst("X-Request-Id"); - assertThat(requestIdHeader) - .as("the filter echoes the requestId back as a header") - .isEqualTo(body.get("requestId")); - } - - @Test - void filterHonoursClientSuppliedRequestIdUnderVirtualThreads() { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Request-Id", "client-supplied-abc"); - ResponseEntity> response = - http.exchange( - new RequestEntity<>( - headers, - HttpMethod.GET, - URI.create("http://localhost:" + port + "/test-probe/context")), - ENVELOPE_TYPE); - - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - @SuppressWarnings("unchecked") - Map body = (Map) response.getBody().get("data"); - assertThat(body.get("requestId")).isEqualTo("client-supplied-abc"); - } - - @SpringBootConfiguration - @EnableAutoConfiguration( - exclude = { - SecurityAutoConfiguration.class, - UserDetailsServiceAutoConfiguration.class, - ServletWebSecurityAutoConfiguration.class, - OAuth2ResourceServerAutoConfiguration.class, - DataSourceAutoConfiguration.class, - HibernateJpaAutoConfiguration.class, - DataJpaRepositoriesAutoConfiguration.class - }) - @Import({ - ContextProbeController.class, - GlobalExceptionHandler.class, - DomainExceptionHandler.class, - EnvelopeBodyAdvice.class, - JacksonNullableConfig.class, - RequestLoggingFilter.class - }) - static class TestBootstrap { - - /** - * RequestLoggingFilter now requires a pseudonymizer; a simple stub suffices here (this pin - * tests MDC propagation, not pseudonymization). - */ - @Bean - UserPrincipalPseudonymizerPort userPrincipalPseudonymizer() { - return raw -> (raw == null || raw.isBlank()) ? null : "pseudo-" + raw; - } - } - - /** - * Self-contained probe controller. Reads MDC and the current carrier thread inside the handler - * (i.e., after the {@link RequestLoggingFilter} chain), mirroring the behaviour the B6 contract - * requires. Defined inside the test so the kept B6 e2e pin does not depend on any demo controller - * slated for removal. - */ - @RestController - static class ContextProbeController { - - @GetMapping("/test-probe/context") - public Map context() { - return Map.of( - "requestId", String.valueOf(MDC.get("request_id")), - "traceId", String.valueOf(MDC.get("trace_id")), - "virtualThread", String.valueOf(Thread.currentThread().isVirtual())); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcPropagationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcPropagationTest.java deleted file mode 100644 index 7b3ee44..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/filter/VirtualThreadMdcPropagationTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.filter; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; -import org.slf4j.MDC; - -/** - * B6 contract pin for virtual-thread context propagation. - * - *

    The branch note rejects {@link InheritableThreadLocal} and requires SLF4J 2.0+ MDC + {@code - * RequestContextHolder} for {@code requestId} / {@code traceId} propagation. SLF4J 2.0's {@code - * MDCAdapter} is virtual-thread aware (the value lives on each virtual thread's own {@code - * ThreadLocal}, not on the carrier), which gives the two guarantees this test pins: - * - *

      - *
    1. Same-thread propagation: an MDC value set early in a virtual thread is visible to - * subsequent code on that same virtual thread — i.e., a filter on a virtual thread can - * populate {@code requestId} and the controller body (still on that virtual thread) reads it - * back. - *
    2. No silent inheritance: a *new* virtual thread spawned inside the request thread does - * NOT inherit MDC. This is the property {@link InheritableThreadLocal} would break, which is - * why the {@code NO_INHERITABLE_THREAD_LOCAL} ArchUnit rule forbids it. Code that crosses - * thread boundaries must capture MDC explicitly (Micrometer Context Propagation). - *
    - * - *

    The two guarantees are exactly what filter-based MDC propagation depends on under virtual - * threads. If a future SLF4J upgrade or JVM change breaks either, this test fails before the change - * reaches production. - */ -class VirtualThreadMdcPropagationTest { - - @Test - void mdcValueSetInAVirtualThreadIsVisibleLaterOnThatSameThread() throws InterruptedException { - CountDownLatch done = new CountDownLatch(1); - AtomicReference observed = new AtomicReference<>(); - AtomicReference ranOnVirtualThread = new AtomicReference<>(); - - Thread vt = - Thread.startVirtualThread( - () -> { - try { - MDC.put("request_id", "req-abc"); - // Simulated handler body — still on the same virtual thread. - ranOnVirtualThread.set(Thread.currentThread().isVirtual()); - observed.set(MDC.get("request_id")); - } finally { - MDC.clear(); - done.countDown(); - } - }); - - assertThat(done.await(2, TimeUnit.SECONDS)) - .as("virtual thread must complete within timeout") - .isTrue(); - vt.join(); - assertThat(ranOnVirtualThread.get()) - .as("test precondition: the body really ran on a virtual thread") - .isTrue(); - assertThat(observed.get()) - .as("same-thread MDC propagation under virtual threads") - .isEqualTo("req-abc"); - } - - @Test - void mdcIsNotSilentlyInheritedByAFreshlySpawnedVirtualThread() throws InterruptedException { - AtomicReference childMdcValue = new AtomicReference<>("not-set"); - - try { - MDC.put("request_id", "parent-req"); - - Thread child = - Thread.startVirtualThread( - () -> { - // Fresh virtual thread spawned without explicit context capture. - childMdcValue.set(MDC.get("request_id")); - }); - child.join(2_000); - } finally { - MDC.clear(); - } - - assertThat(childMdcValue.get()) - .as( - "MDC must not propagate by inheritance (B6) — code crossing thread " - + "boundaries must capture context explicitly via Micrometer Context " - + "Propagation or equivalent. InheritableThreadLocal would break this " - + "contract.") - .isNull(); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializerTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializerTest.java deleted file mode 100644 index d32e520..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/json/WorkLogIdSerializerTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.json; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.json.JsonMapper; -import tools.jackson.databind.module.SimpleModule; - -class WorkLogIdSerializerTest { - - private final ObjectMapper mapper = - JsonMapper.builder() - .addModule(new SimpleModule().addSerializer(WorkLogId.class, new WorkLogIdSerializer())) - .build(); - - @Test - void serializesAsBareUuidStringNotRecordObject() throws Exception { - String json = mapper.writeValueAsString(WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab")); - assertThat(json).isEqualTo("\"0190bd6e-7c3e-7abc-8def-0123456789ab\""); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java deleted file mode 100644 index 9b50ab5..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/objectstorage/MultipartObjectContentProducerTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.objectstorage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.objectstorage.content.ObjectContentProductionContext; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectDigest; -import dev.caskeleton.application.outbound.CallBudget; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockMultipartFile; - -class MultipartObjectContentProducerTest { - - @Test - void streamsBoundedChunksAndRejectsAnyUseAfterTheRequestBoundCallback() throws Exception { - byte[] content = "hello-stream".getBytes(StandardCharsets.UTF_8); - var expected = new ObjectContentIdentity(content.length, ObjectDigest.sha256(content)); - var producer = - new MultipartObjectContentProducer( - new MockMultipartFile("file", "image.png", "image/png", content), expected); - var context = - ObjectContentProductionContext.open( - CallBudget.fromNow(Duration.ofMinutes(1)), CancellationView.never(), 4); - ByteArrayOutputStream delivered = new ByteArrayOutputStream(); - - producer.produce( - context, - (bytes, offset, length) -> { - assertThat(length).isLessThanOrEqualTo(4); - delivered.write(bytes, offset, length); - }); - context.invalidate(); - - assertThat(delivered.toByteArray()).containsExactly(content); - assertThatThrownBy( - () -> - producer.produce( - ObjectContentProductionContext.open( - CallBudget.fromNow(Duration.ofMinutes(1)), CancellationView.never(), 4), - (bytes, offset, length) -> {})) - .hasMessageContaining("already consumed"); - } - - @Test - void exactLengthOrSha256MismatchFailsClosed() { - byte[] content = "hello".getBytes(StandardCharsets.UTF_8); - var producer = - new MultipartObjectContentProducer( - new MockMultipartFile("file", content), - new ObjectContentIdentity(content.length, ObjectDigest.sha256(new byte[] {1}))); - var context = - ObjectContentProductionContext.open( - CallBudget.fromNow(Duration.ofMinutes(1)), CancellationView.never(), 8); - - assertThatThrownBy(() -> producer.produce(context, (bytes, offset, length) -> {})) - .hasMessageContaining("does not match"); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java deleted file mode 100644 index e41b580..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/openapi/OpenApiSnapshotTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.openapi; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.adapter.inbound.web.config.OpenApiContractConfig; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; -import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -import org.springframework.boot.resttestclient.TestRestTemplate; -import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; -import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; -import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.context.annotation.Import; -import org.springframework.http.ResponseEntity; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; - -/** - * feature-api-contract-baseline D10 — OpenAPI producer pin. Boots a real Tomcat with springdoc on - * the classpath and asserts the generated machine-readable contract at {@code /v3/api-docs} is - * served and reflects the actual {@link WorkLogController} surface (so a hand-maintained / stale - * schema cannot pass). The release-blocking drift gate against a committed snapshot is - * owned by feature-contract-verification-test-suite (planned). - */ -@SpringBootTest( - classes = OpenApiSnapshotTest.OpenApiTestApp.class, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = "management.server.port=0") -@AutoConfigureTestRestTemplate -class OpenApiSnapshotTest { - - @LocalServerPort int port; - - @Autowired TestRestTemplate rest; - - @Autowired ObjectMapper objectMapper; - - // The controller is the OpenAPI source; its collaborators are mocked so the slice - // needs no datasource / security. - @MockitoBean CreateWorkLogUseCase createUseCase; - @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; - @MockitoBean UpdateWorkLogUseCase updateUseCase; - @MockitoBean DeleteWorkLogUseCase deleteUseCase; - @MockitoBean GetWorkLogUseCase getUseCase; - @MockitoBean ListWorkLogsUseCase listUseCase; - @MockitoBean GetRepoStatsUseCase repoStatsUseCase; - - @Test - void apiDocsAreGeneratedAndDescribeTheWorklogsContract() throws Exception { - ResponseEntity response = - rest.getForEntity("http://localhost:" + port + "/v3/api-docs", String.class); - - assertThat(response.getStatusCode().is2xxSuccessful()) - .as("springdoc must serve the generated OpenAPI document (D10 producer)") - .isTrue(); - String body = response.getBody(); - assertThat(body).isNotNull(); - assertThat(body).contains("\"openapi\""); // OAS 3.x document - assertThat(body).contains("/worklogs"); // real controller surface, not a stale schema - assertThat(body).contains("/worklogs:batchCreate"); // AIP-136 colon-verb custom method (D23) - - JsonNode apiErrorDetailsSchema = - objectMapper - .readTree(body) - .path("components") - .path("schemas") - .path("ApiError") - .path("properties") - .path("details"); - JsonNode apiErrorDetailsType = apiErrorDetailsSchema.get("type"); - assertThat(apiErrorDetailsType == null ? null : apiErrorDetailsType.asString()) - .as("the public ApiError.details contract must remain an object schema") - .isEqualTo("object"); - } - - @SpringBootConfiguration - @EnableAutoConfiguration( - exclude = { - SecurityAutoConfiguration.class, - UserDetailsServiceAutoConfiguration.class, - ServletWebSecurityAutoConfiguration.class, - OAuth2ResourceServerAutoConfiguration.class, - DataSourceAutoConfiguration.class, - HibernateJpaAutoConfiguration.class - }) - @Import({WorkLogController.class, OpenApiContractConfig.class}) - static class OpenApiTestApp {} -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/versioning/VersioningPrefixTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/versioning/VersioningPrefixTest.java deleted file mode 100644 index 34b5ecb..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/inbound/web/versioning/VersioningPrefixTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.inbound.web.versioning; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig; -import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * feature-api-contract-baseline D2 — the API version prefix is applied to every controller mapping - * by {@link PresentationWebConfig}. With the prefix set to {@code /v1}, an endpoint mapped at - * {@code /probe} is reachable only at {@code /v1/probe}; the unversioned path 404s. This pins that - * an unversioned public endpoint cannot exist (the prefix is applied centrally, not - * per-controller). - */ -@WebMvcTest( - controllers = VersioningPrefixTest.VersionedProbeController.class, - excludeAutoConfiguration = SecurityAutoConfiguration.class) -@AutoConfigureMockMvc(addFilters = false) -@Import({PresentationWebConfig.class, VersioningPrefixTest.VersionedProbeController.class}) -@EnableConfigurationProperties(PresentationSettings.class) -@TestPropertySource(properties = "ca-skeleton.presentation.api-base-path=/v1") -class VersioningPrefixTest { - - @Autowired MockMvc mvc; - - @Test - void endpointIsReachableUnderTheVersionPrefix() throws Exception { - mvc.perform(get("/v1/probe")).andExpect(status().isOk()); - } - - @Test - void unversionedPathIsNotExposed() throws Exception { - mvc.perform(get("/probe")).andExpect(status().isNotFound()); - } - - @RestController - static class VersionedProbeController { - - @GetMapping("/probe") - Map probe() { - return Map.of("probe", "ok"); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java deleted file mode 100644 index 178c4cb..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/HmacPosterImageIdempotencyScopeDigesterTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class HmacPosterImageIdempotencyScopeDigesterTest { - - @Test - void lengthPrefixAndTenantPresencePreventAmbiguousScopeCollisions() { - HmacPosterImageIdempotencyScopeDigester digester = - new HmacPosterImageIdempotencyScopeDigester( - "key-v1", - Map.of("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8))); - - var first = - digester.digest(null, "principal-secret-ab", "use-case-secret-c", "idempotency-secret-d"); - var ambiguous = - digester.digest(null, "principal-secret-a", "buse-case-secret-c", "idempotency-secret-d"); - var tenant = - digester.digest( - "tenant-secret-a", "principal-secret-ab", "use-case-secret-c", "idempotency-secret-d"); - - assertThat(first).isNotEqualTo(ambiguous).isNotEqualTo(tenant); - assertThat(first.toString()) - .doesNotContain("principal-secret-ab", "use-case-secret-c", "idempotency-secret-d"); - } - - @Test - void retainedVersionsSupportOverlapFirstRotationWithoutChangingTheActiveDigest() { - Map keys = new LinkedHashMap<>(); - keys.put("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); - keys.put("key-v2", "abcdef0123456789abcdef0123456789".getBytes(StandardCharsets.UTF_8)); - HmacPosterImageIdempotencyScopeDigester digester = - new HmacPosterImageIdempotencyScopeDigester(() -> "key-v2", keys); - - assertThat(digester.digest(null, "p", "u", "k").keyVersion()).isEqualTo("key-v2"); - assertThat(digester.retainedDigests(null, "p", "u", "k")) - .extracting( - dev.caskeleton.sample.portfolio.application.posterimage - .PosterImageIdempotencyScopeDigest - ::keyVersion) - .containsExactly("key-v1", "key-v2"); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactoryTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactoryTest.java deleted file mode 100644 index c53534d..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/identifier/UuidWorkLogIdFactoryTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.identifier; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import org.junit.jupiter.api.Test; - -class UuidWorkLogIdFactoryTest { - - private final UuidWorkLogIdFactory factory = new UuidWorkLogIdFactory(); - - @Test - void newIdProducesAValidCanonicalWorklogId() { - WorkLogId id = factory.newId(); - // WorkLogId's constructor enforces the canonical UUID pattern, so a successful - // construction proves the generated value is canonical. - assertThat(id.value()) - .matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); - } - - @Test - void successiveIdsAreDistinct() { - assertThat(factory.newId()).isNotEqualTo(factory.newId()); - } - - @Test - void monotonicFactoryProducesStrictlyIncreasingIds() { - // getTimeOrderedEpochPlus1 guarantees each UUIDv7 sorts strictly after the previous one - // (time-ordered timestamp + monotonic counter). Because the canonical string is big-endian - // lowercase hex with hyphens at fixed positions, lexicographic order matches numeric order. - // Looping makes the property observable within a single millisecond and would fail loudly if - // the adapter were swapped to a non-monotonic generator — a two-element check would pass ~50% - // of the time in that regression. - String previous = factory.newId().value(); - for (int i = 0; i < 1_000; i++) { - String current = factory.newId().value(); - assertThat(current).isGreaterThan(previous); - previous = current; - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapperTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapperTest.java deleted file mode 100644 index 824a170..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/mapper/WorkLogPersistenceMapperTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.domain.worklog.Period; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogOwner; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; -import java.time.LocalDate; -import java.util.List; -import org.junit.jupiter.api.Test; - -class WorkLogPersistenceMapperTest { - - private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - - @Test - void roundTripPreservesFieldsIncludingIdViaUuid() { - WorkLog w = - WorkLog.create( - ID, - "t", - WorkCategory.BACKEND, - "s", - "c", - List.of("java"), - List.of("https://x"), - new Period(LocalDate.of(2025, 1, 1), null)); - WorkLog back = WorkLogPersistenceMapper.toDomain(WorkLogPersistenceMapper.toEntity(w)); - assertThat(back.id()).isEqualTo(ID); // UUID string -> uuid -> UUID string is lossless (D10) - assertThat(back.title()).isEqualTo("t"); - assertThat(back.techStack()).containsExactly("java"); - assertThat(back.period().isOngoing()).isTrue(); - assertThat(back.status()).isEqualTo(WorkLogStatus.OPEN); - assertThat(back.owner()).isEqualTo(WorkLogOwner.of("sample-owner")); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java deleted file mode 100644 index 63ec7e3..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterImageUploadIntentRepositoryIntegrationTest.java +++ /dev/null @@ -1,102 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dev.caskeleton.application.idempotency.RequestFingerprint; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationKey; -import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectDigest; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config.JpaConfig; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentConflictException; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; -import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.annotation.Import; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.TestPropertySource; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.postgresql.PostgreSQLContainer; - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Testcontainers -@Import({JpaConfig.class, PosterImageUploadIntentRepositoryAdapter.class}) -@TestPropertySource( - properties = { - "spring.flyway.enabled=true", - "spring.flyway.locations=classpath:db/migration/postgresql,classpath:db/sample-migration", - "spring.jpa.hibernate.ddl-auto=validate" - }) -class PosterImageUploadIntentRepositoryIntegrationTest { - - @Container @ServiceConnection - static final PostgreSQLContainer POSTGRES = PostgreSqlTestContainer.create(); - - @Autowired PosterImageUploadIntentRepositoryAdapter adapter; - @Autowired JdbcTemplate jdbc; - - @Test - void reserveReplayConflictAndRevisionCasUseOnlyNonReversibleScope() { - PosterImageUploadIntent reserved = adapter.createOrRead(proposed("client-request-a")); - - assertThat(reserved.state()).isEqualTo(PosterImageUploadIntentState.RESERVED); - assertThat(adapter.createOrRead(proposed("client-request-a"))).isEqualTo(reserved); - assertThatThrownBy(() -> adapter.createOrRead(proposed("different-body"))) - .isInstanceOf(PosterImageUploadIntentConflictException.class); - - PosterImageUploadIntent pending = - reserved.pending( - reserved.revision(), - ObjectStageHandle.parse("osh1.0123456789ab.0123456789abcdefghjkmnpqrs.f3226a9833"), - ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"), - "a".repeat(64), - 1, - Instant.parse("2026-07-29T00:01:00Z"), - Instant.parse("2026-07-29T00:00:01Z")); - - PosterImageUploadIntent saved = adapter.compareAndSet(reserved.revision(), pending); - - assertThat(saved.state()).isEqualTo(PosterImageUploadIntentState.PENDING); - assertThat(saved.revision()).isEqualTo(1); - assertThat( - jdbc.queryForObject( - "select idempotency_scope_digest from poster_image_upload_intent", String.class)) - .doesNotContain("client-request-a", "principal-a", "tenant-a"); - } - - private static PosterImageUploadIntent proposed(String fingerprintSource) { - Instant now = Instant.parse("2026-07-29T00:00:00Z"); - return PosterImageUploadIntent.reserve( - UUID.fromString("0190bd6e-7c3e-7abc-8def-012345678901"), - PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"), - new ObjectOperationKey( - ObjectDestinationId.of("poster"), - ObjectOperationEpoch.of("epoch-v1"), - ObjectOperationId.of("publication-42")), - new PosterImageIdempotencyScopeDigest("key-v1", "A".repeat(43)), - RequestFingerprint.ofSha256(fingerprintSource.getBytes(StandardCharsets.UTF_8)), - 0, - new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))), - ObjectMediaType.of("image/png"), - now, - now.plusSeconds(3600)); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java deleted file mode 100644 index 22d40be..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/PosterRepositoryAdapterIntegrationTest.java +++ /dev/null @@ -1,155 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config.JpaConfig; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import dev.caskeleton.sample.portfolio.domain.poster.PosterSortField; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; -import dev.caskeleton.sample.portfolio.domain.poster.PublishedPosterImageAttachment; -import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; -import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; -import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.TestPropertySource; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.postgresql.PostgreSQLContainer; - -/** - * Persistence-adapter INTEGRATION test: boots a disposable Postgres via Testcontainers and drives - * the real Flyway schema (V7 {@code poster}) + JPA mapping, so regressions a mock cannot see fail - * here: column mapping, {@code @Enumerated(STRING)}, the UUID string<->native-uuid - * conversion, audit stamping, and {@code @Version} assignment. {@code ddl-auto=validate} makes - * Hibernate verify {@code PosterEntity} against the Flyway-built schema (schema-drift gate). - * Mirrors {@code WorkLogRepositoryAdapterIntegrationTest}; skipped automatically when no Docker - * daemon is present. - */ -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Testcontainers(disabledWithoutDocker = true) -@Import({ - JpaConfig.class, - PosterRepositoryAdapter.class, - PosterRepositoryAdapterIntegrationTest.TestAuditConfig.class -}) -@TestPropertySource( - properties = { - "spring.flyway.enabled=true", - "spring.flyway.locations=classpath:db/migration/postgresql,classpath:db/sample-migration", - "spring.jpa.hibernate.ddl-auto=validate" - }) -class PosterRepositoryAdapterIntegrationTest { - - @Container @ServiceConnection - static final PostgreSQLContainer POSTGRES = PostgreSqlTestContainer.create(); - - private static final PosterId ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - - @Autowired PosterRepositoryAdapter adapter; - - private Poster sample() { - return Poster.create(ID, "여름 세일 배너", "7월 한정"); - } - - @Test - void saveThenFindByIdRoundTripsThroughRealPostgres(@Autowired TestEntityManager em) { - Poster saved = adapter.save(sample()); - assertThat(saved.version()).isNotNull(); // @Version assigned by Hibernate on INSERT - - em.flush(); - em.clear(); - - Optional found = adapter.findById(ID); - assertThat(found).isPresent(); - Poster p = found.get(); - assertThat(p.id()).isEqualTo(ID); // UUID string <-> native uuid - assertThat(p.title()).isEqualTo("여름 세일 배너"); - assertThat(p.status()).isEqualTo(PosterStatus.DRAFT); // @Enumerated(STRING) - assertThat(p.imageKey()).isNull(); - } - - @Test - void attachImageThenPublishIsPersisted(@Autowired TestEntityManager em) { - adapter.save(sample()); - em.flush(); - em.clear(); - - Poster loaded = adapter.findById(ID).orElseThrow(); - loaded.attachImage("posters/" + ID.value() + "/image"); - loaded.publish(); - adapter.save(loaded); // UPDATE path: reload existing audit + bump version - em.flush(); - em.clear(); - - Poster reread = adapter.findById(ID).orElseThrow(); - assertThat(reread.status()).isEqualTo(PosterStatus.PUBLISHED); - assertThat(reread.imageKey()).isEqualTo("posters/" + ID.value() + "/image"); - } - - @Test - void canonicalReadyReferenceUsesOnlyTheAdditiveReferenceColumn(@Autowired TestEntityManager em) { - Poster poster = sample(); - PosterImageReference reference = - PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); - poster.attachPublishedImage(reference); - adapter.save(poster); - em.flush(); - em.clear(); - - Poster reread = adapter.findById(ID).orElseThrow(); - assertThat(reread.imageAttachment()).isEqualTo(new PublishedPosterImageAttachment(reference)); - assertThat(reread.imageReference()).isEqualTo(reference.value()); - Number legacyKeyCount = - (Number) - em.getEntityManager() - .createNativeQuery( - "select count(*) from poster where id = :id and image_key is not null") - .setParameter("id", java.util.UUID.fromString(ID.value())) - .getSingleResult(); - assertThat(legacyKeyCount.longValue()).isZero(); - } - - @Test - void findPageAppliesStatusFilterAndExistsByTitleIsCaseInsensitive( - @Autowired TestEntityManager em) { - adapter.save(sample()); - em.flush(); - em.clear(); - - assertThat(adapter.findPage(0, 20, PosterSortField.TITLE, true, PosterStatus.DRAFT).total()) - .isEqualTo(1); - // A non-matching status filter runs the real derived query and returns nothing. - assertThat(adapter.findPage(0, 20, null, true, PosterStatus.PUBLISHED).total()).isZero(); - - assertThat(adapter.existsByTitle("여름 세일 배너")).isTrue(); - assertThat(adapter.existsByTitle("존재하지 않는 제목")).isFalse(); - } - - @TestConfiguration - static class TestAuditConfig { - - @Bean - Clock clock() { - return Clock.fixed(Instant.parse("2026-06-15T00:00:00Z"), ZoneOffset.UTC); - } - - @Bean - AuditContextPort auditContext() { - return () -> "it-actor"; - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java deleted file mode 100644 index ab92b9b..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterIntegrationTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.config.JpaConfig; -import dev.caskeleton.sample.portfolio.domain.worklog.Period; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; -import dev.caskeleton.sample.portfolio.testinfra.PostgreSqlTestContainer; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; -import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; -import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.TestPropertySource; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.postgresql.PostgreSQLContainer; - -/** - * Persistence-adapter INTEGRATION test (Hombergs Ch7): the book maps a persistence adapter to an - * integration test against a real database, not a mock. The sibling {@code - * WorkLogRepositoryAdapterTest} mocks {@code WorkLogJpaRepository} to verify the audit-stamping - * logic; this test boots a disposable Postgres via Testcontainers and drives the real Flyway schema - * + JPA mapping, so regressions a mock cannot see fail here: column mapping, - * {@code @Enumerated(STRING)}, the {@code @ElementCollection} join tables, the UUID - * string<->native-uuid conversion, and {@code @Version} assignment. - * - *

    Flyway is pointed at the legacy production stream ({@code db/migration/postgresql}) and the - * sample's {@code db/sample-migration} (V2 {@code work_log}); independently versioned JPA - * capability streams are deliberately excluded. {@code ddl-auto=validate} makes Hibernate verify - * {@code WorkLogEntity} against that Flyway-built schema (schema-drift gate). Skipped automatically - * when no Docker daemon is present. - */ -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Testcontainers(disabledWithoutDocker = true) -@Import({ - JpaConfig.class, - WorkLogRepositoryAdapter.class, - WorkLogRepositoryAdapterIntegrationTest.TestAuditConfig.class -}) -@TestPropertySource( - properties = { - "spring.flyway.enabled=true", - "spring.flyway.locations=classpath:db/migration/postgresql,classpath:db/sample-migration", - "spring.jpa.hibernate.ddl-auto=validate" - }) -class WorkLogRepositoryAdapterIntegrationTest { - - @Container @ServiceConnection - static final PostgreSQLContainer POSTGRES = PostgreSqlTestContainer.create(); - - private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - - @Autowired WorkLogRepositoryAdapter adapter; - - private WorkLog sample() { - return WorkLog.create( - ID, - "DB 튜닝", - WorkCategory.DATABASE, - "summary", - "content", - List.of("postgres", "jpa"), - List.of("https://example.test/pr/1"), - new Period(LocalDate.of(2025, 1, 1), LocalDate.of(2025, 2, 1))); - } - - @Test - void saveThenFindByIdRoundTripsThroughRealPostgres(@Autowired TestEntityManager em) { - WorkLog saved = adapter.save(sample()); - assertThat(saved.version()).isNotNull(); // @Version assigned by Hibernate on INSERT - - // Force a real SELECT instead of the persistence-context L1 cache so the column / - // enum / collection / UUID string<->uuid mapping is actually round-tripped through Postgres. - em.flush(); - em.clear(); - - Optional found = adapter.findById(ID); - assertThat(found).isPresent(); - WorkLog w = found.get(); - assertThat(w.id()).isEqualTo(ID); // UUID string <-> native uuid - assertThat(w.title()).isEqualTo("DB 튜닝"); - assertThat(w.category()).isEqualTo(WorkCategory.DATABASE); // @Enumerated(STRING) - assertThat(w.techStack()).containsExactly("postgres", "jpa"); // @ElementCollection join table - assertThat(w.links()).containsExactly("https://example.test/pr/1"); - assertThat(w.period().start()).isEqualTo(LocalDate.of(2025, 1, 1)); - assertThat(w.period().end()).isEqualTo(LocalDate.of(2025, 2, 1)); - } - - @Test - void findPageReadsPersistedRowsAndAppliesCategoryFilter(@Autowired TestEntityManager em) { - adapter.save(sample()); - em.flush(); - em.clear(); - - WorkLogPage matching = - adapter.findPage(0, 20, WorkLogSortField.TITLE, true, WorkCategory.DATABASE); - assertThat(matching.total()).isEqualTo(1); - assertThat(matching.items()).hasSize(1); - assertThat(matching.items().get(0).id()).isEqualTo(ID); - - // A non-matching category filter runs the real derived query and returns nothing. - assertThat(adapter.findPage(0, 20, null, true, WorkCategory.BACKEND).total()).isZero(); - } - - @TestConfiguration - static class TestAuditConfig { - - @Bean - Clock clock() { - return Clock.fixed(Instant.parse("2026-06-15T00:00:00Z"), ZoneOffset.UTC); - } - - @Bean - AuditContextPort auditContext() { - return () -> "it-actor"; - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterTest.java deleted file mode 100644 index 6450dd3..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogRepositoryAdapterTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.entity.WorkLogEntity; -import dev.caskeleton.sample.portfolio.adapter.outbound.persistence.mapper.WorkLogPersistenceMapper; -import dev.caskeleton.sample.portfolio.domain.worklog.Period; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; - -class WorkLogRepositoryAdapterTest { - - private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - private static final Instant NOW = Instant.parse("2026-06-10T12:00:00Z"); - private static final Instant EARLIER = Instant.parse("2026-01-01T00:00:00Z"); - - private final WorkLogJpaRepository jpa = Mockito.mock(WorkLogJpaRepository.class); - private final AuditContextPort auditContext = Mockito.mock(AuditContextPort.class); - private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); - private final WorkLogRepositoryAdapter adapter = - new WorkLogRepositoryAdapter(jpa, clock, auditContext); - - private WorkLog sample() { - return WorkLog.create( - ID, - "t", - WorkCategory.DATABASE, - "s", - "c", - List.of(), - List.of(), - new Period(LocalDate.of(2025, 1, 1), null)); - } - - private WorkLog persisted(long version) { - return WorkLog.rehydrate( - ID, - "t", - WorkCategory.DATABASE, - "s", - "c", - List.of(), - List.of(), - new Period(LocalDate.of(2025, 1, 1), null), - version); - } - - @Test - void saveMapsToEntityAndBack() { - WorkLog w = sample(); - when(jpa.save(any(WorkLogEntity.class))).thenAnswer(inv -> inv.getArgument(0)); - assertThat(adapter.save(w).id()).isEqualTo(w.id()); - } - - @Test - void saveNewStampsCreatedAndUpdatedAuditWithClockAndActor() { - // D1 Manual path / D4 Clock / D5 actor: a never-persisted aggregate (null version) - // is an INSERT — created_* and updated_* both take now + actor. - when(auditContext.currentActor()).thenReturn("alice"); - when(jpa.save(any(WorkLogEntity.class))).thenAnswer(inv -> inv.getArgument(0)); - - adapter.save(sample()); - - ArgumentCaptor saved = ArgumentCaptor.forClass(WorkLogEntity.class); - verify(jpa).save(saved.capture()); - // no existing-row read for an insert - verify(jpa, never()).findById(any()); - assertThat(saved.getValue().getCreatedAt()).isEqualTo(NOW); - assertThat(saved.getValue().getUpdatedAt()).isEqualTo(NOW); - assertThat(saved.getValue().getCreatedBy()).isEqualTo("alice"); - assertThat(saved.getValue().getUpdatedBy()).isEqualTo("alice"); - } - - @Test - void saveUpdatePreservesCreatedAndBumpsUpdatedAudit() { - // D2/D3: an already-persisted aggregate (non-null version) is an UPDATE — created_* - // is carried from the persisted row, only updated_* moves to now + actor. - when(auditContext.currentActor()).thenReturn("bob"); - WorkLogEntity existing = WorkLogPersistenceMapper.toEntity(persisted(1L)); - existing.initializeAudit(EARLIER, "alice"); - when(jpa.findById(WorkLogPersistenceMapper.toUuid(ID))).thenReturn(Optional.of(existing)); - when(jpa.save(any(WorkLogEntity.class))).thenAnswer(inv -> inv.getArgument(0)); - - adapter.save(persisted(1L)); - - ArgumentCaptor saved = ArgumentCaptor.forClass(WorkLogEntity.class); - verify(jpa).save(saved.capture()); - assertThat(saved.getValue().getCreatedAt()).isEqualTo(EARLIER); - assertThat(saved.getValue().getCreatedBy()).isEqualTo("alice"); - assertThat(saved.getValue().getUpdatedAt()).isEqualTo(NOW); - assertThat(saved.getValue().getUpdatedBy()).isEqualTo("bob"); - } - - @Test - void findByIdConvertsIdStringToUuidAndMapsOptional() { - WorkLog w = sample(); - when(jpa.findById(WorkLogPersistenceMapper.toUuid(ID))) - .thenReturn(Optional.of(WorkLogPersistenceMapper.toEntity(w))); - assertThat(adapter.findById(ID)).isPresent(); - } - - @Test - void findPageDelegatesSortedPagerequestAndMapsTotal() { - WorkLog w = sample(); - when(jpa.findAll(any(Pageable.class))) - .thenReturn( - new PageImpl<>( - List.of(WorkLogPersistenceMapper.toEntity(w)), PageRequest.of(2, 5), 11)); - var result = adapter.findPage(2, 5, WorkLogSortField.TITLE, false, null); - assertThat(result.items()).hasSize(1); - assertThat(result.items().get(0).id()).isEqualTo(w.id()); - assertThat(result.total()).isEqualTo(11); - ArgumentCaptor cap = ArgumentCaptor.forClass(Pageable.class); - verify(jpa).findAll(cap.capture()); - assertThat(cap.getValue().getPageNumber()).isEqualTo(2); - assertThat(cap.getValue().getPageSize()).isEqualTo(5); - assertThat(cap.getValue().getSort().getOrderFor("title").getDirection()) - .isEqualTo(Sort.Direction.DESC); - } - - @Test - void findPageWithCategoryFilterDelegatesToFindByCategory() { - WorkLog w = sample(); - when(jpa.findByCategory(eq(WorkCategory.DATABASE), any(Pageable.class))) - .thenReturn(new PageImpl<>(List.of(WorkLogPersistenceMapper.toEntity(w)))); - var result = adapter.findPage(0, 20, null, true, WorkCategory.DATABASE); - assertThat(result.items()).hasSize(1); - verify(jpa).findByCategory(eq(WorkCategory.DATABASE), any(Pageable.class)); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapterTest.java deleted file mode 100644 index 1362415..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/persistence/repository/WorkLogSummaryQueryAdapterTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.persistence.repository; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import java.time.LocalDate; -import java.util.List; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; - -class WorkLogSummaryQueryAdapterTest { - - private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; - private static final UUID ROW_UUID = UUID.fromString(ID); - - private final WorkLogJpaRepository jpa = Mockito.mock(WorkLogJpaRepository.class); - private final WorkLogSummaryQueryAdapter adapter = new WorkLogSummaryQueryAdapter(jpa); - - @Test - void mapsRowUuidBackToCanonicalUuidString() { - when(jpa.findRecentSummaryRows(any(Pageable.class))) - .thenReturn( - List.of( - new WorkLogSummaryRow( - ROW_UUID, - "Tuned Hikari pool", - WorkCategory.DATABASE, - LocalDate.of(2025, 1, 1), - null))); - - List result = adapter.findRecentSummaries(5); - - assertThat(result).hasSize(1); - WorkLogSummary summary = result.get(0); - assertThat(summary.id()).isEqualTo(ID); - assertThat(summary.title()).isEqualTo("Tuned Hikari pool"); - assertThat(summary.category()).isEqualTo(WorkCategory.DATABASE); - assertThat(summary.periodStart()).isEqualTo(LocalDate.of(2025, 1, 1)); - assertThat(summary.periodEnd()).isNull(); - } - - @Test - void buildsPagerequestWithClampedLimitSortedByPeriodDesc() { - when(jpa.findRecentSummaryRows(any(Pageable.class))).thenReturn(List.of()); - - adapter.findRecentSummaries(0); // non-positive → clamped to 1 - - ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); - Mockito.verify(jpa).findRecentSummaryRows(captor.capture()); - Pageable pageable = captor.getValue(); - assertThat(pageable.getPageNumber()).isZero(); - assertThat(pageable.getPageSize()).isEqualTo(1); - assertThat(pageable.getSort().getOrderFor("periodStart").getDirection()) - .isEqualTo(Sort.Direction.DESC); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java deleted file mode 100644 index ff7751a..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/adapter/outbound/repostats/RepoStatsAclMapperTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package dev.caskeleton.sample.portfolio.adapter.outbound.repostats; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; -import dev.caskeleton.shared.error.MappingException; -import java.util.Locale; -import org.junit.jupiter.api.Test; - -class RepoStatsAclMapperTest { - - @Test - void mapsAndNormalizes() { - RepoStats s = - RepoStatsAclMapper.toDomain( - new RawRepoStatsResponse("Owner/Repo", 1200, "2025-01-02T03:04:05Z", "secret-token")); - assertThat(s.fullName()).isEqualTo("owner/repo"); - assertThat(s.stars()).isEqualTo(1200); - assertThat(s.lastPushedIso()).isEqualTo("2025-01-02T03:04:05Z"); - } - - @Test - void normalizesRepositoryNameWithLocaleIndependentLowercase() { - Locale originalDefault = Locale.getDefault(); - Locale.setDefault(Locale.forLanguageTag("tr-TR")); - try { - RepoStats stats = - RepoStatsAclMapper.toDomain( - new RawRepoStatsResponse( - "OWNER/IDENTITY", 1200, "2025-01-02T03:04:05Z", "secret-token")); - - assertThat(stats.fullName()).isEqualTo("owner/identity"); - } finally { - Locale.setDefault(originalDefault); - } - } - - @Test - void missingFullNameThrowsMappingException() { - assertThatThrownBy( - () -> RepoStatsAclMapper.toDomain(new RawRepoStatsResponse(null, 0, "x", "t"))) - .isInstanceOf(MappingException.class); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/command/CommandSelfValidationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/command/CommandSelfValidationTest.java deleted file mode 100644 index 11b87e1..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/command/CommandSelfValidationTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.command; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dev.caskeleton.sample.portfolio.application.query.GetRepoStatsQuery; -import dev.caskeleton.sample.portfolio.application.query.GetWorkLogQuery; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.shared.request.Patch; -import java.time.LocalDate; -import java.util.List; -import org.junit.jupiter.api.Test; - -/** - * Self-validating input models (Hombergs Ch4): commands/queries enforce their own invariants at - * construction so the contract holds for ANY inbound adapter — not just the web edge whose jakarta - * validation a non-web (e.g. messaging) adapter would bypass. - */ -class CommandSelfValidationTest { - - private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - private static final LocalDate START = LocalDate.of(2025, 1, 1); - - @Test - void createRejectsNullRequiredScalars() { - assertThatThrownBy( - () -> - new CreateWorkLogCommand( - "o", null, WorkCategory.DATABASE, "s", "c", List.of(), List.of(), START, null)) - .isInstanceOf(NullPointerException.class); - assertThatThrownBy( - () -> - new CreateWorkLogCommand( - "o", "t", null, "s", "c", List.of(), List.of(), START, null)) - .isInstanceOf(NullPointerException.class); - assertThatThrownBy( - () -> - new CreateWorkLogCommand( - "o", "t", WorkCategory.DATABASE, "s", "c", List.of(), List.of(), null, null)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void createDefaultsNullCollectionsToEmptyImmutable() { - CreateWorkLogCommand cmd = - new CreateWorkLogCommand( - "o", "t", WorkCategory.DATABASE, "s", "c", null, null, START, null); - assertThat(cmd.techStack()).isEmpty(); - assertThat(cmd.links()).isEmpty(); - assertThatThrownBy(() -> cmd.techStack().add("x")) - .isInstanceOf(UnsupportedOperationException.class); - } - - @Test - void updateRejectsNullIdAndNullPatches() { - assertThatThrownBy( - () -> - new UpdateWorkLogCommand( - null, - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.absent())) - .isInstanceOf(NullPointerException.class); - assertThatThrownBy( - () -> - new UpdateWorkLogCommand( - ID, null, Patch.absent(), Patch.absent(), Patch.absent(), Patch.absent())) - .isInstanceOf(NullPointerException.class); - } - - @Test - void deleteGetAndRepostatsRejectNull() { - assertThatThrownBy(() -> new DeleteWorkLogCommand(null)) - .isInstanceOf(NullPointerException.class); - assertThatThrownBy(() -> new GetWorkLogQuery(null)).isInstanceOf(NullPointerException.class); - assertThatThrownBy(() -> new GetRepoStatsQuery(null)).isInstanceOf(NullPointerException.class); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java deleted file mode 100644 index 431a01d..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/PosterEventPublisherTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.application.observability.CorrelationIdPort; -import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; -import dev.caskeleton.sample.portfolio.domain.poster.PosterCreated; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.Optional; -import org.junit.jupiter.api.Test; - -class PosterEventPublisherTest { - - private static final PosterId POSTER_ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - private static final String EVENT_ID = "0190bd6e-7c3e-7abc-8def-0123456789ff"; - private static final OutboxEventIdFactory EVENT_IDS = () -> EVENT_ID; - private static final Clock CLOCK = - Clock.fixed(Instant.parse("2025-06-01T10:00:00Z"), ZoneOffset.UTC); - - @Test - void usesCorrelationIdPortWhenPresent() { - CapturingOutbox outbox = new CapturingOutbox(); - - publish(outbox, () -> Optional.of("corr-123")); - - assertThat(outbox.event.correlationId()).isEqualTo("corr-123"); - } - - @Test - void fallsBackToEventIdWhenCorrelationIdIsBlank() { - CapturingOutbox outbox = new CapturingOutbox(); - - publish(outbox, () -> Optional.of(" ")); - - assertThat(outbox.event.correlationId()).isEqualTo(EVENT_ID); - } - - @Test - void fallsBackToEventIdWhenCorrelationIdIsAbsent() { - CapturingOutbox outbox = new CapturingOutbox(); - - publish(outbox, Optional::empty); - - assertThat(outbox.event.correlationId()).isEqualTo(EVENT_ID); - } - - private static void publish(CapturingOutbox outbox, CorrelationIdPort correlationIdPort) { - new PosterEventPublisher(outbox, EVENT_IDS, correlationIdPort, CLOCK) - .publishCreated(new PosterCreated(POSTER_ID, "title")); - } - - private static final class CapturingOutbox implements OutboxAppendPort { - private NewOutboxEvent event; - - @Override - public void append(NewOutboxEvent event) { - this.event = event; - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedConsumerDedupeContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedConsumerDedupeContractTest.java deleted file mode 100644 index 5734c61..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedConsumerDedupeContractTest.java +++ /dev/null @@ -1,134 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.application.outbox.NewOutboxEvent; -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import org.junit.jupiter.api.Test; - -/** - * Contract test (spec D7) — at-least-once delivery consumer dedupe obligation. - * - *

    This test demonstrates the executable contract that any consumer of {@code - * worklog.reserved} events MUST honour: given identical {@code idempotencyKey} envelopes delivered - * multiple times (at-least-once delivery is the structural guarantee of the polling outbox), - * exactly one processing side-effect must occur. - * - *

    Scope

    - * - *
      - *
    • The dedupe mechanism (in-memory {@link Set} below) is intentionally minimal — a - * real consumer uses a persistent store with TTL. Storage and TTL policy is owned by the - * consumer implementation branch (out of scope here). - *
    • This test exercises the shape of the contract: same {@code idempotencyKey} → skip, - * different key → process. It does not test distributed consistency or TTL expiry. - *
    - * - *

    Relationship to I12

    - * - *

    {@code idempotencyKey = eventId} by default (I12 — per-event deduplication). A consumer that - * keys dedupe on the envelope's {@code idempotencyKey} therefore deduplicates at the event level, - * not the use-case call level. - */ -class WorkLogReservedConsumerDedupeContractTest { - - /** - * Minimal fake consumer with in-memory idempotencyKey tracking. - * - *

    Real implementations replace the {@link HashSet} with a persistent store (Redis / DB {@code - * idempotency_record}) and a configurable TTL. The dedupe logic shape ({@code if (seen.add(key)) - * process()}) is identical. - */ - static class FakeWorkLogReservedConsumer { - - private final Set processedKeys = new HashSet<>(); - final List processedEventIds = new ArrayList<>(); - - /** - * Processes the envelope. If {@code idempotencyKey} was already seen, the message is silently - * skipped (at-least-once → exactly-once effect). - */ - void consume(NewOutboxEvent envelope) { - if (!processedKeys.add(envelope.idempotencyKey())) { - // already processed — discard duplicate - return; - } - processedEventIds.add(envelope.eventId()); - } - - int processedCount() { - return processedEventIds.size(); - } - } - - // ---- contract tests ---- - - @Test - void sameIdempotencyKeyDeliveredFiveTimesProducesExactlyOneEffect() { - // D7: at-least-once delivery + consumer dedupe obligation - FakeWorkLogReservedConsumer consumer = new FakeWorkLogReservedConsumer(); - - NewOutboxEvent envelope = sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789ab"); - - // Simulate at-least-once delivery: same envelope received 5 times - for (int i = 0; i < 5; i++) { - consumer.consume(envelope); - } - - assertThat(consumer.processedCount()) - .as("same idempotencyKey delivered 5 times must produce exactly 1 processing effect (D7)") - .isEqualTo(1); - } - - @Test - void distinctIdempotencyKeysEachProcessedOnce() { - FakeWorkLogReservedConsumer consumer = new FakeWorkLogReservedConsumer(); - - consumer.consume(sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789aa")); - consumer.consume(sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789bb")); - consumer.consume(sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789cc")); - - assertThat(consumer.processedCount()).isEqualTo(3); - } - - @Test - void mixedDeliveryOnlyDeduplicatesMatchingKey() { - FakeWorkLogReservedConsumer consumer = new FakeWorkLogReservedConsumer(); - - String keyA = "0190bd6e-7c3e-7abc-8def-0123456789aa"; - String keyB = "0190bd6e-7c3e-7abc-8def-0123456789bb"; - - // keyA delivered twice, keyB once - consumer.consume(sampleEnvelope(keyA)); - consumer.consume(sampleEnvelope(keyA)); // duplicate — must be skipped - consumer.consume(sampleEnvelope(keyB)); - - assertThat(consumer.processedCount()).isEqualTo(2); - assertThat(consumer.processedEventIds).containsExactly(keyA, keyB); - } - - @Test - void envelopeIdempotencyKeyEqualsEventIdPerI12() { - // I12: idempotencyKey = eventId by default - NewOutboxEvent envelope = sampleEnvelope("0190bd6e-7c3e-7abc-8def-0123456789ab"); - assertThat(envelope.idempotencyKey()).isEqualTo(envelope.eventId()); - } - - // ---- builder ---- - - private static NewOutboxEvent sampleEnvelope(String id) { - return new NewOutboxEvent( - id, - WorkLogReservedIntegrationEventMapper.EVENT_TYPE, - "0190bd6e-7c3e-7abc-8def-0123456789ab", // aggregateId - "{\"workLogId\":\"" + id + "\",\"category\":\"BACKEND\",\"occurredOn\":\"2025-01-01\"}", - Instant.parse("2025-01-01T00:00:00Z"), - id, // correlationId - id // idempotencyKey = eventId (I12) - ); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java deleted file mode 100644 index f6ce3fb..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedContractContributionTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.caskeleton.application.messaging.contract.ContractDescriptor; -import dev.caskeleton.application.messaging.contract.ContractId; -import dev.caskeleton.application.messaging.contract.IntegrationPayload; -import dev.caskeleton.application.messaging.contract.LogicalDestinationId; -import dev.caskeleton.application.messaging.contract.SchemaResourceId; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Modifier; -import java.net.URI; -import java.nio.ByteBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.time.Duration; -import java.util.HexFormat; -import java.util.Locale; -import org.junit.jupiter.api.Test; - -class WorkLogReservedContractContributionTest { - - private static final String SCHEMA_RESOURCE = - "contracts/messaging/portfolio.worklog.reserved/v1.schema.json"; - private static final String DIGEST_RESOURCE = - "contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256"; - private static final String VALID_RESOURCE = - "contracts/messaging/portfolio.worklog.reserved/v1.valid.json"; - private static final String INVALID_UNKNOWN_FIELD_RESOURCE = - "contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - @Test - void payloadIsTheExactImmutableValidatedIntegrationPayloadRecord() { - WorkLogReservedPayload payload = new WorkLogReservedPayload("worklog-42"); - - assertThat(payload.workLogId()).isEqualTo("worklog-42"); - assertThat(WorkLogReservedPayload.class.isRecord()).isTrue(); - assertThat(Modifier.isFinal(WorkLogReservedPayload.class.getModifiers())).isTrue(); - assertThat(IntegrationPayload.class).isAssignableFrom(WorkLogReservedPayload.class); - - assertThatThrownBy(() -> new WorkLogReservedPayload(null)) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> new WorkLogReservedPayload("")) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> new WorkLogReservedPayload("worklog 42")) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> new WorkLogReservedPayload("x".repeat(161))) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void contributionPublishesOnlyTheClosedProviderNeutralContractDescription() { - WorkLogReservedContractContribution contribution = new WorkLogReservedContractContribution(); - - assertThat(contribution.contractId()).isEqualTo(new ContractId("portfolio.worklog.reserved")); - assertThat(contribution.payloadVersion()).isEqualTo(1); - assertThat(contribution.exactPayloadRecordType()).isEqualTo(WorkLogReservedPayload.class); - assertThat(contribution.canonicalRecordComponentOrder()).containsExactly("workLogId"); - assertThat(contribution.payloadSchemaResource()) - .isEqualTo( - new SchemaResourceId("contracts/messaging/portfolio.worklog.reserved/v1.schema.json")); - assertThat(contribution.payloadSchemaHash()).isSameAs(contribution.payloadSchemaHash()); - - ContractDescriptor descriptor = contribution.descriptor(); - assertThat(descriptor.ownerModule()).isEqualTo("sample-portfolio"); - assertThat(descriptor.logicalDestination()) - .isEqualTo(new LogicalDestinationId("portfolio-domain-events")); - assertThat(descriptor.serializerId()).isEqualTo("json-schema-envelope-v1"); - assertThat(descriptor.orderingRequired()).isTrue(); - assertThat(descriptor.maximumPayloadBytes()).isEqualTo(64 * 1024); - assertThat(descriptor.maximumEnvelopeBytes()).isEqualTo(128 * 1024); - assertThat(descriptor.sensitivityClassification()) - .isEqualTo(ContractDescriptor.SensitivityClassification.INTERNAL); - assertThat(descriptor.sameEventRequeueHorizon()).isEqualTo(Duration.ofDays(7)); - - assertThat(WorkLogReservedContractContribution.class.getDeclaredMethods()) - .allSatisfy( - method -> { - String name = method.getName().toLowerCase(Locale.ROOT); - assertThat(name) - .doesNotContain( - "json", "mapper", "tree", "parser", "topic", "kafka", "bootstrap"); - }); - } - - @Test - void checkedInPayloadSchemaIsClosedBoundedAndMatchesItsExactByteDigest() throws Exception { - byte[] schemaBytes = readResource(SCHEMA_RESOURCE); - String schemaText = strictUtf8(schemaBytes); - JsonNode schema = OBJECT_MAPPER.readTree(schemaText); - - assertThat(schema.path("$schema").textValue()) - .isEqualTo("https://json-schema.org/draft/2020-12/schema"); - String schemaId = schema.path("$id").textValue(); - assertThat(schemaId) - .isEqualTo("urn:dev-caskeleton:contracts:messaging:portfolio.worklog.reserved:v1"); - assertThat(URI.create(schemaId).isAbsolute()).isTrue(); - assertThat(schema.path("type").textValue()).isEqualTo("object"); - assertThat(schema.path("required")).containsExactly(OBJECT_MAPPER.valueToTree("workLogId")); - assertThat(schema.path("properties").size()).isEqualTo(1); - assertThat(schema.path("properties").has("workLogId")).isTrue(); - JsonNode workLogId = schema.path("properties").path("workLogId"); - assertThat(workLogId.path("type").textValue()).isEqualTo("string"); - assertThat(workLogId.path("minLength").intValue()).isEqualTo(1); - assertThat(workLogId.path("maxLength").intValue()).isEqualTo(160); - assertThat(workLogId.path("pattern").textValue()).isEqualTo("^[A-Za-z0-9][A-Za-z0-9._:-]*$"); - assertThat(schema.path("unevaluatedProperties").booleanValue()).isFalse(); - assertThat(schemaText).doesNotContainPattern("\"type\"\\s*:\\s*\\[[^]]*\"null\""); - assertThat(schemaText).doesNotContain("\"$ref\""); - - byte[] manifestBytes = readResource(DIGEST_RESOURCE); - String manifest = strictUtf8(manifestBytes); - assertThat(manifestBytes).endsWith((byte) '\n'); - assertThat(manifest).matches("[0-9a-f]{64}\\n"); - String exactDigest = - HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(schemaBytes)); - assertThat(manifest).isEqualTo(exactDigest + "\n"); - - WorkLogReservedContractContribution contribution = new WorkLogReservedContractContribution(); - assertThat(contribution.payloadSchemaHash().toString()).isEqualTo(exactDigest); - } - - @Test - void goldenVectorsFreezeRequiredUnknownAndNullPolicyWithoutClaimingValidatorCompatibility() - throws Exception { - JsonNode valid = OBJECT_MAPPER.readTree(strictUtf8(readResource(VALID_RESOURCE))); - JsonNode invalidUnknown = - OBJECT_MAPPER.readTree(strictUtf8(readResource(INVALID_UNKNOWN_FIELD_RESOURCE))); - JsonNode missing = OBJECT_MAPPER.readTree("{}"); - JsonNode explicitNull = OBJECT_MAPPER.readTree("{\"workLogId\":null}"); - - assertThat(valid).isEqualTo(OBJECT_MAPPER.readTree("{\"workLogId\":\"worklog-42\"}")); - assertThat(invalidUnknown.path("workLogId").textValue()).isEqualTo("worklog-42"); - assertThat(invalidUnknown.has("unexpected")).isTrue(); - - assertThat(matchesStructuralV1Policy(valid)).isTrue(); - assertThat(matchesStructuralV1Policy(invalidUnknown)).isFalse(); - assertThat(matchesStructuralV1Policy(missing)).isFalse(); - assertThat(matchesStructuralV1Policy(explicitNull)).isFalse(); - } - - private static boolean matchesStructuralV1Policy(JsonNode candidate) { - if (!candidate.isObject() || candidate.size() != 1 || !candidate.has("workLogId")) { - return false; - } - JsonNode workLogId = candidate.path("workLogId"); - if (!workLogId.isTextual()) { - return false; - } - String value = workLogId.textValue(); - return value.length() <= 160 && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); - } - - private static byte[] readResource(String resource) throws IOException { - try (InputStream input = - WorkLogReservedContractContributionTest.class - .getClassLoader() - .getResourceAsStream(resource)) { - if (input == null) { - throw new IOException("Missing classpath resource: " + resource); - } - return input.readAllBytes(); - } - } - - private static String strictUtf8(byte[] bytes) throws CharacterCodingException { - return StandardCharsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)) - .toString(); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperJsonTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperJsonTest.java deleted file mode 100644 index 86a647d..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperJsonTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; -import java.time.LocalDate; -import java.util.Locale; -import org.junit.jupiter.api.Test; - -/** - * Verifies the JSON serialisation produced by {@link WorkLogReservedIntegrationEventMapper#toJson} - * (feature-domain-event-outbox-contract D11 + D12). - * - *

    Hand-rolled serialisation: no Jackson dependency. Checks field presence, ordering, and RFC - * 8259 §7 escape correctness. - */ -class WorkLogReservedIntegrationEventMapperJsonTest { - - private static final WorkLogReserved SAMPLE_EVENT = - new WorkLogReserved( - WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"), - WorkCategory.BACKEND, - LocalDate.of(2025, 1, 1)); - - @Test - void toJsonProducesExpectedShape() { - WorkLogReservedIntegrationEvent ie = - WorkLogReservedIntegrationEventMapper.toIntegrationEvent(SAMPLE_EVENT); - - String json = WorkLogReservedIntegrationEventMapper.toJson(ie); - - assertThat(json) - .isEqualTo( - "{\"workLogId\":\"0190bd6e-7c3e-7abc-8def-0123456789ab\"," - + "\"category\":\"BACKEND\"," - + "\"occurredOn\":\"2025-01-01\"}"); - } - - @Test - void toJsonIsValidParseableJsonObject() { - WorkLogReservedIntegrationEvent ie = - WorkLogReservedIntegrationEventMapper.toIntegrationEvent(SAMPLE_EVENT); - String json = WorkLogReservedIntegrationEventMapper.toJson(ie); - - assertThat(json).startsWith("{").endsWith("}"); - assertThat(json).contains("\"workLogId\""); - assertThat(json).contains("\"category\""); - assertThat(json).contains("\"occurredOn\""); - } - - @Test - void escapeHandlesBackslashAndDoubleQuote() { - String input = "path\\to\"value"; - String escaped = WorkLogReservedIntegrationEventMapper.escape(input); - assertThat(escaped).isEqualTo("path\\\\to\\\"value"); - } - - @Test - void escapeHandlesControlCharacters() { - // Tab (0x09), newline (0x0A), carriage return (0x0D) are < 0x20 - String input = "a\tb\nc"; - String escaped = WorkLogReservedIntegrationEventMapper.escape(input); - assertThat(escaped).isEqualTo("a\\u0009b\\u000ac"); - } - - @Test - void escapeNullReturnsEmptyString() { - assertThat(WorkLogReservedIntegrationEventMapper.escape(null)).isEqualTo(""); - } - - @Test - void eventTypeConstantIsStable() { - // I9: eventType becomes the broker topic — must not change silently - assertThat(WorkLogReservedIntegrationEventMapper.EVENT_TYPE).isEqualTo("worklog.reserved"); - } - - @Test - void toJsonContainsNoPiiFieldNames() { - // EventPayloadPiiContractTest (Task E) also enforces this statically; - // this test makes it explicit for the sample payload shape. - WorkLogReservedIntegrationEvent ie = - WorkLogReservedIntegrationEventMapper.toIntegrationEvent(SAMPLE_EVENT); - String json = WorkLogReservedIntegrationEventMapper.toJson(ie); - - assertThat(json.toLowerCase(Locale.ROOT)) - .doesNotContain("email") - .doesNotContain("password") - .doesNotContain("token") - .doesNotContain("secret"); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperTest.java deleted file mode 100644 index 735c538..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/event/WorkLogReservedIntegrationEventMapperTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.event; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogReserved; -import java.time.LocalDate; -import org.junit.jupiter.api.Test; - -/** - * Verifies the Claims-To-Verify flow: a transport-free domain event is convertible to an - * integration event at the application boundary (feature-domain-modeling-guardrails D4/D8). - */ -class WorkLogReservedIntegrationEventMapperTest { - - @Test - void mapsDomainEventToIntegrationEventFlatteningValueObjects() { - WorkLogReserved domainEvent = - new WorkLogReserved( - WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"), - WorkCategory.BACKEND, - LocalDate.of(2025, 1, 1)); - - WorkLogReservedIntegrationEvent integrationEvent = - WorkLogReservedIntegrationEventMapper.toIntegrationEvent(domainEvent); - - assertThat(integrationEvent.workLogId()).isEqualTo("0190bd6e-7c3e-7abc-8def-0123456789ab"); - assertThat(integrationEvent.category()).isEqualTo("BACKEND"); - assertThat(integrationEvent.occurredOn()).isEqualTo("2025-01-01"); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java deleted file mode 100644 index cc195ae..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/DeletePosterImageRetirementTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import dev.caskeleton.sample.portfolio.domain.poster.PosterStatus; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Supplier; -import org.junit.jupiter.api.Test; - -class DeletePosterImageRetirementTest { - - private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); - private static final PosterId POSTER = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - private static final PosterImageReference REFERENCE = - PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); - private static final ObjectVersionToken VERSION = - ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); - - @Test - void commitsExactRetirementWorkBeforeDeletingCanonicalPoster() { - Poster poster = - Poster.rehydrate( - POSTER, "title", "caption", null, REFERENCE.value(), PosterStatus.DRAFT, 4L); - PosterRepository posters = mock(PosterRepository.class); - when(posters.findById(POSTER)).thenReturn(Optional.of(poster)); - PosterImageUploadIntent ready = mock(PosterImageUploadIntent.class); - when(ready.publishedVersion()).thenReturn(VERSION); - PosterImageUploadIntentStorePort uploads = mock(PosterImageUploadIntentStorePort.class); - when(uploads.findReadyByPublishedReference(REFERENCE)).thenReturn(Optional.of(ready)); - CapturingRetirements retirements = new CapturingRetirements(posters); - TrackingTransactions transactions = new TrackingTransactions(); - RetirePosterImageUseCase retire = - new RetirePosterImageUseCase( - retirements, uploads, allocated(), transactions, Clock.fixed(NOW, ZoneOffset.UTC)); - DeletePosterUseCase useCase = - new DeletePosterUseCase( - posters, - mock(PosterEventPublisher.class), - transactions, - Optional.of(retire), - Duration.ofDays(7)); - - useCase.handle(new DeletePosterCommand(POSTER)); - - assertThat(retirements.stored).isNotNull(); - assertThat(retirements.stored.posterId()).isEqualTo(POSTER); - assertThat(retirements.stored.reference()).isEqualTo(REFERENCE); - assertThat(retirements.stored.exactVersion()).isEqualTo(VERSION); - assertThat(retirements.stored.retainUntil()).isEqualTo(NOW.plus(Duration.ofDays(7))); - } - - private static PosterImageOperationIdFactory allocated() { - return () -> - new PosterImageOperationIdFactory.AllocatedOperation( - UUID.fromString("0190bd6e-7c3e-7abc-8def-012345678901"), - ObjectOperationId.of("0190bd6e-7c3e-7abc-8def-012345678901")); - } - - private static final class CapturingRetirements implements PosterImageRetirementIntentStorePort { - private final PosterRepository posters; - private PosterImageRetirementIntent stored; - - private CapturingRetirements(PosterRepository posters) { - this.posters = posters; - } - - @Override - public PosterImageRetirementIntent createOrRead(PosterImageRetirementIntent proposed) { - assertThat(posters.findById(POSTER)).isPresent(); - stored = proposed; - return proposed; - } - - @Override - public Optional findByOperation(ObjectOperationId operation) { - return Optional.ofNullable(stored); - } - - @Override - public PosterImageRetirementIntent compareAndSet( - long expectedRevision, PosterImageRetirementIntent replacement) { - stored = replacement; - return replacement; - } - - @Override - public Optional claimReady( - String claimantDigest, Instant now, Duration leaseDuration) { - return Optional.empty(); - } - } - - private static final class TrackingTransactions implements TransactionPort { - private boolean active; - - @Override - public T inWrite(Supplier action) { - assertThat(active).isFalse(); - active = true; - try { - return action.get(); - } finally { - active = false; - } - } - - @Override - public T inRootWrite(Supplier action) { - return inWrite(action); - } - - @Override - public T inRead(Supplier action) { - return action.get(); - } - - @Override - public T inNew(Supplier action) { - return inWrite(action); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java deleted file mode 100644 index 9519e80..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/LegacyPosterImageUploadCharacterizationTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import dev.caskeleton.application.storage.ObjectStoragePort; -import dev.caskeleton.application.storage.StoredObject; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.LegacyPosterImageController; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.dto.response.StoredObjectResponse; -import dev.caskeleton.sample.portfolio.application.command.DeletePosterCommand; -import dev.caskeleton.sample.portfolio.application.command.UploadPosterImageCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterEventPublisher; -import dev.caskeleton.sample.portfolio.domain.poster.Poster; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterRepository; -import java.io.IOException; -import java.net.URI; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.function.Supplier; -import org.junit.jupiter.api.Test; -import org.springframework.web.multipart.MultipartFile; - -/** Characterizes the sample's legacy database/object-storage coupling before migration. */ -// This named characterization suite preserves the active seam until migration completes. -@SuppressWarnings("removal") -class LegacyPosterImageUploadCharacterizationTest { - - private static final PosterId POSTER_ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - private static final String LEGACY_KEY = "posters/" + POSTER_ID.value() + "/image"; - - @Test - void uploadCallsObjectStorageInsideTheWriteTransaction() { - PosterRepository repository = mock(PosterRepository.class); - Poster poster = Poster.create(POSTER_ID, "legacy", "characterization"); - when(repository.findById(POSTER_ID)).thenReturn(Optional.of(poster)); - TrackingTransactionPort tx = new TrackingTransactionPort(); - ObjectStoragePort storage = mock(ObjectStoragePort.class); - when(storage.put(eq(LEGACY_KEY), any(byte[].class), eq("image/png"))) - .thenAnswer( - ignored -> { - assertThat(tx.inWrite()).isTrue(); - return legacyReceipt(); - }); - PosterEventPublisher events = mock(PosterEventPublisher.class); - UploadPosterImageUseCase useCase = - new UploadPosterImageUseCase(repository, storage, events, tx); - - StoredObject stored = - useCase.handle(new UploadPosterImageCommand(POSTER_ID, new byte[] {1, 2, 3}, "image/png")); - - assertThat(stored.key()).isEqualTo(LEGACY_KEY); - assertThat(poster.imageKey()).isEqualTo(LEGACY_KEY); - verify(repository).save(poster); - verify(events).publishImageAttached(any()); - } - - @Test - void controllerMaterializesMultipartBytesAndReturnsRawKeyAndLocation() throws IOException { - UploadPosterImageUseCase upload = mock(UploadPosterImageUseCase.class); - when(upload.handle(any())).thenReturn(legacyReceipt()); - LegacyPosterImageController controller = new LegacyPosterImageController(upload); - MultipartFile file = mock(MultipartFile.class); - when(file.getBytes()).thenReturn(new byte[] {1, 2, 3}); - when(file.getContentType()).thenReturn("image/png"); - - StoredObjectResponse response = controller.uploadImage(POSTER_ID.value(), file); - - verify(file).getBytes(); - assertThat(response.key()).isEqualTo(LEGACY_KEY); - assertThat(response.location()).isEqualTo("s3://legacy-bucket/" + LEGACY_KEY); - } - - @Test - void deletingAPosterDoesNotRetireItsLegacyObject() { - InMemoryLegacyStorage storage = new InMemoryLegacyStorage(); - storage.put(LEGACY_KEY, new byte[] {9}, "image/png"); - PosterRepository repository = mock(PosterRepository.class); - when(repository.existsById(POSTER_ID)).thenReturn(true); - DeletePosterUseCase delete = - new DeletePosterUseCase( - repository, mock(PosterEventPublisher.class), new TrackingTransactionPort()); - - delete.handle(new DeletePosterCommand(POSTER_ID)); - - verify(repository).deleteById(POSTER_ID); - assertThat(storage.exists(LEGACY_KEY)).isTrue(); - } - - private static StoredObject legacyReceipt() { - return new StoredObject( - LEGACY_KEY, 3, "image/png", URI.create("s3://legacy-bucket/" + LEGACY_KEY)); - } - - private static final class TrackingTransactionPort implements TransactionPort { - private boolean inWrite; - - boolean inWrite() { - return inWrite; - } - - @Override - public T inWrite(Supplier action) { - inWrite = true; - try { - return action.get(); - } finally { - inWrite = false; - } - } - - @Override - public T inRootWrite(Supplier action) { - assertThat(inWrite).isFalse(); - return inWrite(action); - } - - @Override - public T inRead(Supplier action) { - return action.get(); - } - - @Override - public T inNew(Supplier action) { - return action.get(); - } - } - - private static final class InMemoryLegacyStorage implements ObjectStoragePort { - private final Map objects = new HashMap<>(); - - @Override - public StoredObject put(String key, byte[] content, String contentType) { - objects.put(key, content.clone()); - return new StoredObject(key, content.length, contentType, URI.create("memory:///" + key)); - } - - @Override - public Optional get(String key) { - byte[] content = objects.get(key); - return content == null ? Optional.empty() : Optional.of(content.clone()); - } - - @Override - public void delete(String key) { - objects.remove(key); - } - - @Override - public boolean exists(String key) { - return objects.containsKey(key); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java deleted file mode 100644 index 84b5401..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImagePublicationWorkflowTest.java +++ /dev/null @@ -1,314 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import dev.caskeleton.application.idempotency.IdempotencyExecutor; -import dev.caskeleton.application.idempotency.IdempotencyRecord; -import dev.caskeleton.application.idempotency.IdempotencyScope; -import dev.caskeleton.application.idempotency.IdempotencyStatus; -import dev.caskeleton.application.idempotency.IdempotencyStorePort; -import dev.caskeleton.application.idempotency.RequestFingerprint; -import dev.caskeleton.application.idempotency.StoredResponse; -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.objectstorage.content.ObjectContentProductionContext; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.identity.ObjectStageHandle; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.application.objectstorage.model.ObjectContentIdentity; -import dev.caskeleton.application.objectstorage.model.ObjectDigest; -import dev.caskeleton.application.objectstorage.model.ObjectHandoffReceipt; -import dev.caskeleton.application.objectstorage.model.ObjectMediaType; -import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome; -import dev.caskeleton.application.objectstorage.model.ObjectStageReceipt; -import dev.caskeleton.application.objectstorage.model.ObjectVerificationReceipt; -import dev.caskeleton.application.objectstorage.port.ObjectPublicationHandoffPort; -import dev.caskeleton.application.objectstorage.port.StagedObjectPublicationPort; -import dev.caskeleton.application.outbound.CallBudget; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.adapter.outbound.identifier.HmacPosterImageIdempotencyScopeDigester; -import dev.caskeleton.sample.portfolio.application.command.PublishPosterImageCommand; -import dev.caskeleton.sample.portfolio.application.event.PosterImagePublicationEventPublisher; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigest; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageOperationIdFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImagePublicationPolicy; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentClaim; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentState; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageUploadIntentStorePort; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.nio.charset.StandardCharsets; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Test; - -class PosterImagePublicationWorkflowTest { - - private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); - private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); - private static final PosterId POSTER = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - private static final ObjectContentIdentity CONTENT = - new ObjectContentIdentity(5, ObjectDigest.sha256("hello".getBytes(StandardCharsets.UTF_8))); - private static final ObjectStageHandle STAGE = - ObjectStageHandle.parse("osh1.0123456789ab.0123456789abcdefghjkmnpqrs.f3226a9833"); - private static final ObjectVersionToken VERSION = - ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); - - @Test - void commitsTx1BeforeProducerAndTx2OnlyAfterExactStageHandoff() { - TrackingTransactions transactions = new TrackingTransactions(); - FakeIntents intents = new FakeIntents(); - StagedObjectPublicationPort publications = mock(StagedObjectPublicationPort.class); - ObjectPublicationHandoffPort handoffs = mock(ObjectPublicationHandoffPort.class); - PosterImagePublicationEventPublisher events = mock(PosterImagePublicationEventPublisher.class); - AtomicInteger produced = new AtomicInteger(); - - when(publications.stage(any(), any())) - .thenAnswer( - invocation -> { - assertThat(transactions.active).isFalse(); - var request = - (dev.caskeleton.application.objectstorage.request.ObjectStageRequest) - invocation.getArgument(0); - var producer = - (dev.caskeleton.application.objectstorage.content.ObjectContentProducer) - invocation.getArgument(1); - ObjectContentProductionContext context = - ObjectContentProductionContext.open(request.budget(), request.cancellation(), 64); - producer.produce(context, (bytes, offset, length) -> produced.addAndGet(length)); - context.invalidate(); - return new ObjectStageReceipt( - request.operationKey(), STAGE, VERSION, CONTENT, ObjectMutationOutcome.APPLIED); - }); - when(publications.verifyIntegrity(any())) - .thenAnswer( - invocation -> { - assertThat(transactions.active).isFalse(); - var request = - (dev.caskeleton.application.objectstorage.request.ObjectVerifyRequest) - invocation.getArgument(0); - return new ObjectVerificationReceipt( - request.operationKey(), STAGE, VERSION, CONTENT, ObjectMutationOutcome.APPLIED); - }); - when(handoffs.claimForPublication(any())) - .thenAnswer( - invocation -> { - assertThat(transactions.active).isFalse(); - var request = - (dev.caskeleton.application.objectstorage.request.ObjectHandoffClaimRequest) - invocation.getArgument(0); - return new ObjectHandoffReceipt( - request.operationKey(), - STAGE, - 1, - NOW.plusSeconds(60), - ObjectMutationOutcome.APPLIED); - }); - doAnswer( - invocation -> { - assertThat(transactions.active).isTrue(); - return null; - }) - .when(events) - .publishPrepared(any()); - - var digester = - new HmacPosterImageIdempotencyScopeDigester( - "key-v1", - Map.of("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8))); - PublishPosterImageUseCase useCase = - new PublishPosterImageUseCase( - intents, - new PosterImageSanitizedIdempotencyContextFactory(digester), - () -> - new PosterImageOperationIdFactory.AllocatedOperation( - UUID.fromString("0190bd6e-7c3e-7abc-8def-012345678901"), - ObjectOperationId.of("0190bd6e-7c3e-7abc-8def-012345678901")), - new IdempotencyExecutor(new FakeIdempotencyStore(), CLOCK, Duration.ofHours(24)), - transactions, - publications, - handoffs, - events, - new PosterImagePublicationPolicy(1024), - ObjectDestinationId.of("poster"), - ObjectOperationEpoch.of("poster-image-v1"), - Duration.ofHours(24), - Duration.ofMinutes(1), - CLOCK); - PublishPosterImageCommand command = - new PublishPosterImageCommand( - POSTER, - 0, - "tenant-a", - "principal-a", - "client-key-a", - CONTENT, - ObjectMediaType.of("image/png"), - (context, sink) -> sink.write("hello".getBytes(StandardCharsets.UTF_8), 0, 5), - CallBudget.fromNow(Duration.ofMinutes(1)), - CancellationView.never()); - - var first = useCase.handle(command); - var replay = - useCase.handle( - new PublishPosterImageCommand( - POSTER, - 0, - "tenant-a", - "principal-a", - "client-key-a", - CONTENT, - ObjectMediaType.of("image/png"), - (context, sink) -> { - throw new AssertionError("producer must not replay after PENDING"); - }, - command.budget(), - command.cancellation())); - - assertThat(first).isEqualTo(replay); - assertThat(produced).hasValue(5); - assertThat(intents.only().state()).isEqualTo(PosterImageUploadIntentState.PENDING); - assertThat(transactions.writeCommits).isEqualTo(3); - } - - private static final class TrackingTransactions implements TransactionPort { - boolean active; - int writeCommits; - - @Override - public T inWrite(java.util.function.Supplier action) { - assertThat(active).isFalse(); - active = true; - try { - T result = action.get(); - writeCommits++; - return result; - } finally { - active = false; - } - } - - @Override - public T inRootWrite(java.util.function.Supplier action) { - return inWrite(action); - } - - @Override - public T inRead(java.util.function.Supplier action) { - assertThat(active).isFalse(); - active = true; - try { - return action.get(); - } finally { - active = false; - } - } - - @Override - public T inNew(java.util.function.Supplier action) { - return inWrite(action); - } - } - - private static final class FakeIntents implements PosterImageUploadIntentStorePort { - private final Map rows = new LinkedHashMap<>(); - - @Override - public PosterImageUploadIntent createOrRead(PosterImageUploadIntent proposed) { - return rows.computeIfAbsent( - proposed.operationKey().operationId().value(), ignored -> proposed); - } - - @Override - public Optional findByOperation( - dev.caskeleton.application.objectstorage.identity.ObjectOperationKey operation) { - return Optional.ofNullable(rows.get(operation.operationId().value())); - } - - @Override - public Optional findByAnyScopeDigest( - Collection retainedDigests) { - return rows.values().stream() - .filter(intent -> retainedDigests.contains(intent.idempotencyScope())) - .findFirst(); - } - - @Override - public Optional findReadyByPublishedReference( - PosterImageReference reference) { - return Optional.empty(); - } - - @Override - public PosterImageUploadIntent compareAndSet( - long expectedRevision, PosterImageUploadIntent replacement) { - PosterImageUploadIntent current = rows.get(replacement.operationKey().operationId().value()); - assertThat(current.revision()).isEqualTo(expectedRevision); - rows.put(replacement.operationKey().operationId().value(), replacement); - return replacement; - } - - @Override - public Optional claimPending( - String claimantDigest, Instant now, Duration leaseDuration) { - return Optional.empty(); - } - - PosterImageUploadIntent only() { - return rows.values().iterator().next(); - } - } - - private static final class FakeIdempotencyStore implements IdempotencyStorePort { - private final Map rows = new LinkedHashMap<>(); - - @Override - public boolean tryBegin( - IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { - return rows.putIfAbsent( - scope.storageKey(), - new IdempotencyRecord( - scope, fingerprint, IdempotencyStatus.IN_FLIGHT, null, NOW, expiresAt)) - == null; - } - - @Override - public Optional find(IdempotencyScope scope, Instant now) { - return Optional.ofNullable(rows.get(scope.storageKey())); - } - - @Override - public void complete(IdempotencyScope scope, StoredResponse response) { - IdempotencyRecord current = rows.get(scope.storageKey()); - rows.put( - scope.storageKey(), - new IdempotencyRecord( - current.scope(), - current.fingerprint(), - IdempotencyStatus.COMPLETED, - response, - current.createdAt(), - current.expiresAt())); - } - - @Override - public void discard(IdempotencyScope scope) { - rows.remove(scope.storageKey()); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java deleted file mode 100644 index b910498..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/poster/PosterImageRetirementCrashMatrixTest.java +++ /dev/null @@ -1,191 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.poster; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dev.caskeleton.application.objectstorage.content.CancellationView; -import dev.caskeleton.application.objectstorage.identity.ObjectDestinationId; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationEpoch; -import dev.caskeleton.application.objectstorage.identity.ObjectOperationId; -import dev.caskeleton.application.objectstorage.identity.ObjectVersionToken; -import dev.caskeleton.application.objectstorage.model.ObjectMutationOutcome; -import dev.caskeleton.application.objectstorage.model.ObjectMutationReceipt; -import dev.caskeleton.application.objectstorage.model.ObjectOperationError; -import dev.caskeleton.application.objectstorage.port.ObjectRetirementPort; -import dev.caskeleton.application.outbound.CallBudget; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.application.command.ReconcilePosterImageRetirementCommand; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntent; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentState; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageRetirementIntentStorePort; -import dev.caskeleton.sample.portfolio.domain.poster.PosterId; -import dev.caskeleton.sample.portfolio.domain.poster.PosterImageReference; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; -import org.junit.jupiter.api.Test; - -class PosterImageRetirementCrashMatrixTest { - - private static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); - private static final String CLAIMANT = - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - private static final ObjectOperationId OPERATION = - ObjectOperationId.of("0190bd6e-7c3e-7abc-8def-012345678901"); - private static final PosterImageReference REFERENCE = - PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); - private static final ObjectVersionToken VERSION = - ObjectVersionToken.parse("osv1.0123456789ab.0123456789abcdefghjkmnpqrs.6678c6821f"); - - @Test - void responseLossRetriesTheSameExactOperationOutsideTransactions() { - InMemoryRetirements store = new InMemoryRetirements(pending()); - TrackingTransactions transactions = new TrackingTransactions(); - AtomicInteger attempts = new AtomicInteger(); - ObjectRetirementPort objects = - request -> { - assertThat(transactions.active).isFalse(); - assertThat(request.reference().canonicalText()).isEqualTo(REFERENCE.value()); - assertThat(request.exactVersion()).isEqualTo(VERSION); - if (attempts.incrementAndGet() == 1) { - throw new IllegalStateException("response lost"); - } - return new ObjectMutationReceipt( - request.operationKey(), - ObjectMutationOutcome.REPLAYED, - ObjectOperationError.NONE, - NOW); - }; - ReconcilePosterImageRetirementUseCase useCase = useCase(store, transactions, objects); - - var first = useCase.handle(command()); - var second = useCase.handle(command()); - - assertThat(first.state()).isEqualTo(PosterImageRetirementIntentState.PENDING); - assertThat(second.state()).isEqualTo(PosterImageRetirementIntentState.RETIRED); - assertThat(attempts).hasValue(2); - assertThat(store.current.operationId()).isEqualTo(OPERATION); - assertThat(store.current.claimFence()).isEqualTo(2); - } - - @Test - void expiredWorkerCannotCommitAfterTakeoverAdvancesFence() { - PosterImageRetirementIntent first = pending().claim(0, CLAIMANT, NOW, Duration.ofSeconds(1)); - PosterImageRetirementIntent takeover = - first.claim( - first.revision(), - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - NOW.plusSeconds(2), - Duration.ofSeconds(30)); - - assertThat(takeover.claimFence()).isEqualTo(2); - assertThatThrownBy( - () -> takeover.retired(takeover.revision(), first.claimFence(), NOW.plusSeconds(3))) - .isInstanceOf(RuntimeException.class); - } - - private static ReconcilePosterImageRetirementUseCase useCase( - InMemoryRetirements store, TrackingTransactions transactions, ObjectRetirementPort objects) { - return new ReconcilePosterImageRetirementUseCase( - store, - transactions, - objects, - ObjectDestinationId.of("poster"), - ObjectOperationEpoch.of("poster-image-v1"), - Clock.fixed(NOW, ZoneOffset.UTC)); - } - - private static ReconcilePosterImageRetirementCommand command() { - return new ReconcilePosterImageRetirementCommand( - CLAIMANT, - Duration.ofSeconds(30), - Duration.ZERO, - CallBudget.fromNow(Duration.ofSeconds(10)), - CancellationView.never()); - } - - private static PosterImageRetirementIntent pending() { - return PosterImageRetirementIntent.pending( - UUID.fromString("0190bd6e-7c3e-7abc-8def-012345678901"), - PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"), - OPERATION, - REFERENCE, - VERSION, - NOW, - NOW); - } - - private static final class InMemoryRetirements implements PosterImageRetirementIntentStorePort { - private PosterImageRetirementIntent current; - - private InMemoryRetirements(PosterImageRetirementIntent current) { - this.current = current; - } - - @Override - public PosterImageRetirementIntent createOrRead(PosterImageRetirementIntent proposed) { - return current; - } - - @Override - public Optional findByOperation(ObjectOperationId operation) { - return Optional.of(current); - } - - @Override - public PosterImageRetirementIntent compareAndSet( - long expectedRevision, PosterImageRetirementIntent replacement) { - assertThat(current.revision()).isEqualTo(expectedRevision); - current = replacement; - return replacement; - } - - @Override - public Optional claimReady( - String claimantDigest, Instant now, Duration leaseDuration) { - if (current.state().terminal() - || (current.retryAt() != null && current.retryAt().isAfter(now))) { - return Optional.empty(); - } - PosterImageRetirementIntent claimed = - current.claim(current.revision(), claimantDigest, now, leaseDuration); - current = claimed; - return Optional.of(claimed); - } - } - - private static final class TrackingTransactions implements TransactionPort { - private boolean active; - - @Override - public T inWrite(Supplier action) { - assertThat(active).isFalse(); - active = true; - try { - return action.get(); - } finally { - active = false; - } - } - - @Override - public T inRootWrite(Supplier action) { - return inWrite(action); - } - - @Override - public T inRead(Supplier action) { - return action.get(); - } - - @Override - public T inNew(Supplier action) { - return inWrite(action); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactoryTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactoryTest.java deleted file mode 100644 index ed626a7..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/posterimage/PosterImageSanitizedIdempotencyContextFactoryTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.posterimage; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.application.idempotency.RequestFingerprint; -import dev.caskeleton.sample.portfolio.adapter.outbound.identifier.HmacPosterImageIdempotencyScopeDigester; -import java.nio.charset.StandardCharsets; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class PosterImageSanitizedIdempotencyContextFactoryTest { - - @Test - void rawCallerDimensionsNeverEnterTheGenericStoreScope() { - var digester = - new HmacPosterImageIdempotencyScopeDigester( - "key-v1", - Map.of("key-v1", "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8))); - var factory = new PosterImageSanitizedIdempotencyContextFactory(digester); - - var result = - factory.create( - "tenant-raw-secret", - "principal-raw-secret", - "idempotency-raw-secret", - RequestFingerprint.ofSha256(new byte[] {1})); - String storageKey = result.generic().scope().storageKey(); - - assertThat(storageKey) - .doesNotContain("tenant-raw-secret", "principal-raw-secret", "idempotency-raw-secret") - .contains(PosterImageSanitizedIdempotencyContextFactory.RESERVATION_USE_CASE); - assertThat(result.retainedScopes()).contains(result.activeScope()); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java deleted file mode 100644 index 21fcc5c..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java +++ /dev/null @@ -1,299 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.application.observability.CorrelationIdPort; -import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.event.WorkLogReservedIntegrationEventMapper; -import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDate; -import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.function.Supplier; -import org.junit.jupiter.api.Test; - -/** - * Verifies that {@link CreateWorkLogUseCase} appends a {@code WorkLogReserved} outbox event inside - * the same {@code tx.inWrite} block as the aggregate save (feature-domain-event-outbox-contract - * D2). - * - *

    Uses hand-rolled fakes — no Spring context, no JPA. - */ -class CreateWorkLogOutboxTest { - - // ---- fakes ---- - - static final WorkLogId FIXED_ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - static final WorkLogIdFactory IDS = () -> FIXED_ID; - static final String FIXED_EVENT_ID = "0190bd6e-7c3e-7abc-8def-0123456789ff"; - static final OutboxEventIdFactory EVENT_IDS = () -> FIXED_EVENT_ID; - static final Clock FIXED_CLOCK = - Clock.fixed(Instant.parse("2025-06-01T10:00:00Z"), ZoneOffset.UTC); - static final CorrelationIdPort NO_CORRELATION = Optional::empty; - - static class FakeRepo implements WorkLogRepository { - final List store = new ArrayList<>(); - - @Override - public WorkLog save(WorkLog w) { - store.add(w); - return w; - } - - @Override - public Optional findById(WorkLogId id) { - return store.stream().filter(x -> x.id().equals(id)).findFirst(); - } - - @Override - public WorkLogPage findPage( - int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) { - return new WorkLogPage(List.of(), 0); - } - - @Override - public boolean existsById(WorkLogId id) { - return findById(id).isPresent(); - } - - @Override - public void deleteById(WorkLogId id) { - store.removeIf(x -> x.id().equals(id)); - } - } - - static class CapturingOutboxPort implements OutboxAppendPort { - final List appended = new ArrayList<>(); - boolean appendCalledInTx = false; - - void markInTx() { - appendCalledInTx = true; - } - - @Override - public void append(NewOutboxEvent event) { - appended.add(event); - } - } - - /** - * A {@link TransactionPort} that tracks whether {@code outboxAppendPort.append} was called inside - * the {@code inWrite} callback — used to assert D2 (same-tx dual-write guarantee). - */ - static class TrackingTx implements TransactionPort { - private final CapturingOutboxPort outboxPort; - boolean appendHappenedInsideTx = false; - - TrackingTx(CapturingOutboxPort outboxPort) { - this.outboxPort = outboxPort; - } - - @Override - public T inWrite(Supplier action) { - int beforeSize = outboxPort.appended.size(); - T result = action.get(); - // If the outbox port was called during the action, the size increased - appendHappenedInsideTx = outboxPort.appended.size() > beforeSize; - return result; - } - - @Override - public T inRootWrite(Supplier action) { - return inWrite(action); - } - - @Override - public T inRead(Supplier action) { - return action.get(); - } - - @Override - public T inNew(Supplier action) { - return action.get(); - } - } - - // ---- helpers ---- - - private CreateWorkLogCommand createCmd() { - return new CreateWorkLogCommand( - "owner1", - "Test Work", - WorkCategory.BACKEND, - "summary", - "content", - List.of("java"), - List.of(), - LocalDate.of(2025, 1, 1), - null); - } - - // ---- tests ---- - - @Test - void createAppendsOutboxEventWithCorrectEventType() { - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TrackingTx tx = new TrackingTx(outbox); - - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) - .handle(createCmd()); - - assertThat(outbox.appended).hasSize(1); - assertThat(outbox.appended.get(0).eventType()) - .isEqualTo(WorkLogReservedIntegrationEventMapper.EVENT_TYPE); - } - - @Test - void createAppendsOutboxEventInsideTxInWriteBlock() { - // D2: append must happen inside the same tx.inWrite as the aggregate save - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TrackingTx tx = new TrackingTx(outbox); - - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) - .handle(createCmd()); - - assertThat(tx.appendHappenedInsideTx) - .as("OutboxAppendPort.append must be called inside tx.inWrite (D2)") - .isTrue(); - } - - @Test - void createOutboxEventHasAggregateIdMatchingWorklogId() { - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TransactionPort tx = plainTx(); - - WorkLog created = - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) - .handle(createCmd()); - - assertThat(outbox.appended).hasSize(1); - assertThat(outbox.appended.get(0).aggregateId()).isEqualTo(created.id().value()); - } - - @Test - void createOutboxEventIdempotencyKeyEqualsEventId() { - // I12: idempotencyKey = eventId - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TransactionPort tx = plainTx(); - - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) - .handle(createCmd()); - - NewOutboxEvent event = outbox.appended.get(0); - assertThat(event.idempotencyKey()) - .as("idempotencyKey must equal eventId (I12)") - .isEqualTo(event.eventId()); - } - - @Test - void createOutboxEventOccurredAtUsesInjectedClock() { - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TransactionPort tx = plainTx(); - - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) - .handle(createCmd()); - - assertThat(outbox.appended.get(0).occurredAt()) - .isEqualTo(Instant.parse("2025-06-01T10:00:00Z")); - } - - @Test - void createOutboxEventPayloadContainsWorklogIdAndCategory() { - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TransactionPort tx = plainTx(); - - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) - .handle(createCmd()); - - String payload = outbox.appended.get(0).payload(); - assertThat(payload).contains(FIXED_ID.value()); - assertThat(payload).contains("BACKEND"); - } - - @Test - void createOutboxEventCorrelationIdFallsBackToEventIdWhenContextIsAbsent() { - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TransactionPort tx = plainTx(); - - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, NO_CORRELATION, FIXED_CLOCK, tx) - .handle(createCmd()); - - NewOutboxEvent event = outbox.appended.get(0); - assertThat(event.correlationId()) - .as("self-correlation fallback: correlationId = eventId when context is absent") - .isEqualTo(event.eventId()); - } - - @Test - void createOutboxEventUsesCorrelationIdPortWhenPresent() { - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TransactionPort tx = plainTx(); - - CorrelationIdPort correlationIdPort = () -> Optional.of("test-corr-123"); - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, correlationIdPort, FIXED_CLOCK, tx) - .handle(createCmd()); - - assertThat(outbox.appended.get(0).correlationId()).isEqualTo("test-corr-123"); - } - - @Test - void createOutboxEventFallsBackToEventIdWhenCorrelationIdPortReturnsBlank() { - FakeRepo repo = new FakeRepo(); - CapturingOutboxPort outbox = new CapturingOutboxPort(); - TransactionPort tx = plainTx(); - CorrelationIdPort correlationIdPort = () -> Optional.of(" "); - - new CreateWorkLogUseCase(repo, IDS, EVENT_IDS, outbox, correlationIdPort, FIXED_CLOCK, tx) - .handle(createCmd()); - - NewOutboxEvent event = outbox.appended.get(0); - assertThat(event.correlationId()).isEqualTo(event.eventId()); - } - - // Plain pass-through tx (no tracking) - private static TransactionPort plainTx() { - return new TransactionPort() { - @Override - public T inWrite(Supplier a) { - return a.get(); - } - - @Override - public T inRootWrite(Supplier a) { - return a.get(); - } - - @Override - public T inRead(Supplier a) { - return a.get(); - } - - @Override - public T inNew(Supplier a) { - return a.get(); - } - }; - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCaseTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCaseTest.java deleted file mode 100644 index 29123aa..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/GetRepoStatsUseCaseTest.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.application.port.RepoStatsPort; -import dev.caskeleton.sample.portfolio.application.query.GetRepoStatsQuery; -import dev.caskeleton.sample.portfolio.domain.worklog.RepoStats; -import org.junit.jupiter.api.Test; - -class GetRepoStatsUseCaseTest { - - @Test - void delegatesToPort() { - RepoStatsPort port = repoUrl -> new RepoStats("owner/repo", 7, "2025-01-01T00:00:00Z"); - RepoStats result = new GetRepoStatsUseCase(port).handle(new GetRepoStatsQuery("x")); - assertThat(result.stars()).isEqualTo(7); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java deleted file mode 100644 index aa20e9e..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.application.port.WorkLogSummaryQueryPort; -import dev.caskeleton.sample.portfolio.application.query.ListRecentWorkLogSummariesQuery; -import dev.caskeleton.sample.portfolio.application.query.WorkLogSummary; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import java.time.LocalDate; -import java.util.List; -import java.util.function.Supplier; -import org.junit.jupiter.api.Test; - -class ListRecentWorkLogSummariesUseCaseTest { - - /** Records whether the read happened inside {@link TransactionPort#inRead} (D4 boundary). */ - private static final class RecordingTransactionPort implements TransactionPort { - boolean inReadCalled; - - @Override - public T inWrite(Supplier action) { - return action.get(); - } - - @Override - public T inRootWrite(Supplier action) { - return action.get(); - } - - @Override - public T inRead(Supplier action) { - inReadCalled = true; - return action.get(); - } - - @Override - public T inNew(Supplier action) { - return action.get(); - } - } - - @Test - void delegatesToQueryPortInsideReadTransaction() { - WorkLogSummary summary = - new WorkLogSummary( - "0190bd6e-7c3e-7abc-8def-0123456789ab", - "Tuned Hikari pool", - WorkCategory.DATABASE, - LocalDate.of(2025, 1, 1), - null); - WorkLogSummaryQueryPort port = limit -> List.of(summary); - RecordingTransactionPort tx = new RecordingTransactionPort(); - - List result = - new ListRecentWorkLogSummariesUseCase(port, tx) - .handle(new ListRecentWorkLogSummariesQuery(10)); - - assertThat(result).containsExactly(summary); - assertThat(tx.inReadCalled) - .as("projection read must run inside TransactionPort.inRead (D4 default)") - .isTrue(); - } - - @Test - void forwardsTheRequestedLimitToThePort() { - int[] seenLimit = {-1}; - WorkLogSummaryQueryPort port = - limit -> { - seenLimit[0] = limit; - return List.of(); - }; - - new ListRecentWorkLogSummariesUseCase(port, new RecordingTransactionPort()) - .handle(new ListRecentWorkLogSummariesQuery(7)); - - assertThat(seenLimit[0]).isEqualTo(7); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java deleted file mode 100644 index 56945ae..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java +++ /dev/null @@ -1,317 +0,0 @@ -package dev.caskeleton.sample.portfolio.application.worklog; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.github.f4b6a3.uuid.UuidCreator; -import dev.caskeleton.application.observability.CorrelationIdPort; -import dev.caskeleton.application.outbox.OutboxAppendPort; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.application.command.BatchCreateWorkLogsCommand; -import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.command.UpdateWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.exception.WorkLogNotFoundException; -import dev.caskeleton.sample.portfolio.application.query.GetWorkLogQuery; -import dev.caskeleton.sample.portfolio.application.query.ListWorkLogsQuery; -import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogInvariantException; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogPage; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogStatus; -import dev.caskeleton.shared.request.Patch; -import java.time.Clock; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.function.Supplier; -import org.junit.jupiter.api.Test; - -class WorkLogUseCasesTest { - - /** A valid UUID guaranteed absent from an empty FakeRepo. */ - private static final WorkLogId ABSENT_ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - - /** Server-assigned id source (D5) — distinct monotonic UUIDv7s per create. */ - private static final WorkLogIdFactory IDS = - () -> WorkLogId.of(UuidCreator.getTimeOrderedEpochPlus1().toString()); - - static class FakeRepo implements WorkLogRepository { - final List store = new ArrayList<>(); - - @Override - public WorkLog save(WorkLog w) { - store.removeIf(x -> x.id().equals(w.id())); - store.add(w); - return w; - } - - @Override - public Optional findById(WorkLogId id) { - return store.stream().filter(x -> x.id().equals(id)).findFirst(); - } - - @Override - public WorkLogPage findPage( - int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) { - int safeSize = size <= 0 ? 20 : size; - List filtered = - store.stream().filter(x -> category == null || x.category() == category).toList(); - List pageItems = - filtered.stream().skip((long) Math.max(0, page) * safeSize).limit(safeSize).toList(); - return new WorkLogPage(pageItems, filtered.size()); - } - - @Override - public boolean existsById(WorkLogId id) { - return findById(id).isPresent(); - } - - @Override - public void deleteById(WorkLogId id) { - store.removeIf(x -> x.id().equals(id)); - } - } - - static final TransactionPort TX = - new TransactionPort() { - @Override - public T inWrite(Supplier a) { - return a.get(); - } - - @Override - public T inRootWrite(Supplier a) { - return a.get(); - } - - @Override - public T inRead(Supplier a) { - return a.get(); - } - - @Override - public T inNew(Supplier a) { - return a.get(); - } - }; - - /** No-op outbox port — existing tests focus on use-case behaviour, not outbox wiring. */ - static final OutboxAppendPort NO_OP_OUTBOX = e -> {}; - - static final CorrelationIdPort NO_CORRELATION = Optional::empty; - - /** Deterministic event-id stub for existing tests. */ - static final OutboxEventIdFactory STUB_EVENT_IDS = () -> "01FAKEEVENTIDTSV4RRFFQ6900"; - - static final Clock UTC_CLOCK = Clock.systemUTC(); - - private CreateWorkLogCommand createCmd(String title) { - return new CreateWorkLogCommand( - "sample-owner", - title, - WorkCategory.DATABASE, - "s", - "c", - List.of("postgres"), - List.of(), - LocalDate.of(2025, 1, 1), - null); - } - - @Test - void createThenGetReturnsSaved() { - FakeRepo repo = new FakeRepo(); - WorkLog created = - new CreateWorkLogUseCase( - repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) - .handle(createCmd("DB 튜닝")); - WorkLog got = new GetWorkLogUseCase(repo, TX).handle(new GetWorkLogQuery(created.id())); - assertThat(got.title()).isEqualTo("DB 튜닝"); - } - - @Test - void createSetsOwnerFromCommandPrincipal() { - FakeRepo repo = new FakeRepo(); - WorkLog created = - new CreateWorkLogUseCase( - repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) - .handle( - new CreateWorkLogCommand( - "sub-7", - "owner demo", - WorkCategory.DATABASE, - "s", - "c", - List.of("postgres"), - List.of(), - LocalDate.of(2025, 1, 1), - null)); - assertThat(created.owner().value()).isEqualTo("sub-7"); - } - - @Test - void getMissingThrowsNotFound() { - FakeRepo repo = new FakeRepo(); - assertThatThrownBy(() -> new GetWorkLogUseCase(repo, TX).handle(new GetWorkLogQuery(ABSENT_ID))) - .isInstanceOf(WorkLogNotFoundException.class); - } - - @Test - void updateAppliesPatchThreeState() { - FakeRepo repo = new FakeRepo(); - WorkLog created = - new CreateWorkLogUseCase( - repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) - .handle(createCmd("old")); - WorkLog updated = - new UpdateWorkLogUseCase(repo, TX) - .handle( - new UpdateWorkLogCommand( - created.id(), - Patch.of("new title"), - Patch.absent(), - Patch.of("new content"), - Patch.absent(), - Patch.absent())); - assertThat(updated.title()).isEqualTo("new title"); - assertThat(updated.content()).isEqualTo("new content"); - } - - @Test - void updateCanAdvanceStatusButClosedWorklogRejectsLaterMutation() { - FakeRepo repo = new FakeRepo(); - WorkLog created = - new CreateWorkLogUseCase( - repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) - .handle(createCmd("status demo")); - WorkLog inProgress = - new UpdateWorkLogUseCase(repo, TX) - .handle( - new UpdateWorkLogCommand( - created.id(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.of(WorkLogStatus.IN_PROGRESS))); - WorkLog closed = - new UpdateWorkLogUseCase(repo, TX) - .handle( - new UpdateWorkLogCommand( - inProgress.id(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.of(WorkLogStatus.CLOSED))); - - assertThat(closed.status()).isEqualTo(WorkLogStatus.CLOSED); - assertThatThrownBy( - () -> - new UpdateWorkLogUseCase(repo, TX) - .handle( - new UpdateWorkLogCommand( - closed.id(), - Patch.of("late edit"), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.absent()))) - .isInstanceOf(WorkLogInvariantException.class) - .extracting(ex -> ((WorkLogInvariantException) ex).reason()) - .isEqualTo(WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION); - } - - @Test - void updateRejectsRevertingStatusToOpen() { - FakeRepo repo = new FakeRepo(); - WorkLog created = - new CreateWorkLogUseCase( - repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) - .handle(createCmd("status revert")); - WorkLog inProgress = - new UpdateWorkLogUseCase(repo, TX) - .handle( - new UpdateWorkLogCommand( - created.id(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.of(WorkLogStatus.IN_PROGRESS))); - - assertThatThrownBy( - () -> - new UpdateWorkLogUseCase(repo, TX) - .handle( - new UpdateWorkLogCommand( - inProgress.id(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.absent(), - Patch.of(WorkLogStatus.OPEN)))) - .isInstanceOf(WorkLogInvariantException.class) - .extracting(ex -> ((WorkLogInvariantException) ex).reason()) - .isEqualTo(WorkLogInvariantException.Reason.INVALID_STATUS_TRANSITION); - } - - @Test - void listReturnsPageWithTotal() { - FakeRepo repo = new FakeRepo(); - CreateWorkLogUseCase create = - new CreateWorkLogUseCase( - repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX); - create.handle(createCmd("a")); - create.handle(createCmd("b")); - var page = - new ListWorkLogsUseCase(repo, TX).handle(new ListWorkLogsQuery(0, 10, null, true, null)); - assertThat(page.items()).hasSize(2); - assertThat(page.total()).isEqualTo(2); - } - - @Test - void batchCreatePersistsEveryItemAtomically() { - FakeRepo repo = new FakeRepo(); - var batch = - new BatchCreateWorkLogsUseCase(repo, IDS, TX) - .handle(new BatchCreateWorkLogsCommand(List.of(createCmd("one"), createCmd("two")))); - assertThat(batch).hasSize(2); - assertThat(repo.store).hasSize(2); - } - - @Test - void updateExplicitNullClearsNullableField() { - FakeRepo repo = new FakeRepo(); - WorkLog created = - new CreateWorkLogUseCase( - repo, IDS, STUB_EVENT_IDS, NO_OP_OUTBOX, NO_CORRELATION, UTC_CLOCK, TX) - .handle(createCmd("t")); - WorkLog updated = - new UpdateWorkLogUseCase(repo, TX) - .handle( - new UpdateWorkLogCommand( - created.id(), - Patch.absent(), - Patch.ofNull(), - Patch.absent(), - Patch.absent(), - Patch.absent())); - assertThat(updated.summary()).isEqualTo(""); // updateSummary(null) normalizes to "" - } - - @Test - void deleteMissingThrowsNotFound() { - FakeRepo repo = new FakeRepo(); - assertThatThrownBy( - () -> new DeleteWorkLogUseCase(repo, TX).handle(new DeleteWorkLogCommand(ABSENT_ID))) - .isInstanceOf(WorkLogNotFoundException.class); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java deleted file mode 100644 index 9a515fd..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java +++ /dev/null @@ -1,237 +0,0 @@ -package dev.caskeleton.sample.portfolio.authz; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; -import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; -import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; -import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; -import dev.caskeleton.application.observability.CorrelationIdPort; -import dev.caskeleton.application.outbox.NewOutboxEvent; -import dev.caskeleton.application.outbox.OutboxAppendPort; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.application.command.CreateWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.command.DeleteWorkLogCommand; -import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; -import dev.caskeleton.sample.portfolio.domain.worklog.OutboxEventIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogIdFactory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import java.time.Clock; -import java.time.LocalDate; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Supplier; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.EnableAspectJAutoProxy; -import org.springframework.context.annotation.Import; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -/** - * End-to-end demonstration of the authorization contract (§5): real, method-secured use case beans - * are invoked through the Spring AOP proxy with an authenticated principal in the {@code - * SecurityContext}, proving the 3-tier model — public ⊂ authenticated ⊂ authorized — in code. - * - *

    Enforcement runs at the method-security layer ({@link MethodSecurityConfig}), so this test - * deliberately bypasses the HTTP filter chain (no JWT decoding / issuer needed) and exercises the - * {@code @RequiresPermission} → {@code AuthorizationPort} → {@code RolePermissionRegistry} path - * directly. The fixture mirrors the production role bundles: {@code user} = {worklog:read, - * worklog:write}; {@code admin} additionally holds {@code worklog:close}. - */ -@SpringBootTest( - classes = WorkLogAuthorizationContractTest.AuthzTestConfig.class, - webEnvironment = SpringBootTest.WebEnvironment.NONE) -class WorkLogAuthorizationContractTest { - - private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; - - @Autowired CreateWorkLogUseCase createUseCase; - @Autowired DeleteWorkLogUseCase deleteUseCase; - - @MockitoBean WorkLogRepository repository; - @MockitoBean WorkLogIdFactory idFactory; - - @AfterEach - void clearSecurityContext() { - SecurityContextHolder.clearContext(); - } - - private void authenticateWithRoles(String... roles) { - AuthenticatedPrincipal principal = - new AuthenticatedPrincipal("sub-1", "u@example.com", Set.of(roles)); - TestingAuthenticationToken token = new TestingAuthenticationToken(principal, "n/a"); - token.setAuthenticated(true); - SecurityContextHolder.getContext().setAuthentication(token); - } - - private static CreateWorkLogCommand createCommand() { - return new CreateWorkLogCommand( - "sub-1", - "authz demo", - WorkCategory.DATABASE, - "s", - "c", - List.of("postgres"), - List.of(), - LocalDate.of(2025, 1, 1), - null); - } - - @Test - void authenticatedUserWithWritePermissionMayCreate() { - authenticateWithRoles("user"); - when(idFactory.newId()).thenReturn(WorkLogId.of(ID)); - when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - WorkLog created = createUseCase.handle(createCommand()); - - assertThat(created).isNotNull(); - verify(repository).save(any()); - } - - @Test - void authenticatedUserWithoutClosePermissionIsDeniedDelete() { - // tier-3: authenticated ≠ authorized. `user` lacks `worklog:close`. - authenticateWithRoles("user"); - - assertThatThrownBy(() -> deleteUseCase.handle(new DeleteWorkLogCommand(WorkLogId.of(ID)))) - .isInstanceOf(AccessDeniedException.class); - // enforcement happens before the use-case body — the repository is never touched. - verify(repository, never()).deleteById(any()); - verify(repository, never()).existsById(any()); - } - - @Test - void adminWithClosePermissionMayDelete() { - authenticateWithRoles("admin"); - when(repository.existsById(any())).thenReturn(true); - - assertThatCode(() -> deleteUseCase.handle(new DeleteWorkLogCommand(WorkLogId.of(ID)))) - .doesNotThrowAnyException(); - - verify(repository).deleteById(any()); - } - - @Test - void unauthenticatedCallerIsDeniedFailClosed() { - // No authentication in the context. Method security is a defense-in-depth backstop: - // it rejects the missing authentication outright (AuthenticationException) rather than - // reaching a permission check. In production the HTTP filter chain - // (.anyRequest().authenticated()) returns 401 before a request ever reaches here; this - // proves the use case is never silently invoked without an authenticated principal. - assertThatThrownBy(() -> deleteUseCase.handle(new DeleteWorkLogCommand(WorkLogId.of(ID)))) - .isInstanceOf(AuthenticationException.class); - verify(repository, never()).deleteById(any()); - } - - // Force CGLIB class proxies, mirroring Spring Boot's AOP default - // (spring.aop.proxy-target-class=true). The production app gets this from - // AopAutoConfiguration; this isolated slice has no auto-config, so without it the - // method-security advisor would create JDK interface proxies that are not assignable - // to the concrete *UseCase types the controller (and this test) inject. - @Configuration - @EnableAspectJAutoProxy(proxyTargetClass = true) - @Import(MethodSecurityConfig.class) - static class AuthzTestConfig { - - @Bean - RolePermissionRegistry rolePermissionRegistry() { - return new RolePermissionRegistry( - new RolePermissionPolicy( - Map.of( - "user", List.of("worklog:read", "worklog:write"), - "admin", List.of("worklog:read", "worklog:write", "worklog:close")))); - } - - @Bean - AuthorizationAdapter authorizationAdapter(RolePermissionRegistry registry) { - return new AuthorizationAdapter(registry); - } - - /** Pass-through transaction boundary — the contract under test is authorization, not tx. */ - @Bean - TransactionPort transactionPort() { - return new TransactionPort() { - @Override - public T inWrite(Supplier action) { - return action.get(); - } - - @Override - public T inRootWrite(Supplier action) { - return action.get(); - } - - @Override - public T inRead(Supplier action) { - return action.get(); - } - - @Override - public T inNew(Supplier action) { - return action.get(); - } - }; - } - - @Bean - OutboxEventIdFactory outboxEventIdFactory() { - return () -> "0190c9a2-1b3c-7def-8abc-1234567890ab"; - } - - @Bean - OutboxAppendPort outboxAppendPort() { - // No-op for authorization tests — the contract under test is auth, not outbox. - return (NewOutboxEvent e) -> {}; - } - - @Bean - Clock clock() { - return Clock.systemUTC(); - } - - @Bean - CorrelationIdPort correlationIdPort() { - return Optional::empty; - } - - @Bean - CreateWorkLogUseCase createWorkLogUseCase( - WorkLogRepository repository, - WorkLogIdFactory idFactory, - OutboxEventIdFactory eventIdFactory, - OutboxAppendPort outboxAppendPort, - CorrelationIdPort correlationIdPort, - Clock clock, - TransactionPort tx) { - return new CreateWorkLogUseCase( - repository, idFactory, eventIdFactory, outboxAppendPort, correlationIdPort, clock, tx); - } - - @Bean - DeleteWorkLogUseCase deleteWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { - return new DeleteWorkLogUseCase(repository, tx); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java deleted file mode 100644 index 00d40ba..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java +++ /dev/null @@ -1,181 +0,0 @@ -package dev.caskeleton.sample.portfolio.authz; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; -import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; -import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; -import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; -import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; -import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; -import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; -import dev.caskeleton.application.transaction.TransactionPort; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.json.WorkLogIdSerializer; -import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.Supplier; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.EnableAspectJAutoProxy; -import org.springframework.context.annotation.Import; -import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -/** - * Negative + positive authorization E2E over the real {@link WorkLogController} DELETE - * endpoint (feature-authentication-authorization-contract §4/§5 — full HTTP path). - * - *

    This is the path the unit/slice tests could not cover on their own: an HTTP request → MVC - * dispatch → method-security interception of the proxied use case → application {@code - * AuthorizationDeniedException} → Spring {@code AccessDeniedException} → {@link - * GlobalExceptionHandler#handleForbidden} → {@code AUTHZ_INSUFFICIENT_PERMISSION} 403 envelope on - * the wire. The negative case ({@code user} cannot delete) and the positive control ({@code admin} - * can) together prove the wiring end to end. - * - *

    The HTTP security filter chain is intentionally out of scope here (no JWT decoding): the - * authenticated principal is placed directly in the {@code SecurityContext}, which the - * method-security interceptor reads on the synchronous request thread. In production the filter - * chain authenticates first; this test isolates the authorization leg. - */ -@WebMvcTest( - controllers = WorkLogController.class, - excludeAutoConfiguration = SecurityAutoConfiguration.class) -@AutoConfigureMockMvc(addFilters = false) -@Import({ - WorkLogController.class, - GlobalExceptionHandler.class, - EnvelopeBodyAdvice.class, - JacksonNullableConfig.class, - WorkLogIdSerializer.class, - WorkLogAuthorizationE2ETest.AuthzE2EConfig.class -}) -class WorkLogAuthorizationE2ETest { - - private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; - - @Autowired MockMvc mvc; - - // The DeleteWorkLogUseCase @Bean is real (proxied); its repository is a reset-between-tests mock. - @MockitoBean WorkLogRepository repository; - - // controller constructor collaborators that the DELETE path does not exercise - @MockitoBean CreateWorkLogUseCase createUseCase; - @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; - @MockitoBean UpdateWorkLogUseCase updateUseCase; - @MockitoBean GetWorkLogUseCase getUseCase; - @MockitoBean ListWorkLogsUseCase listUseCase; - @MockitoBean GetRepoStatsUseCase repoStatsUseCase; - - @AfterEach - void clearSecurityContext() { - SecurityContextHolder.clearContext(); - } - - private void authenticateWithRoles(String... roles) { - AuthenticatedPrincipal principal = - new AuthenticatedPrincipal("sub-1", "u@example.com", Set.of(roles)); - TestingAuthenticationToken token = new TestingAuthenticationToken(principal, "n/a"); - token.setAuthenticated(true); - SecurityContextHolder.getContext().setAuthentication(token); - } - - @Test - void userDeletingAWorklogGets403InsufficientPermissionEnvelope() throws Exception { - authenticateWithRoles("user"); // has worklog:write, NOT worklog:close - - mvc.perform(delete("/worklogs/{id}", ID)) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.error.code").value("AUTHZ_INSUFFICIENT_PERMISSION")) - .andExpect(jsonPath("$.error.category").value("AUTHZ")) - .andExpect(jsonPath("$.error.retryable").value(false)); - - // enforcement happened before the use-case body — the repository is untouched. - verify(repository, never()).deleteById(any()); - } - - @Test - void adminDeletingAWorklogSucceedsWith204() throws Exception { - authenticateWithRoles("admin"); // has worklog:close - when(repository.existsById(any())).thenReturn(true); - - mvc.perform(delete("/worklogs/{id}", ID)).andExpect(status().isNoContent()); - - verify(repository).deleteById(any()); - } - - @Configuration - @EnableAspectJAutoProxy(proxyTargetClass = true) - @Import(MethodSecurityConfig.class) - static class AuthzE2EConfig { - - @Bean - RolePermissionRegistry rolePermissionRegistry() { - return new RolePermissionRegistry( - new RolePermissionPolicy( - Map.of( - "user", List.of("worklog:read", "worklog:write"), - "admin", List.of("worklog:read", "worklog:write", "worklog:close")))); - } - - @Bean - AuthorizationAdapter authorizationAdapter(RolePermissionRegistry registry) { - return new AuthorizationAdapter(registry); - } - - @Bean - TransactionPort transactionPort() { - return new TransactionPort() { - @Override - public T inWrite(Supplier action) { - return action.get(); - } - - @Override - public T inRootWrite(Supplier action) { - return action.get(); - } - - @Override - public T inRead(Supplier action) { - return action.get(); - } - - @Override - public T inNew(Supplier action) { - return action.get(); - } - }; - } - - @Bean - DeleteWorkLogUseCase deleteWorkLogUseCase(WorkLogRepository repository, TransactionPort tx) { - return new DeleteWorkLogUseCase(repository, tx); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java deleted file mode 100644 index d3c6505..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageIdempotencyConfigTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import static org.assertj.core.api.Assertions.assertThat; - -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyKeyEpochPort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageIdempotencyScopeDigesterPort; -import dev.caskeleton.sample.portfolio.application.posterimage.PosterImageSanitizedIdempotencyContextFactory; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.PosixFilePermission; -import java.util.Base64; -import java.util.Set; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -class PosterImageIdempotencyConfigTest { - - @TempDir Path root; - - @Test - void disabledConfigurationCreatesNoDigester() { - new ApplicationContextRunner() - .withUserConfiguration(PosterImageIdempotencyConfig.class) - .withPropertyValues("app.poster-image.idempotency.enabled=false") - .run( - context -> { - assertThat(context).doesNotHaveBean(PosterImageIdempotencyScopeDigesterPort.class); - assertThat(context) - .doesNotHaveBean(PosterImageSanitizedIdempotencyContextFactory.class); - }); - } - - @Test - void permissionCheckedExternalKeyRingCreatesRotationAwareDigester() throws Exception { - Path keyRing = root.resolve("poster-image.keys"); - String key = - Base64.getEncoder() - .encodeToString("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); - Files.writeString( - keyRing, "key.key-v1=" + key + System.lineSeparator(), StandardCharsets.ISO_8859_1); - try { - Files.setPosixFilePermissions( - keyRing, Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); - } catch (UnsupportedOperationException ignored) { - // The production loader applies the portable no-follow/regular-file/size checks. - } - - new ApplicationContextRunner() - .withUserConfiguration(PosterImageIdempotencyConfig.class) - .withBean(PosterImageIdempotencyKeyEpochPort.class, FixedEpoch::new) - .withPropertyValues( - "app.poster-image.idempotency.enabled=true", - "app.poster-image.idempotency.key-ring-path=" + keyRing, - "app.poster-image.idempotency.bootstrap-active-key-version=key-v1") - .run( - context -> { - assertThat(context).hasNotFailed(); - assertThat(context).hasSingleBean(PosterImageIdempotencyScopeDigesterPort.class); - assertThat(context) - .hasSingleBean(PosterImageSanitizedIdempotencyContextFactory.class); - }); - } - - private static final class FixedEpoch implements PosterImageIdempotencyKeyEpochPort { - - @Override - public String activeVersionForReservation() { - return "key-v1"; - } - - @Override - public void activate(String expectedVersion, String newVersion) { - throw new UnsupportedOperationException(); - } - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java deleted file mode 100644 index 78e324b..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/objectstorage/PosterImageRetirementConfigTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.objectstorage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -import dev.caskeleton.sample.portfolio.application.poster.ReconcilePosterImageRetirementUseCase; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -class PosterImageRetirementConfigTest { - - @Test - void disabledConfigurationCreatesNoWorker() { - new ApplicationContextRunner() - .withUserConfiguration(PosterImageRetirementConfig.class) - .withPropertyValues("app.poster-image.retirement.enabled=false") - .run(context -> assertThat(context).doesNotHaveBean(PosterImageRetirementJob.class)); - } - - @Test - void enabledConfigurationRequiresBoundedIdentityAndCreatesOneWorker() { - new ApplicationContextRunner() - .withUserConfiguration(PosterImageRetirementConfig.class) - .withBean( - ReconcilePosterImageRetirementUseCase.class, - () -> mock(ReconcilePosterImageRetirementUseCase.class)) - .withPropertyValues( - "app.poster-image.retirement.enabled=true", - "app.poster-image.retirement.claimant-digest=" + "a".repeat(64), - "app.poster-image.retirement.batch-size=10", - "app.poster-image.retirement.claim-lease=30s", - "app.poster-image.retirement.operation-timeout=5s") - .run( - context -> { - assertThat(context).hasNotFailed(); - assertThat(context).hasSingleBean(PosterImageRetirementJob.class); - }); - } - - @Test - void enabledConfigurationRejectsRawOrMissingClaimantIdentity() { - new ApplicationContextRunner() - .withUserConfiguration(PosterImageRetirementConfig.class) - .withBean( - ReconcilePosterImageRetirementUseCase.class, - () -> mock(ReconcilePosterImageRetirementUseCase.class)) - .withPropertyValues( - "app.poster-image.retirement.enabled=true", - "app.poster-image.retirement.claimant-digest=worker-1") - .run(context -> assertThat(context).hasFailed()); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfigTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfigTest.java deleted file mode 100644 index ed12411..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/bootstrap/security/SamplePublicAccessSecurityConfigTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package dev.caskeleton.sample.portfolio.bootstrap.security; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import dev.caskeleton.adapter.inbound.web.auth.SecurityConfig; -import dev.caskeleton.adapter.inbound.web.authz.MethodSecurityConfig; -import dev.caskeleton.adapter.inbound.web.config.JacksonNullableConfig; -import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; -import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; -import dev.caskeleton.sample.portfolio.SamplePortfolioApplication; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.error.DomainExceptionHandler; -import dev.caskeleton.sample.portfolio.adapter.inbound.web.json.WorkLogIdSerializer; -import dev.caskeleton.sample.portfolio.application.worklog.BatchCreateWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.CreateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.DeleteWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetRepoStatsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.ListWorkLogsUseCase; -import dev.caskeleton.sample.portfolio.application.worklog.UpdateWorkLogUseCase; -import dev.caskeleton.sample.portfolio.domain.worklog.Period; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLog; -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; -import java.time.LocalDate; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Import; -import org.springframework.core.annotation.AnnotationUtils; -import org.springframework.http.MediaType; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@SpringBootTest(classes = SamplePublicAccessSecurityConfigTest.TestBootstrap.class) -@AutoConfigureMockMvc -class SamplePublicAccessSecurityConfigTest { - - private static final String ID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; - - @Autowired MockMvc mvc; - - @MockitoBean CreateWorkLogUseCase createUseCase; - @MockitoBean BatchCreateWorkLogsUseCase batchCreateUseCase; - @MockitoBean UpdateWorkLogUseCase updateUseCase; - @MockitoBean DeleteWorkLogUseCase deleteUseCase; - @MockitoBean GetWorkLogUseCase getUseCase; - @MockitoBean ListWorkLogsUseCase listUseCase; - @MockitoBean GetRepoStatsUseCase repoStatsUseCase; - - @Test - void sampleWorklogWriteEndpointIsReachableWithoutAuthorizationHeader() throws Exception { - when(createUseCase.handle(any())).thenReturn(sample("public demo")); - - mvc.perform( - post("/worklogs") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"title\":\"public demo\",\"category\":\"DATABASE\",\"periodStart\":\"2025-01-01\"}")) - .andExpect(status().isOk()); - } - - @Test - void sampleCompositionRootDoesNotImportProductionJwtOrMethodSecurity() { - ComponentScan componentScan = - AnnotationUtils.findAnnotation(SamplePortfolioApplication.class, ComponentScan.class); - - assertThat(componentScan).isNotNull(); - assertThat(componentScan.excludeFilters()) - .flatExtracting(filter -> List.of(filter.classes())) - .contains(SecurityConfig.class, MethodSecurityConfig.class); - } - - private static WorkLog sample(String title) { - return WorkLog.rehydrate( - WorkLogId.of(ID), - title, - WorkCategory.DATABASE, - "summary", - "content", - List.of("postgres"), - List.of(), - new Period(LocalDate.of(2025, 1, 1), null), - 0L); - } - - @SpringBootConfiguration - @EnableAutoConfiguration( - exclude = { - DataSourceAutoConfiguration.class, - HibernateJpaAutoConfiguration.class, - OAuth2ResourceServerAutoConfiguration.class - }) - @Import({ - WorkLogController.class, - SamplePublicAccessSecurityConfig.class, - GlobalExceptionHandler.class, - DomainExceptionHandler.class, - EnvelopeBodyAdvice.class, - JacksonNullableConfig.class, - WorkLogIdSerializer.class - }) - static class TestBootstrap {} -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdTest.java deleted file mode 100644 index 7222aa8..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterIdTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import org.junit.jupiter.api.Test; - -class PosterIdTest { - - private static final String VALID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; - - @Test - void acceptsCanonicalUuid() { - assertThat(PosterId.of(VALID).value()).isEqualTo(VALID); - } - - @Test - void rejectsMalformedOrNull() { - assertThatThrownBy(() -> PosterId.of("not-a-uuid")) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> PosterId.of(null)).isInstanceOf(IllegalArgumentException.class); - // an unhyphenated UUID is not canonical — the VO validates the canonical 8-4-4-4-12 form. - assertThatThrownBy(() -> PosterId.of("0190bd6e7c3e7abc8def0123456789ab")) - .isInstanceOf(IllegalArgumentException.class); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java deleted file mode 100644 index 0dfc23a..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterImageReferenceTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; - -import org.junit.jupiter.api.Test; - -class PosterImageReferenceTest { - - @Test - void acceptsOnlyTheBoundedOpaquePublishedReferenceGrammar() { - PosterImageReference reference = - PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); - - assertThat(reference.value()) - .isEqualTo("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); - assertThatIllegalArgumentException() - .isThrownBy(() -> PosterImageReference.of("s3://bucket/posters/1")); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java deleted file mode 100644 index a81e9a0..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/poster/PosterTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.poster; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import org.junit.jupiter.api.Test; - -class PosterTest { - - private static final PosterId ID = PosterId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - private static final PosterImageReference REFERENCE = - PosterImageReference.of("osr1.0123456789ab.0123456789abcdefghjkmnpqrs.d53d9429ac"); - - private Poster sample() { - return Poster.create(ID, "여름 세일 배너", "7월 한정"); - } - - @Test - void createStartsInDraftWithNoImageAndNullVersion() { - Poster p = sample(); - assertThat(p.id()).isEqualTo(ID); - assertThat(p.title()).isEqualTo("여름 세일 배너"); - assertThat(p.status()).isEqualTo(PosterStatus.DRAFT); - assertThat(p.hasImage()).isFalse(); - assertThat(p.version()).isNull(); - } - - @Test - void nullTitleViolatesInvariantNotNpe() { - // client-facing validation must surface as a domain invariant (→ 400), never an NPE (→ 500). - assertThatThrownBy(() -> Poster.create(ID, null, "c")) - .isInstanceOf(PosterInvariantException.class) - .extracting(ex -> ((PosterInvariantException) ex).reason()) - .isEqualTo(PosterInvariantException.Reason.TITLE_BLANK); - } - - @Test - void blankTitleViolatesInvariant() { - assertThatThrownBy(() -> Poster.create(ID, " ", "c")) - .isInstanceOf(PosterInvariantException.class) - .extracting(ex -> ((PosterInvariantException) ex).reason()) - .isEqualTo(PosterInvariantException.Reason.TITLE_BLANK); - } - - @Test - void renameChangesTitleAndChangeCaptionNormalizesNull() { - Poster p = sample(); - p.rename("새 제목"); - p.changeCaption(null); - assertThat(p.title()).isEqualTo("새 제목"); - assertThat(p.caption()).isEmpty(); - } - - @Test - void publishWithoutImageIsRejected() { - Poster p = sample(); - assertThatThrownBy(p::publish) - .isInstanceOf(PosterInvariantException.class) - .extracting(ex -> ((PosterInvariantException) ex).reason()) - .isEqualTo(PosterInvariantException.Reason.IMAGE_REQUIRED); - } - - @Test - void attachImageThenPublishTransitionsToPublished() { - Poster p = sample(); - p.attachImage("posters/x/image"); - p.publish(); - assertThat(p.status()).isEqualTo(PosterStatus.PUBLISHED); - assertThat(p.hasImage()).isTrue(); - } - - @Test - void canonicalWriterAttachesOnlyAPublishedOpaqueReference() { - Poster p = sample(); - - p.attachPublishedImage(REFERENCE); - - assertThat(p.imageAttachment()).isEqualTo(new PublishedPosterImageAttachment(REFERENCE)); - assertThat(p.imageReference()).isEqualTo(REFERENCE.value()); - assertThat(p.imageKey()).isEqualTo(REFERENCE.value()); - } - - @Test - void dualReadRejectsAmbiguousLegacyAndPublishedColumns() { - assertThatThrownBy( - () -> - Poster.rehydrate( - ID, - "title", - "caption", - "posters/legacy", - REFERENCE.value(), - PosterStatus.DRAFT, - 1L)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("both"); - } - - @Test - void draftCanBeArchivedDirectly() { - Poster p = sample(); - p.archive(); - assertThat(p.status()).isEqualTo(PosterStatus.ARCHIVED); - } - - @Test - void archivedIsTerminalSoRepublishIsRejected() { - Poster p = sample(); - p.attachImage("posters/x/image"); - p.publish(); - p.archive(); - assertThatThrownBy(p::publish) - .isInstanceOf(PosterInvariantException.class) - .extracting(ex -> ((PosterInvariantException) ex).reason()) - .isEqualTo(PosterInvariantException.Reason.INVALID_STATUS_TRANSITION); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/PeriodTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/PeriodTest.java deleted file mode 100644 index 8a35fc7..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/PeriodTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.time.LocalDate; -import org.junit.jupiter.api.Test; - -class PeriodTest { - - @Test - void ongoingPeriodHasNullEnd() { - Period p = new Period(LocalDate.of(2025, 1, 1), null); - assertThat(p.isOngoing()).isTrue(); - } - - @Test - void endBeforeStartIsRejected() { - assertThatThrownBy(() -> new Period(LocalDate.of(2025, 5, 1), LocalDate.of(2025, 1, 1))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("end"); - } - - @Test - void startIsRequired() { - assertThatThrownBy(() -> new Period(null, null)).isInstanceOf(NullPointerException.class); - } - - // feature-business-rule-validation-contract C2 / D2: the business invariant - // ("end must not be before start") is enforced inside the domain value object's - // canonical constructor. Period is an immutable record, so the compact canonical - // constructor is the SOLE construction path and there is no setter to bypass it — - // no application-service or persistence path can hand out an invariant-violating - // Period. This is the "intentional bypass test" the branch note's 테스트 계약 requires. - @Test - void invariantIsEnforcedOnTheSoleConstructionPathAndCannotBeBypassed() { - assertThatThrownBy(() -> new Period(LocalDate.of(2030, 12, 31), LocalDate.of(2030, 1, 1))) - .as("invariant must reject end-before-start on the sole (record) construction path (C2)") - .isInstanceOf(IllegalArgumentException.class); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdPropertyTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdPropertyTest.java deleted file mode 100644 index 28a934a..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdPropertyTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import net.jqwik.api.Arbitraries; -import net.jqwik.api.Arbitrary; -import net.jqwik.api.Assume; -import net.jqwik.api.ForAll; -import net.jqwik.api.Property; -import net.jqwik.api.Provide; -import net.jqwik.api.constraints.StringLength; - -/** - * Property-based proof that the {@code @ValueObject} {@link WorkLogId} enforces its format - * invariant on the sole construction path, across many generated inputs - * (feature-domain-modeling-guardrails Claims To Verify — VO invariant). A single example-based test - * can only show the invariant holds for the cases the author happened to pick; jqwik exercises the - * whole input space. - */ -class WorkLogIdPropertyTest { - - private static final String UUID_PATTERN = - "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; - - /** Canonical UUID form (RFC 9562): 32 hex chars grouped 8-4-4-4-12. */ - @Provide - Arbitrary canonicalUuids() { - return Arbitraries.strings() - .withChars("0123456789abcdefABCDEF") - .ofLength(32) - .map(WorkLogIdPropertyTest::hyphenate); - } - - @Property - void acceptsEveryCanonicalUuid(@ForAll("canonicalUuids") String value) { - assertThat(WorkLogId.of(value).value()).isEqualTo(value); - } - - @Property - void rejectsAnyNonCanonicalInput(@ForAll @StringLength(min = 0, max = 40) String value) { - // Filter out the (vanishingly rare) randomly-canonical strings so this property - // only asserts the rejection path. - Assume.that(!value.matches(UUID_PATTERN)); - - assertThatThrownBy(() -> WorkLogId.of(value)).isInstanceOf(IllegalArgumentException.class); - } - - /** 32 non-hex letters hyphenated to the UUID shape — always invalid (g-z are not hex). */ - @Provide - Arbitrary nonHexUuidShapedStrings() { - return Arbitraries.strings() - .withChars("ghijklmnopqrstuvwxyz") - .ofLength(32) - .map(WorkLogIdPropertyTest::hyphenate); - } - - @Property - void rejectsNonHexLetters(@ForAll("nonHexUuidShapedStrings") String value) { - assertThatThrownBy(() -> WorkLogId.of(value)).isInstanceOf(IllegalArgumentException.class); - } - - /** Inserts hyphens into 32 chars to form the canonical 8-4-4-4-12 UUID layout. */ - private static String hyphenate(String hex) { - return hex.substring(0, 8) - + "-" - + hex.substring(8, 12) - + "-" - + hex.substring(12, 16) - + "-" - + hex.substring(16, 20) - + "-" - + hex.substring(20, 32); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdTest.java deleted file mode 100644 index 89866a5..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogIdTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.Locale; -import org.junit.jupiter.api.Test; - -class WorkLogIdTest { - - private static final String VALID = "0190bd6e-7c3e-7abc-8def-0123456789ab"; // D19 fixture - - @Test - void acceptsCanonicalUuid() { - assertThat(WorkLogId.of(VALID).value()).isEqualTo(VALID); - } - - @Test - void rejectsNull() { - assertThatThrownBy(() -> WorkLogId.of(null)).isInstanceOf(IllegalArgumentException.class); - } - - @Test - void acceptsUppercaseHexVariant() { - // The VO regex is case-insensitive over hex; canonical lowercasing happens at the web edge. - String uppercase = VALID.toUpperCase(Locale.ROOT); - assertThat(WorkLogId.of(uppercase).value()).isEqualTo(uppercase); - } - - @Test - void rejectsNonHexLetters() { - assertThatThrownBy(() -> WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789zz")) // z is not hex - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void rejectsWrongLength() { - assertThatThrownBy(() -> WorkLogId.of("0190bd6e-7c3e")) // too short - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void rejectsUnhyphenatedUuid() { - assertThatThrownBy( - () -> WorkLogId.of("0190bd6e7c3e7abc8def0123456789ab")) // no 8-4-4-4-12 groups - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void rejectsCrockfordUlidFormat() { - // the previous 26-char Crockford ULID form is no longer a valid id - assertThatThrownBy(() -> WorkLogId.of("01ARZ3NDEKTSV4RRFFQ69G5FAV")) - .isInstanceOf(IllegalArgumentException.class); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantTest.java deleted file mode 100644 index a212b48..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogInvariantTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dev.caskeleton.shared.error.ApiErrorCode; -import java.time.LocalDate; -import java.util.List; -import org.junit.jupiter.api.Test; - -/** - * Verifies the WorkLog aggregate invariant surfaces as a domain exception carrying a safe reason - * (feature-domain-modeling-guardrails D3/D2): the domain neither logs nor knows operational error - * codes. - */ -class WorkLogInvariantTest { - - private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - - private static WorkLog sample(String title) { - return WorkLog.create( - ID, - title, - WorkCategory.BACKEND, - "s", - "c", - List.of(), - List.of(), - new Period(LocalDate.of(2025, 1, 1), null)); - } - - @Test - void blankTitleIsRejectedWithASafeReasonOnCreate() { - assertThatThrownBy(() -> sample(" ")) - .isInstanceOf(WorkLogInvariantException.class) - .extracting(ex -> ((WorkLogInvariantException) ex).reason()) - .isEqualTo(WorkLogInvariantException.Reason.TITLE_BLANK); - } - - @Test - void renameToBlankIsRejected() { - WorkLog w = sample("ok"); - assertThatThrownBy(() -> w.rename("")).isInstanceOf(WorkLogInvariantException.class); - } - - @Test - void nullTitleStaysANullCheckNotAnInvariantViolation() { - // The "required" check (NPE) and the "must not be blank" invariant are distinct. - assertThatThrownBy(() -> sample(null)).isInstanceOf(NullPointerException.class); - } - - // D2: the domain invariant exception carries only a safe domain reason — it must NOT - // implement the operational ApiErrorCode contract. Translating reason() into an - // error.category / log line is the application/web layer's responsibility. - @Test - void invariantExceptionDoesNotKnowOperationalErrorCodes() { - WorkLogInvariantException ex = - new WorkLogInvariantException(WorkLogInvariantException.Reason.TITLE_BLANK); - - assertThat(ex).isNotInstanceOf(ApiErrorCode.class); - assertThat(ex.reason()).isEqualTo(WorkLogInvariantException.Reason.TITLE_BLANK); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java deleted file mode 100644 index b8f4467..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/domain/worklog/WorkLogTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package dev.caskeleton.sample.portfolio.domain.worklog; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.time.LocalDate; -import java.util.List; -import org.junit.jupiter.api.Test; - -class WorkLogTest { - - private static final WorkLogId ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - - private WorkLog sample() { - return WorkLog.create( - ID, - "DB 쿼리 튜닝", - WorkCategory.DATABASE, - "slow query 개선", - "...", - List.of("postgres"), - List.of(), - new Period(LocalDate.of(2025, 1, 1), null)); - } - - @Test - void createKeepsAssignedIdAndFields() { - WorkLog w = sample(); - assertThat(w.id()).isEqualTo(ID); - assertThat(w.title()).isEqualTo("DB 쿼리 튜닝"); - assertThat(w.category()).isEqualTo(WorkCategory.DATABASE); - } - - @Test - void titleIsRequired() { - assertThatThrownBy( - () -> - WorkLog.create( - ID, - null, - WorkCategory.DATABASE, - "s", - "c", - List.of(), - List.of(), - new Period(LocalDate.of(2025, 1, 1), null))) - .isInstanceOf(NullPointerException.class); - } - - @Test - void renameChangesTitle() { - WorkLog w = sample(); - w.rename("새 제목"); - assertThat(w.title()).isEqualTo("새 제목"); - } - - @Test - void createStartsOpenAndKeepsOwner() { - WorkLog w = sample(); - assertThat(w.status()).isEqualTo(WorkLogStatus.OPEN); - assertThat(w.owner()).isEqualTo(WorkLogOwner.of("sample-owner")); - } - - @Test - void statusTransitionIsOpenToInProgressToClosed() { - WorkLog w = sample(); - w.startProgress(); - assertThat(w.status()).isEqualTo(WorkLogStatus.IN_PROGRESS); - - w.close(); - assertThat(w.status()).isEqualTo(WorkLogStatus.CLOSED); - } - - @Test - void closedWorklogRejectsContentMutation() { - WorkLog w = sample(); - w.startProgress(); - w.close(); - - assertThatThrownBy(() -> w.rename("닫힌 작업 수정")) - .isInstanceOf(WorkLogInvariantException.class) - .extracting(ex -> ((WorkLogInvariantException) ex).reason()) - .isEqualTo(WorkLogInvariantException.Reason.CLOSED_WORKLOG_MUTATION); - } -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/fixtures/SamplePortfolioFixture.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/fixtures/SamplePortfolioFixture.java deleted file mode 100644 index e6c7642..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/fixtures/SamplePortfolioFixture.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.caskeleton.sample.portfolio.fixtures; - -import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId; - -/** - * Shared sample fixtures (feature-resource-identifier-contract §8/D19). - * - *

    {@link #WORK_LOG_ID} is the canonical reference UUID cross-cited by - * feature-api-contract-baseline (URL path variable example) and the project note's §17 Sample - * Domain Fixture / §22 Sample-portfolio Contract Matrix. - */ -public final class SamplePortfolioFixture { - - /** D19: canonical 36-char UUID (RFC 9562 UUIDv7) reference value. */ - public static final WorkLogId WORK_LOG_ID = WorkLogId.of("0190bd6e-7c3e-7abc-8def-0123456789ab"); - - private SamplePortfolioFixture() {} -} diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/testinfra/PostgreSqlTestContainer.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/testinfra/PostgreSqlTestContainer.java deleted file mode 100644 index 536fd19..0000000 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/testinfra/PostgreSqlTestContainer.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.caskeleton.sample.portfolio.testinfra; - -import org.testcontainers.postgresql.PostgreSQLContainer; - -/** Creates sample integration-test databases with reuse off unless local opt-in is explicit. */ -public final class PostgreSqlTestContainer { - - public static final String IMAGE = "postgres:16-alpine"; - private static final String REUSE_ENV = "TESTCONTAINERS_REUSE_ENABLE"; - - private PostgreSqlTestContainer() {} - - @SuppressWarnings("resource") - public static PostgreSQLContainer create() { - boolean localReuse = Boolean.parseBoolean(System.getenv(REUSE_ENV)); - return new PostgreSQLContainer(IMAGE).withReuse(localReuse); - } -} diff --git a/src/sample-portfolio/src/test/resources/application-test.yml b/src/sample-portfolio/src/test/resources/application-test.yml deleted file mode 100644 index f9f38b3..0000000 --- a/src/sample-portfolio/src/test/resources/application-test.yml +++ /dev/null @@ -1,195 +0,0 @@ -# ============================================================================= -# Test profile overrides for SampleApplicationContextTest and any future -# sample-portfolio slice tests that need the full context. -# -# Testcontainers provides a real Postgres; datasource properties are registered -# via @DynamicPropertySource in SampleApplicationContextTest. -# All ${ENV_VAR} placeholders without a .env file are resolved here with safe defaults. -# ============================================================================= - -spring: - application: - name: sample-portfolio-test - mvc: - problemdetails: - enabled: false - web: - error: - include-stacktrace: never - include-message: never - # Testcontainers datasource is set via @DynamicPropertySource in SampleApplicationContextTest. - # Provide safe non-networked defaults for any other test that does not use Testcontainers. - datasource: - url: jdbc:postgresql://localhost:5432/sample_test - username: sample_test - password: sample_test - driver-class-name: org.postgresql.Driver - hikari: - maximum-pool-size: 5 - minimum-idle: 1 - connection-timeout: 30000 - idle-timeout: 600000 - max-lifetime: 1800000 - keepalive-time: 120000 - leak-detection-threshold: 30000 - validation-timeout: 3000 - initialization-fail-timeout: 1 - flyway: - baseline-on-migrate: false - out-of-order: false - clean-disabled: true - locations: classpath:db/migration/postgresql,classpath:db/sample-migration - jpa: - hibernate: - ddl-auto: none - show-sql: false - open-in-view: false - properties: - hibernate: - format_sql: false - security: - oauth2: - resourceserver: - jwt: - issuer-uri: http://localhost:8081/realms/sample-test - audiences: sample-test - main: - banner-mode: off - lazy-initialization: false - log-startup-info: false - threads: - virtual: - enabled: false - jackson: - deserialization: - fail-on-unknown-properties: true - fail-on-null-for-primitives: true - fail-on-ignored-properties: true - datatype: - enum: - read-unknown-enum-values-as-null: false - datetime: - write-dates-as-timestamps: false - lifecycle: - timeout-per-shutdown-phase: 30s - -management: - # Random port to avoid conflicts with the production default 9001. - server: - port: 0 - -server: - port: 0 - shutdown: immediate - forward-headers-strategy: none - tomcat: - threads: - max: 10 - min-spare: 2 - accept-count: 10 - max-connections: 100 - connection-timeout: 20s - compression: - enabled: false - min-response-size: 1024 -logging: - level: - root: WARN - dev.caskeleton: WARN - org.springframework: WARN - org.springframework.web: WARN - org.hibernate.SQL: WARN - -ca-skeleton: - bootstrap: - app-name: sample-portfolio-test - runtime: - error-detail-exposure-enabled: false - log-body-capture-enabled: false - multi-instance-enabled: false - lock: - wait-time: 3s - lease-ttl: 30s - presentation: - api-base-path: /api - rate-limit: - enabled: false - limit: 100 - window: 1s - algorithm: fixed-window - idempotency: - ttl: 24h - reaper-interval: 10m - security: - issuer-uri: http://localhost:8081/realms/sample-test - audience: sample-test - public-paths: /api/healthcheck - authz: - role-permissions: - user: worklog:read,worklog:write - admin: worklog:read,worklog:write,worklog:close - cors: - enabled: true - allowed-origins: http://localhost:3000 - allowed-methods: "" - allowed-headers: "*" - allow-credentials: true - max-age-seconds: 3600 - logging: - file: - enabled: false - path: logs/sample-portfolio-test.json - max-size: 100MB - max-history: 14 - total-size-cap: 3GB - async: - enabled: false - queue-size: 512 - discarding-threshold: 20 - json: - timezone: UTC - timestamp-pattern: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" - include-caller-data: false - logger-name-length: 0 - sampling-rate: 1.0 - privacy: - pseudonymization-salt: __LOCAL_DEV_test_salt - tracing: - enabled: true - sample-rate: "" - exporter: - otlp-endpoint: "" - outbox: - relay-enabled: false - poll-interval: PT5S - batch-size: 20 - in-flight-timeout: PT5M - reaper-interval: PT10M - published-retention: P7D - -app: - poster-image: - api: - mode: legacy - idempotency: - enabled: false - publication: - enabled: false - object-storage: - enabled: false - legacy: - enabled: true - backend: filesystem - base-path: ./.data/test-objectstorage - cache: - redis: - enabled: false - messaging: - broker: "" - kafka: - brokers: "" - notification: - slack: - provider: "" - email: - provider: "" diff --git a/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json b/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json deleted file mode 100644 index 832008d..0000000 --- a/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json +++ /dev/null @@ -1 +0,0 @@ -{"workLogId":"worklog-42","unexpected":"rejected"} diff --git a/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json b/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json deleted file mode 100644 index 4de16dd..0000000 --- a/src/sample-portfolio/src/test/resources/contracts/messaging/portfolio.worklog.reserved/v1.valid.json +++ /dev/null @@ -1 +0,0 @@ -{"workLogId":"worklog-42"} diff --git a/src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json b/src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json deleted file mode 100644 index 8ea1e66..0000000 --- a/src/sample-portfolio/src/test/resources/openapi/worklogs-openapi-snapshot.json +++ /dev/null @@ -1,523 +0,0 @@ -{ - "components" : { - "schemas" : { - "ApiError" : { - "properties" : { - "category" : { - "type" : "string" - }, - "code" : { - "type" : "string" - }, - "details" : { - "type" : "object" - }, - "message" : { - "type" : "string" - }, - "retryable" : { - "type" : "boolean" - } - }, - "type" : "object" - }, - "BatchCreateRequest" : { - "properties" : { - "requests" : { - "items" : { - "$ref" : "#/components/schemas/CreateWorkLogRequest" - }, - "maxItems" : 1000, - "minItems" : 0, - "type" : "array" - } - }, - "type" : "object" - }, - "CreateWorkLogRequest" : { - "properties" : { - "category" : { - "enum" : [ "INFRASTRUCTURE", "DATABASE", "BACKEND", "PLATFORM" ], - "type" : "string" - }, - "content" : { - "type" : "string" - }, - "links" : { - "items" : { - "type" : "string" - }, - "type" : "array" - }, - "periodEnd" : { - "format" : "date", - "type" : "string" - }, - "periodOrdered" : { - "type" : "boolean" - }, - "periodStart" : { - "format" : "date", - "type" : "string" - }, - "summary" : { - "type" : "string" - }, - "techStack" : { - "items" : { - "type" : "string" - }, - "type" : "array" - }, - "title" : { - "type" : "string" - } - }, - "type" : "object" - }, - "EnvelopeListWorkLogSummaryResponse" : { - "properties" : { - "data" : { - "items" : { - "$ref" : "#/components/schemas/WorkLogSummaryResponse" - }, - "type" : "array" - }, - "error" : { - "$ref" : "#/components/schemas/ApiError" - }, - "meta" : { - "$ref" : "#/components/schemas/ResponseMeta" - }, - "success" : { - "type" : "boolean" - } - }, - "type" : "object" - }, - "JsonNullableString" : { - "properties" : { - "present" : { - "type" : "boolean" - } - }, - "type" : "object" - }, - "JsonNullableWorkCategory" : { - "properties" : { - "present" : { - "type" : "boolean" - } - }, - "type" : "object" - }, - "JsonNullableWorkLogStatus" : { - "properties" : { - "present" : { - "type" : "boolean" - } - }, - "type" : "object" - }, - "PageMeta" : { - "properties" : { - "number" : { - "format" : "int32", - "type" : "integer" - }, - "size" : { - "format" : "int32", - "type" : "integer" - }, - "sort" : { - "type" : "string" - }, - "total" : { - "format" : "int64", - "type" : "integer" - } - }, - "type" : "object" - }, - "RepoStatsResponse" : { - "properties" : { - "fullName" : { - "type" : "string" - }, - "lastPushedIso" : { - "type" : "string" - }, - "stars" : { - "format" : "int32", - "type" : "integer" - } - }, - "type" : "object" - }, - "ResponseMeta" : { - "properties" : { - "correlationId" : { - "type" : "string" - }, - "page" : { - "$ref" : "#/components/schemas/PageMeta" - }, - "requestId" : { - "type" : "string" - }, - "traceId" : { - "type" : "string" - } - }, - "type" : "object" - }, - "UpdateWorkLogRequest" : { - "properties" : { - "category" : { - "$ref" : "#/components/schemas/JsonNullableWorkCategory" - }, - "content" : { - "$ref" : "#/components/schemas/JsonNullableString" - }, - "status" : { - "$ref" : "#/components/schemas/JsonNullableWorkLogStatus" - }, - "summary" : { - "$ref" : "#/components/schemas/JsonNullableString" - }, - "title" : { - "$ref" : "#/components/schemas/JsonNullableString" - } - }, - "type" : "object" - }, - "WorkLogId" : { - "properties" : { - "value" : { - "type" : "string" - } - }, - "type" : "object" - }, - "WorkLogResponse" : { - "properties" : { - "category" : { - "enum" : [ "INFRASTRUCTURE", "DATABASE", "BACKEND", "PLATFORM" ], - "type" : "string" - }, - "content" : { - "type" : "string" - }, - "id" : { - "$ref" : "#/components/schemas/WorkLogId" - }, - "links" : { - "items" : { - "type" : "string" - }, - "type" : "array" - }, - "periodEnd" : { - "format" : "date", - "type" : "string" - }, - "periodStart" : { - "format" : "date", - "type" : "string" - }, - "status" : { - "enum" : [ "OPEN", "IN_PROGRESS", "CLOSED" ], - "type" : "string" - }, - "summary" : { - "type" : "string" - }, - "techStack" : { - "items" : { - "type" : "string" - }, - "type" : "array" - }, - "title" : { - "type" : "string" - } - }, - "type" : "object" - }, - "WorkLogSummaryResponse" : { - "properties" : { - "category" : { - "enum" : [ "INFRASTRUCTURE", "DATABASE", "BACKEND", "PLATFORM" ], - "type" : "string" - }, - "id" : { - "$ref" : "#/components/schemas/WorkLogId" - }, - "periodEnd" : { - "format" : "date", - "type" : "string" - }, - "periodStart" : { - "format" : "date", - "type" : "string" - }, - "status" : { - "enum" : [ "OPEN", "IN_PROGRESS", "CLOSED" ], - "type" : "string" - }, - "title" : { - "type" : "string" - } - }, - "type" : "object" - } - } - }, - "info" : { - "title" : "OpenAPI definition", - "version" : "v0" - }, - "openapi" : "3.1.0", - "paths" : { - "/worklogs" : { - "get" : { - "operationId" : "list", - "parameters" : [ { - "in" : "query", - "name" : "page", - "required" : false, - "schema" : { - "format" : "int32", - "type" : "integer" - } - }, { - "in" : "query", - "name" : "size", - "required" : false, - "schema" : { - "format" : "int32", - "type" : "integer" - } - }, { - "in" : "query", - "name" : "sort", - "required" : false, - "schema" : { - "type" : "string" - } - }, { - "in" : "query", - "name" : "category", - "required" : false, - "schema" : { - "enum" : [ "INFRASTRUCTURE", "DATABASE", "BACKEND", "PLATFORM" ], - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/EnvelopeListWorkLogSummaryResponse" - } - } - }, - "description" : "OK" - } - }, - "tags" : [ "work-log-controller" ] - }, - "post" : { - "operationId" : "create", - "parameters" : [ { - "in" : "header", - "name" : "Idempotency-Key", - "required" : false, - "schema" : { - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/CreateWorkLogRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/WorkLogResponse" - } - } - }, - "description" : "OK" - } - }, - "tags" : [ "work-log-controller" ] - } - }, - "/worklogs/repoStats" : { - "get" : { - "operationId" : "repoStats", - "parameters" : [ { - "in" : "query", - "name" : "repoUrl", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/RepoStatsResponse" - } - } - }, - "description" : "OK" - } - }, - "tags" : [ "work-log-controller" ] - } - }, - "/worklogs/{id}" : { - "delete" : { - "operationId" : "delete", - "parameters" : [ { - "in" : "path", - "name" : "id", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - }, - "tags" : [ "work-log-controller" ] - }, - "get" : { - "operationId" : "getOne", - "parameters" : [ { - "in" : "path", - "name" : "id", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "in" : "header", - "name" : "If-None-Match", - "required" : false, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/WorkLogResponse" - } - } - }, - "description" : "OK" - } - }, - "tags" : [ "work-log-controller" ] - }, - "patch" : { - "operationId" : "patch", - "parameters" : [ { - "in" : "path", - "name" : "id", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "in" : "header", - "name" : "If-Match", - "required" : false, - "schema" : { - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/UpdateWorkLogRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/WorkLogResponse" - } - } - }, - "description" : "OK" - } - }, - "tags" : [ "work-log-controller" ] - } - }, - "/worklogs:batchCreate" : { - "post" : { - "operationId" : "batchCreate", - "parameters" : [ { - "in" : "header", - "name" : "Idempotency-Key", - "required" : false, - "schema" : { - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/BatchCreateRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "content" : { - "*/*" : { - "schema" : { - "items" : { - "$ref" : "#/components/schemas/WorkLogResponse" - }, - "type" : "array" - } - } - }, - "description" : "OK" - } - }, - "tags" : [ "work-log-controller" ] - } - } - } -} diff --git a/src/settings.gradle b/src/settings.gradle index d2acc6a..930d3d1 100644 --- a/src/settings.gradle +++ b/src/settings.gradle @@ -24,7 +24,7 @@ if (moduleRegistry.keySet().collect { it as String }.toSet() != expectedRootFiel if (!(moduleRegistry.modules instanceof List) || moduleRegistry.modules.isEmpty()) { throw new GradleException("Module registry has no modules: ${moduleRegistryFile}") } -Set expectedRuntimeCompositions = ['app-bootstrap', 'sample-portfolio'] as Set +Set expectedRuntimeCompositions = ['app-bootstrap'] as Set if (!(moduleRegistry.runtime_compositions instanceof List) || moduleRegistry.runtime_compositions.collect { it as String }.toSet() != expectedRuntimeCompositions || @@ -34,7 +34,7 @@ if (!(moduleRegistry.runtime_compositions instanceof List) || moduleRegistryFile) } -int expectedModuleCount = 19 +int expectedModuleCount = 18 if (moduleRegistry.modules.size() != expectedModuleCount) { throw new GradleException( "Module registry must contain exactly ${expectedModuleCount} modules, " + @@ -167,7 +167,7 @@ validatedModules.each { module -> if (module.id != 'sample-portfolio' && dependencyId == 'sample-portfolio') { throw new GradleException( "Production module registry entry '${module.id}' must not allow a dependency on " + - "'sample-portfolio'.") + "deleted 'sample-portfolio' (negative re-entry guard).") } if (!moduleIds.contains(dependencyId)) { throw new GradleException(