diff --git a/.gitignore b/.gitignore index 9d7183f..b9a1e41 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,8 @@ out/ ### Python ### .venv -.env \ No newline at end of file +.env +_workflow.md +_workflow.log +_state.json +run_workflow.py diff --git a/CLAUDE.md b/CLAUDE.md index 10eeab3..49da0a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,10 +22,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **BEHAVIOR EXAMPLES:** -* **[Handling JPA Lazy Loading]** - * **BAD:** Accessing collection fields to trigger loading (`val _ = entity.items.size`). - * **GOOD:** Using `@Query("SELECT e FROM Entity e JOIN FETCH e.items WHERE ...")`. - * **[Handling Test Failures]** * **BAD:** Adding `@Disabled("fix later")` or commenting out assertions. * **GOOD:** Analyzing TestContainer/WireMock logs and fixing the root cause. @@ -49,11 +45,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Framework:** Spring Boot 3.5.6, Spring Cloud 2025.0.0 - **Build:** Gradle with Kotlin DSL (multi-project) - **Database:** PostgreSQL 15, Flyway migrations -- **ORM:** Spring Data JPA (user/tarot services), Spring Data R2DBC (divination-service) +- **ORM:** Spring Data R2DBC (all services - fully reactive) +- **Event Streaming:** Apache Kafka 7.5 (3 brokers, KRaft mode) - **Service Discovery:** Netflix Eureka - **API Gateway:** Spring Cloud Gateway - **Inter-service:** Spring Cloud OpenFeign - **Resilience:** Resilience4j (circuit breaker, retry, time limiter) +- **Object Storage:** MinIO (S3-compatible) - **Code Style:** ktlint 1.5.0 ## Microservices Architecture @@ -63,26 +61,119 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | **config-server** | 8888 | Spring Cloud Config | Centralized configuration | | **eureka-server** | 8761 | Netflix Eureka | Service discovery | | **gateway-service** | 8080 | Spring Cloud Gateway | API Gateway, JWT validation | -| **user-service** | 8081 | Spring MVC + JPA | User management, authentication | -| **tarot-service** | 8082 | Spring MVC + JPA | Cards & layout types reference data | +| **user-service** | 8081 | WebFlux + R2DBC | User management, authentication (reactive) | +| **tarot-service** | 8082 | WebFlux + R2DBC | Cards & layout types reference data (reactive) | | **divination-service** | 8083 | WebFlux + R2DBC | Spreads & interpretations (reactive) | +| **notification-service** | 8084 | WebFlux + R2DBC + Kafka | Real-time notifications (reactive) | +| **files-service** | 8085 | WebFlux + R2DBC + MinIO | File attachments for interpretations (reactive) | +| **kafka-1/2/3** | 9092-9094 | Apache Kafka (KRaft) | Event streaming cluster (3 brokers) | +| **kafka-ui** | 8090 | Provectus Kafka UI | Kafka cluster monitoring | +| **minio** | 9000/9001 | MinIO | S3-compatible object storage | -**Shared modules:** `shared-dto` (DTOs), `shared-clients` (Feign clients), `e2e-tests` +**Shared modules:** `shared-dto` (DTOs, event data classes), `shared-clients` (Feign clients), `e2e-tests` **Inter-service Communication:** - Services register with Eureka and discover each other dynamically -- `divination-service` calls other services via Feign clients +- `divination-service` calls other services via Feign clients (synchronous) +- `notification-service` consumes events from Kafka and pushes via WebSocket (asynchronous) +- `user-service` and `divination-service` publish domain events to Kafka (asynchronous) - External clients access through `gateway-service` - Currently all services share a single PostgreSQL database with separate Flyway history tables, but are designed for independent database deployment -**Cascade Delete (User Deletion):** -- When a user is deleted, user-service calls divination-service's internal API first -- Internal endpoint: `DELETE /internal/users/{userId}/data` -- Deletes all user's spreads and interpretations before user record is removed -- User deletion fails (503 Service Unavailable) if cleanup fails +**Cascade Delete (User Deletion - Eventual Consistency):** +- When a user is deleted, user-service publishes a `DELETED` event to `users-events` Kafka topic +- divination-service consumes the event and asynchronously deletes all user's spreads and interpretations +- User deletion completes immediately; cleanup happens asynchronously +- If divination-service is temporarily unavailable, cleanup will occur when it recovers +- During the eventual consistency window, orphaned spreads may briefly remain visible **Configuration:** External Git repository (`highload-config/` submodule) served by config-server. +## Clean Architecture + +All five backend services (user-service, tarot-service, divination-service, notification-service, files-service) follow Clean Architecture with four layers: + +``` +{service}/ +├── domain/ +│ └── model/ # Pure domain objects (no annotations) +│ +├── application/ +│ ├── service/ # Use cases / application services +│ └── interfaces/ +│ ├── repository/ # Persistence interfaces +│ ├── provider/ # External service interfaces +│ └── publisher/ # Event publishing interfaces +│ +├── infrastructure/ +│ ├── persistence/ +│ │ ├── entity/ # R2DBC @Table classes +│ │ ├── repository/ # Spring Data R2DBC interfaces (internal) +│ │ └── mapper/ # Entity ↔ Domain mappers +│ ├── messaging/ +│ │ ├── mapper/ # Domain ↔ Event DTO mappers +│ │ └── Kafka*.kt # Kafka publisher implementations +│ ├── external/ # Feign implementations of provider interfaces +│ └── security/ # Security implementations +│ +├── api/ +│ ├── controller/ # REST controllers +│ └── mapper/ # DTO ↔ Domain mappers +│ +└── config/ # Spring configurations +``` + +### Layer Dependencies + +| Layer | Contains | Depends on | +|-------|----------|------------| +| Domain | Pure business entities | Nothing | +| Application | Use cases, interfaces | Domain only | +| Infrastructure | R2DBC, Feign, Security | Application, Domain | +| API | HTTP boundary (controllers) | Application, Domain | + +### Three Model Types + +Each service has three model types: + +| Type | Location | Purpose | Annotations | +|------|----------|---------|-------------| +| Domain | `domain/model/` | Pure business objects | None | +| Entity | `infrastructure/persistence/entity/` | Database persistence | `@Table`, `@Id` | +| DTO | `shared-dto` module | API serialization | `@JsonProperty` | + +### Naming Convention + +No "Port", "Adapter", "Impl" suffixes. Technology prefix for implementations: + +| Interface (application/) | Implementation (infrastructure/) | +|--------------------------|----------------------------------| +| `CardRepository` | `R2dbcCardRepository` | +| `UserProvider` | `FeignUserProvider` | +| `TokenProvider` | `JwtTokenProvider` | +| `CurrentUserProvider` | `SecurityContextCurrentUserProvider` | +| `UserEventPublisher` | `KafkaUserEventPublisher` | +| `SpreadEventPublisher` | `KafkaSpreadEventPublisher` | +| `FileEventPublisher` | `KafkaFileEventPublisher` | +| `FileStorage` | `MinioFileStorage` | + +### Data Flow + +``` +HTTP Request → DTO → Domain Model → Entity → Database +Database → Entity → Domain Model → DTO → HTTP Response +``` + +### Key Design Decisions + +1. **Application interfaces return `Mono/Flux`** - Pragmatic choice for reactive application, avoids blocking wrapper overhead. + +2. **Feign calls in infrastructure, not mappers** - Mappers are pure functions (Entity ↔ Domain, Domain ↔ DTO). External service calls happen in `FeignUserProvider`, `FeignCardProvider`, etc. + +3. **Spring Data interfaces are internal** - `SpringDataCardRepository` (extends `R2dbcRepository`) is internal to infrastructure. Application layer uses `CardRepository` interface. + +4. **Domain models have no framework annotations** - `domain/model/` classes are plain Kotlin data classes. + ## Authentication & Authorization ### Authentication Flow @@ -146,6 +237,26 @@ Minimum 8 chars, uppercase, lowercase, digit, special character (@$!%*?&#). - FK to spread only (internal); author_id stored without FK constraint - Unique constraint: (author_id, spread_id) +**interpretation_attachment** - (id UUID PK, interpretation_id UUID FK UNIQUE CASCADE, file_upload_id UUID, original_file_name VARCHAR(256), content_type VARCHAR(128), file_size BIGINT, created_at TIMESTAMPTZ) +- FK to interpretation only (internal); file_upload_id references files-service without FK constraint +- UNIQUE on interpretation_id (one attachment per interpretation) +- File metadata cached locally to avoid N+1 calls to files-service + +### notification-service tables + +**notification** - (id UUID PK, recipient_id UUID, interpretation_id UUID UNIQUE, interpretation_author_id UUID, spread_id UUID, title VARCHAR(256), message TEXT, is_read BOOLEAN, created_at TIMESTAMPTZ) +- UNIQUE on interpretation_id for deduplication (one notification per interpretation) +- Index on (recipient_id, is_read, created_at DESC) +- No foreign keys to other services (service independence) + +### files-service tables + +**file_upload** - (id UUID PK, user_id UUID, file_path VARCHAR(512), original_file_name VARCHAR(256), content_type VARCHAR(128), file_size BIGINT, status VARCHAR(20), created_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, completed_at TIMESTAMPTZ) +- status: PENDING (upload requested), COMPLETED (file verified in MinIO), EXPIRED (cleanup) +- expires_at: For PENDING cleanup (default 60 min from creation) +- Indexes: user_id, status, expires_at (where PENDING) +- No foreign keys to other services (service independence) + ## API Endpoints Base path: `/api/v0.0.1` @@ -179,14 +290,51 @@ Base path: `/api/v0.0.1` | GET | `/spreads/{id}` | Get spread with cards/interpretations | Any | | DELETE | `/spreads/{id}` | Delete spread | Author/ADMIN | | GET | `/spreads/{spreadId}/interpretations` | List interpretations | Any | -| POST | `/spreads/{spreadId}/interpretations` | Add interpretation | MEDIUM/ADMIN | +| POST | `/spreads/{spreadId}/interpretations` | Add interpretation (optional uploadId for attachment) | MEDIUM/ADMIN | | PUT | `/spreads/{spreadId}/interpretations/{id}` | Update interpretation | Author/ADMIN | | DELETE | `/spreads/{spreadId}/interpretations/{id}` | Delete interpretation | Author/ADMIN | +### notification-service +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| GET | `/notifications?page=N&size=M&isRead=bool` | List notifications for current user | Any | +| GET | `/notifications/unread-count` | Get unread count for current user | Any | +| PUT | `/notifications/{id}/read` | Mark notification as read | Any | + +### notification-service (WebSocket) +| Endpoint | Description | Auth | +|----------|-------------|------| +| `/ws/notifications?token=JWT` | Real-time notification stream | JWT via query param | + +WebSocket connections receive real-time notifications when interpretations are added to user's spreads. + +### files-service +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| POST | `/files/presigned-upload` | Request presigned URL for direct MinIO upload | Any | +| GET | `/files/{uploadId}` | Get file upload metadata | Any | +| GET | `/files/{uploadId}/download-url` | Get presigned download URL | Any | +| DELETE | `/files/{uploadId}` | Delete file upload | Owner | + +**Upload flow:** +1. Client requests presigned URL via `POST /files/presigned-upload` with fileName and contentType +2. Service validates content type (image/jpeg, image/png, image/gif, image/webp) and size limit (5MB) +3. Service creates PENDING record and returns uploadId + presigned PUT URL +4. Client uploads file directly to MinIO using presigned URL +5. When creating interpretation, client includes uploadId +6. divination-service verifies upload via internal API and creates attachment + +### files-service (Internal API) +| Method | Endpoint | Description | Access | +|--------|----------|-------------|--------| +| POST | `/internal/files/{uploadId}/verify` | Verify upload exists and complete it | Service-to-service only | +| GET | `/internal/files/{uploadId}/metadata` | Get upload metadata | Service-to-service only | +| GET | `/internal/files/{uploadId}/download-url` | Get presigned download URL | Service-to-service only | + ### divination-service (Internal API) | Method | Endpoint | Description | Access | |--------|----------|-------------|--------| -| DELETE | `/internal/users/{userId}/data` | Delete all user's spreads and interpretations | Service-to-service only | +| GET | `/internal/spreads/{spreadId}/owner` | Get spread author ID | Service-to-service only | **Note:** Internal endpoints are not exposed through the gateway and are only accessible via Eureka service discovery. @@ -195,7 +343,7 @@ Base path: `/api/v0.0.1` **Centralized Swagger UI** is available at the API Gateway: - **URL:** `http://localhost:8080/swagger-ui.html` - **Features:** - - Dropdown selector to switch between services (User Service, Tarot Service, Divination Service) + - Dropdown selector to switch between services (User Service, Tarot Service, Divination Service, Notification Service, Files Service) - "Try it out" functionality for testing endpoints - Full OpenAPI 3.1 specification for each service @@ -236,6 +384,8 @@ docker compose up -d && ./gradlew :e2e-tests:test - `EUREKA_URL` - Eureka Server URL (required) - `JWT_SECRET` - JWT signing key (required for user-service, gateway-service) - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - Database connection +- `KAFKA_BOOTSTRAP_SERVERS` - Kafka broker addresses (required for user-service, divination-service, notification-service, files-service) +- `MINIO_HOST`, `MINIO_PORT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY` - MinIO connection (required for files-service) ## Testing @@ -252,18 +402,29 @@ docker compose up -d && ./gradlew :e2e-tests:test ## Key Implementation Notes -### Reactive Programming (divination-service) +### Reactive Programming (All Services) -**IMPORTANT:** The `tarot-service` uses blocking JPA - this is intentional, not a bug. The `divination-service` is reactive (WebFlux + R2DBC) while other services use traditional Spring MVC + JPA. +All backend services (user-service, tarot-service, divination-service) use Spring WebFlux + R2DBC for fully reactive, non-blocking operation. **Blocking Feign in Reactive Context:** -Feign clients are blocking. In divination-service, wrap calls with `Mono.fromCallable().subscribeOn(Schedulers.boundedElastic())` to avoid blocking the reactive event loop. +Feign clients are blocking. Wrap all Feign calls with `Mono.fromCallable().subscribeOn(Schedulers.boundedElastic())` to avoid blocking the reactive event loop. -**R2DBC Entities:** -- Use `@Table` instead of `@Entity` +**R2DBC Entities (infrastructure/persistence/entity/):** +- Use `@Table` annotation (not JPA `@Entity`) - Store foreign key IDs directly (no `@ManyToOne`) - ID is nullable for database generation - Always use returned entity from `save()` +- Separate from domain models - use `EntityMapper` to convert + +**Reactive Security (user-service):** +- Uses `@EnableWebFluxSecurity` and `@EnableReactiveMethodSecurity` +- Authentication filter implements `WebFilter` (not servlet filter) +- Security context via `ReactiveSecurityContextHolder` + +**Testing:** +- Use `WebTestClient` for controller tests (not MockMvc) +- Use `StepVerifier` for service tests +- Reactive cleanup with `repository.deleteAll().block()` in `@BeforeEach` ### Service Discovery (Eureka) @@ -273,7 +434,7 @@ Feign clients use pattern: `@FeignClient(name = "service-name", url = "${service ### Flyway with Shared Database -Each service uses separate history table: `flyway_schema_history_user`, `flyway_schema_history_tarot`, `flyway_schema_history_divination`. +Each service uses separate history table: `flyway_schema_history_user`, `flyway_schema_history_tarot`, `flyway_schema_history_divination`, `flyway_schema_history_notification`, `flyway_schema_history_files`. ### Configuration Repository (highload-config) @@ -297,3 +458,93 @@ cd .. # back to main project git add highload-config git commit -m "Update highload-config" ``` + +## Event-Driven Architecture + +### Kafka Infrastructure + +3-broker Kafka cluster running in KRaft mode (no Zookeeper): +- **Brokers:** kafka-1 (9092), kafka-2 (9093), kafka-3 (9094) +- **Internal port:** 29092 (used by services within Docker network) +- **Replication factor:** 3, min.insync.replicas: 2 +- **Monitoring:** kafka-ui at http://localhost:8090 + +### Topics + +| Topic | Publisher | Events | +|-------|-----------|--------| +| `users-events` | user-service | CREATED, UPDATED, DELETED | +| `spreads-events` | divination-service | CREATED, DELETED | +| `interpretations-events` | divination-service | CREATED, UPDATED, DELETED | +| `files-events` | files-service | COMPLETED, DELETED | + +### Event Consumers + +| Topic | Consumer | Action | +|-------|----------|--------| +| `users-events` | divination-service | Deletes all user's spreads and interpretations when user DELETED | +| `interpretations-events` | notification-service | Creates notification for spread owner when interpretation CREATED (if not self-action) | + +### Event Message Format + +Events use Kafka headers for metadata and JSON body for payload: + +**Structure:** +- **Key:** Entity ID (UUID string) - enables partitioning by entity +- **Headers:** + - `eventType`: `CREATED` | `UPDATED` | `DELETED` + - `timestamp`: ISO-8601 instant +- **Value:** Full entity state (JSON) + +**Example (users-events):** +``` +Key: "550e8400-e29b-41d4-a716-446655440000" +Headers: { eventType: "CREATED", timestamp: "2026-01-20T20:00:00Z" } +Value: {"id":"550e8400-...","username":"john_doe","role":"USER","createdAt":"2026-01-20T20:00:00Z"} +``` + +### Event DTOs + +Located in `shared-dto` module (`com.github.butvinmitmo.shared.dto.events`): +- `EventType` - Enum: CREATED, UPDATED, DELETED +- `UserEventData` - id, username, role, createdAt +- `SpreadEventData` - id, question, layoutTypeId, authorId, createdAt +- `InterpretationEventData` - id, text, authorId, spreadId, createdAt +- `FileEventData` - uploadId, filePath, originalFileName, contentType, fileSize, userId, completedAt + +### Publishing Pattern + +**Post-commit, at-least-once semantics:** +1. Application service saves entity to database +2. After successful save, publishes event to Kafka +3. If Kafka publish fails, database transaction already committed (event may be lost) + +**Implementation:** +- Publisher interfaces in `application/interfaces/publisher/` +- Kafka implementations in `infrastructure/messaging/` +- Blocking Kafka calls wrapped with `Mono.fromCallable().subscribeOn(Schedulers.boundedElastic())` + +**Testing:** +- Unit tests mock publisher interfaces +- Integration tests use `@MockBean` for publishers to avoid Kafka dependency + +### Notification Flow + +``` +interpretations-events (CREATED) + ↓ +InterpretationEventConsumer + ↓ +SpreadProvider.getSpreadOwnerId(spreadId) + ↓ +If authorId != spreadOwnerId: + ↓ +NotificationService.create() + ↓ +Save to DB + WebSocketSessionManager.sendToUser() +``` + +**WebSocket Authentication:** +- Gateway validates JWT from query param (`?token=JWT`) for WebSocket connections +- X-User-Id header is set by gateway after JWT validation +- NotificationWebSocketHandler extracts user ID from header for session management diff --git a/PROGRESS.md b/PROGRESS.md deleted file mode 100644 index 7ae9c14..0000000 --- a/PROGRESS.md +++ /dev/null @@ -1,27 +0,0 @@ -# Centralized Swagger UI Implementation Progress - -## Task: Implement single Swagger UI on API Gateway - -### Phases - -- [x] **Phase 1:** Add springdoc dependency to gateway-service - - Added `springdoc-openapi-starter-webflux-ui:2.8.4` to gateway-service - - Build verified successfully - -- [x] **Phase 2:** Configure gateway Swagger UI - - Added springdoc configuration to gateway-service.yml with URL groups for all services - - Added OpenAPI proxy routes to rewrite /v3/api-docs/{service} to backend /api-docs - - Updated public paths to allow Swagger UI access without JWT authentication - - Verified: http://localhost:8080/swagger-ui.html shows dropdown with all 3 services - - All API endpoints properly documented and accessible - -- [x] **Phase 3:** Remove per-service Swagger UI - - Changed user-service from webmvc-ui to webmvc-api - - Changed tarot-service from webflux-ui to webflux-api - - Changed divination-service from webflux-ui to webflux-api - - Build successful, services start correctly - - Per-service Swagger UI disabled, API docs still available for gateway proxying - -- [x] **Phase 4:** Update CLAUDE.md Documentation - - Added "API Documentation (Swagger UI)" section with architecture and configuration details - - Documented centralized URL, features, and configuration locations diff --git a/README.md b/README.md index 951e9b5..1cf672b 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,20 @@ A Kotlin/Spring Boot microservices application for Tarot card readings and inter ## Architecture -The application consists of 6 microservices with centralized configuration, service discovery, and API gateway: +The application consists of 6 microservices with centralized configuration, service discovery, API gateway, and event streaming: -| Service | Port | Description | Swagger UI | -| ---------------------- | ---- | ------------------------------------- | ------------------------------------------------------------------------------------------ | -| **config-server** | 8888 | Centralized configuration management | - | -| **eureka-server** | 8761 | Service discovery (Netflix Eureka) | [http://localhost:8761](http://localhost:8761) | -| **gateway-service** | 8080 | API Gateway (routing, resilience) | - | -| **user-service** | 8081 | User management | [http://localhost:8081/swagger-ui/index.html](http://localhost:8081/swagger-ui/index.html) | -| **tarot-service** | 8082 | Cards & LayoutTypes catalog | [http://localhost:8082/swagger-ui/index.html](http://localhost:8082/swagger-ui/index.html) | -| **divination-service** | 8083 | Spreads & Interpretations | [http://localhost:8083/swagger-ui/index.html](http://localhost:8083/swagger-ui/index.html) | +| Service | Port | Description | +| ---------------------- | --------- | ------------------------------------ | +| **config-server** | 8888 | Centralized configuration management | +| **eureka-server** | 8761 | Service discovery (Netflix Eureka) | +| **gateway-service** | 8080 | API Gateway (routing, resilience) | +| **user-service** | 8081 | User management & authentication | +| **tarot-service** | 8082 | Cards & LayoutTypes catalog | +| **divination-service** | 8083 | Spreads & Interpretations | +| **kafka-1/2/3** | 9092-9094 | Event streaming (3-broker cluster) | +| **kafka-ui** | 8090 | Kafka cluster monitoring UI | + +**API Documentation:** Centralized Swagger UI at [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html) **API Gateway:** `gateway-service` provides a unified entry point for external clients. All API requests route through the gateway (port 8080) to backend services via Eureka discovery. The gateway includes circuit breaker protection and centralized monitoring. @@ -25,6 +29,41 @@ The application consists of 6 microservices with centralized configuration, serv **Database:** All services share a single PostgreSQL database with separate Flyway migration history tables. +**Event Streaming:** Apache Kafka cluster (3 brokers, KRaft mode) enables asynchronous event-driven communication. Services publish domain events after successful database operations. + +## Event-Driven Architecture + +### Kafka Infrastructure + +- **Cluster:** 3-broker setup running in KRaft mode (no Zookeeper dependency) +- **Brokers:** kafka-1 (9092), kafka-2 (9093), kafka-3 (9094) +- **Replication:** Factor 3, min.insync.replicas: 2 +- **Monitoring:** Kafka UI at [http://localhost:8090](http://localhost:8090) + +### Topics & Events + +| Topic | Publisher | Events | +| ------------------------ | ------------------ | ------------------------- | +| `users-events` | user-service | CREATED, UPDATED, DELETED | +| `spreads-events` | divination-service | CREATED, DELETED | +| `interpretations-events` | divination-service | CREATED, UPDATED, DELETED | + +### Event Message Format + +Events use Kafka headers for metadata and JSON body for payload: + +- **Key:** Entity ID (UUID) - enables partitioning by entity +- **Headers:** `eventType` (CREATED/UPDATED/DELETED), `timestamp` (ISO-8601) +- **Value:** Full entity state as JSON + +Example (`users-events`): + +``` +Key: "550e8400-e29b-41d4-a716-446655440000" +Headers: { eventType: "CREATED", timestamp: "2026-01-20T20:00:00Z" } +Value: {"id":"550e8400-...","username":"john_doe","role":"USER","createdAt":"2026-01-20T20:00:00Z"} +``` + ## Quick Start ```bash @@ -34,26 +73,17 @@ git submodule update --init # Start all services docker compose up -d -# Verify services are running -curl http://localhost:8888/actuator/health # Config Server -curl http://localhost:8761/actuator/health # Eureka Server -curl http://localhost:8080/actuator/health # Gateway Service -curl http://localhost:8081/actuator/health # User Service -curl http://localhost:8082/actuator/health # Tarot Service -curl http://localhost:8083/actuator/health # Divination Service - # Check Eureka dashboard for registered services open http://localhost:8761 -# Access APIs through the gateway (recommended for external clients) -curl http://localhost:8080/api/v0.0.1/users -curl http://localhost:8080/api/v0.0.1/cards -curl http://localhost:8080/api/v0.0.1/spreads +# Access API documentation +open http://localhost:8080/swagger-ui.html -# Run E2E tests (requires services to be running) -docker compose up -d +# Monitor Kafka cluster +open http://localhost:8090 + +# Run E2E tests ./gradlew :e2e-tests:test -docker compose down ``` ## API Overview @@ -62,35 +92,38 @@ docker compose down ### user-service (port 8081) -| Method | Endpoint | Description | -| ------ | ------------------------ | ----------- | -| POST | `/api/v0.0.1/users` | Create user | -| GET | `/api/v0.0.1/users` | List users | -| GET | `/api/v0.0.1/users/{id}` | Get user | -| PUT | `/api/v0.0.1/users/{id}` | Update user | -| DELETE | `/api/v0.0.1/users/{id}` | Delete user | +| Method | Endpoint | Description | +| ------ | ------------------------ | ------------------ | +| POST | `/api/v0.0.1/auth/login` | Login, returns JWT | +| POST | `/api/v0.0.1/users` | Create user | +| GET | `/api/v0.0.1/users` | List users | +| GET | `/api/v0.0.1/users/{id}` | Get user | +| PUT | `/api/v0.0.1/users/{id}` | Update user | +| DELETE | `/api/v0.0.1/users/{id}` | Delete user | ### tarot-service (port 8082) -| Method | Endpoint | Description | -| ------ | -------------------------- | --------------------------- | -| GET | `/api/v0.0.1/cards` | List tarot cards (78 cards) | -| GET | `/api/v0.0.1/layout-types` | List spread layouts | +| Method | Endpoint | Description | +| ------ | ------------------------------- | ------------------- | +| GET | `/api/v0.0.1/cards` | List tarot cards | +| GET | `/api/v0.0.1/cards/random` | Get random cards | +| GET | `/api/v0.0.1/layout-types` | List spread layouts | +| GET | `/api/v0.0.1/layout-types/{id}` | Get layout type | ### divination-service (port 8083) -| Method | Endpoint | Description | -| ------ | ------------------------------------------------------ | -------------------------------- | -| POST | `/api/v0.0.1/spreads` | Create spread | -| GET | `/api/v0.0.1/spreads?page=N&size=M` | List spreads (paginated) | -| GET | `/api/v0.0.1/spreads/scroll?after=ID&size=N` | Scroll spreads (cursor-based) | -| GET | `/api/v0.0.1/spreads/{id}` | Get spread with cards & interps | -| DELETE | `/api/v0.0.1/spreads/{id}` | Delete spread (author only) | -| GET | `/api/v0.0.1/spreads/{id}/interpretations` | List interpretations for spread | -| GET | `/api/v0.0.1/spreads/{id}/interpretations/{interpId}` | Get interpretation | -| POST | `/api/v0.0.1/spreads/{id}/interpretations` | Add interpretation | -| PUT | `/api/v0.0.1/spreads/{id}/interpretations/{interpId}` | Update interpretation (author) | -| DELETE | `/api/v0.0.1/spreads/{id}/interpretations/{interpId}` | Delete interpretation (author) | +| Method | Endpoint | Description | +| ------ | ----------------------------------------------------- | ------------------------------- | +| POST | `/api/v0.0.1/spreads` | Create spread | +| GET | `/api/v0.0.1/spreads?page=N&size=M` | List spreads (paginated) | +| GET | `/api/v0.0.1/spreads/scroll?after=ID&size=N` | Scroll spreads (cursor-based) | +| GET | `/api/v0.0.1/spreads/{id}` | Get spread with cards & interps | +| DELETE | `/api/v0.0.1/spreads/{id}` | Delete spread (author only) | +| GET | `/api/v0.0.1/spreads/{id}/interpretations` | List interpretations for spread | +| GET | `/api/v0.0.1/spreads/{id}/interpretations/{interpId}` | Get interpretation | +| POST | `/api/v0.0.1/spreads/{id}/interpretations` | Add interpretation | +| PUT | `/api/v0.0.1/spreads/{id}/interpretations/{interpId}` | Update interpretation (author) | +| DELETE | `/api/v0.0.1/spreads/{id}/interpretations/{interpId}` | Delete interpretation (author) | ## Configuration Management @@ -118,12 +151,11 @@ docker compose restart config-server ``` **Configuration files:** -- `application.yml` - Shared configuration (database, JPA, Flyway, SpringDoc) + +- `application.yml` - Shared configuration (database, R2DBC, Flyway, SpringDoc) - `eureka-server.yml` - Eureka server settings -- `gateway-service.yml` - Gateway routes and Resilience4j settings -- `user-service.yml` - User service specific -- `tarot-service.yml` - Tarot service specific -- `divination-service.yml` - Divination service + Resilience4j settings +- `gateway-service.yml` - Gateway routes, Resilience4j, Swagger UI +- `user-service.yml`, `tarot-service.yml`, `divination-service.yml` - Service-specific settings ## Development @@ -149,129 +181,47 @@ docker compose restart config-server # Clean build artifacts ./gradlew clean -``` - -Coverage reports are located at: -- `user-service/build/reports/jacoco/test/html/index.html` -- `tarot-service/build/reports/jacoco/test/html/index.html` -- `divination-service/build/reports/jacoco/test/html/index.html` - -### Code Quality - -```bash -# Run ktlint checks -./gradlew ktlintCheck # Auto-format code ./gradlew ktlintFormat - -# Pre-commit hooks (automatic formatting before commit) -.venv/bin/pre-commit install # Install hooks (one-time setup) -.venv/bin/pre-commit run --all-files # Manually run on all files -``` - -**Pre-commit hooks:** The project uses [pre-commit](https://pre-commit.com/) to automatically run ktlint format before each commit. - -First-time setup: -```bash -python -m venv .venv -.venv/bin/pip install pre-commit -.venv/bin/pre-commit install ``` ### Running with Docker ```bash -# Start all services (database + microservices) -docker compose up -d - -# Rebuild and restart -docker compose up -d --build - -# View logs -docker compose logs -f - -# View logs for specific service -docker compose logs -f config-server -docker compose logs -f eureka-server -docker compose logs -f gateway-service -docker compose logs -f user-service -docker compose logs -f tarot-service -docker compose logs -f divination-service - -# Stop all services -docker compose down +docker compose up -d # Start all services +docker compose up -d --build # Rebuild and restart +docker compose logs -f # View logs +docker compose down # Stop all services ``` -**Service startup order** (enforced by docker-compose health checks): -1. `config-server` - Must be healthy first -2. `eureka-server` - Fetches config, then starts -3. `gateway-service` - Registers with Eureka for routing -4. `postgres` - Database -5. `user-service`, `tarot-service` - Register with Eureka -6. `divination-service` - Discovers other services via Eureka - -**Environment variables:** -- `CONFIG_SERVER_URL` - Config Server URL (default: http://localhost:8888) -- `EUREKA_URL` - Eureka Server URL (required, no default) -- Database env vars: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` +Service startup order is enforced by docker-compose health checks. ### Running Locally (Development) ```bash -# Start infrastructure services (config-server, eureka-server, gateway-service, postgres) -docker compose up -d config-server eureka-server gateway-service postgres +# Start infrastructure (includes Kafka cluster) +docker compose up -d config-server eureka-server gateway-service postgres kafka-1 kafka-2 kafka-3 kafka-ui -# Wait for services to be healthy, then run individual services (in separate terminals) +# Run services locally ./gradlew :user-service:bootRun ./gradlew :tarot-service:bootRun ./gradlew :divination-service:bootRun - -# Or run config-server, eureka-server, and gateway-service locally too -./gradlew :config-server:bootRun # Terminal 1 -./gradlew :eureka-server:bootRun # Terminal 2 -./gradlew :gateway-service:bootRun # Terminal 3 -# ... then run other services ``` ### E2E Testing -E2E tests run against a pre-running application. Services must be started before running tests. +E2E tests run against a pre-running application (63 tests). -**Local Development:** ```bash -# 1. Start all services (required) +# Start services and run tests docker compose up -d - -# 2. Run E2E tests ./gradlew :e2e-tests:test -# 3. Stop services when done -docker compose down -``` - -**Custom Gateway URL:** -```bash -# Via environment variable +# Custom gateway URL GATEWAY_URL=http://localhost:8080 ./gradlew :e2e-tests:test - -# Via system property -./gradlew :e2e-tests:test -DGATEWAY_URL=http://localhost:8080 ``` -**Test coverage (31 tests):** -- All tests route through gateway-service (simulating external client access) -- User CRUD, duplicate username (409), not found (404), authentication -- Cards pagination (78 total cards), layout types, random cards -- Spreads with inter-service Feign calls, interpretations CRUD -- Delete operations, authorization verification (403) - -**Health Check:** -- Tests verify gateway health before execution (GET /actuator/health) -- 3 retry attempts with 1-second delays -- Fail-fast with clear error message if services aren't running -- Error message includes `docker compose up -d` command - ## Project Structure ``` @@ -293,17 +243,10 @@ highload/ ## Technology Stack -- **Language:** Kotlin 2.2.10 -- **Framework:** Spring Boot 3.5.6 -- **Build:** Gradle (Kotlin DSL) -- **JVM:** Java 21 -- **Database:** PostgreSQL 15 -- **Migrations:** Flyway (per-service) -- **Service Discovery:** Netflix Eureka (Spring Cloud Netflix) -- **API Gateway:** Spring Cloud Gateway with circuit breaker -- **Configuration:** Spring Cloud Config Server (Git backend) -- **Inter-service:** Spring Cloud OpenFeign with Eureka discovery +- **Language:** Kotlin 2.2.10, Java 21 +- **Framework:** Spring Boot 3.5.6, Spring Cloud 2025.0.0 +- **Database:** PostgreSQL 15, Spring Data R2DBC, Flyway +- **Event Streaming:** Apache Kafka 7.5 (3 brokers, KRaft mode) +- **Infrastructure:** Netflix Eureka, Spring Cloud Gateway, Spring Cloud Config - **Resilience:** Resilience4j (circuit breaker, retry, time limiter) -- **Testing:** Spring Boot Test for E2E tests (pre-running services) -- **API Docs:** SpringDoc OpenAPI (Swagger) -- **Code Style:** ktlint 1.5.0 +- **API Docs:** SpringDoc OpenAPI diff --git a/config-server/src/main/resources/application.yml b/config-server/src/main/resources/application.yml index b105811..49d298f 100644 --- a/config-server/src/main/resources/application.yml +++ b/config-server/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: server: git: uri: https://github.com/butvinm-itmo/highload-config.git - default-label: revert-to-lab3 + default-label: main clone-on-start: true timeout: 10 diff --git a/divination-service/build.gradle.kts b/divination-service/build.gradle.kts index 604b57b..5e5553f 100644 --- a/divination-service/build.gradle.kts +++ b/divination-service/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation("org.springframework.cloud:spring-cloud-starter-config") implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") + implementation("org.springframework.kafka:spring-kafka") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-database-postgresql") diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/InternalController.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/InternalController.kt new file mode 100644 index 0000000..72d6950 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/InternalController.kt @@ -0,0 +1,37 @@ +package com.github.butvinmitmo.divinationservice.api.controller + +import com.github.butvinmitmo.divinationservice.application.service.DivinationService +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.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import java.util.UUID + +/** + * Internal API controller for service-to-service communication. + * These endpoints are not exposed through the gateway and are only accessible + * via Eureka service discovery for internal operations. + */ +@RestController +@RequestMapping("/internal") +class InternalController( + private val divinationService: DivinationService, +) { + /** + * Gets the author (owner) ID of a spread. + * Called by notification-service to determine who should receive notifications. + * + * @param spreadId The ID of the spread + * @return 200 OK with author UUID, or 404 if spread not found + */ + @GetMapping("/spreads/{spreadId}/owner") + fun getSpreadOwner( + @PathVariable spreadId: UUID, + ): Mono> = + divinationService + .getSpreadAuthorId(spreadId) + .map { ResponseEntity.ok(it) } + .defaultIfEmpty(ResponseEntity.notFound().build()) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/InterpretationController.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/InterpretationController.kt similarity index 93% rename from divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/InterpretationController.kt rename to divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/InterpretationController.kt index 9b7f0af..c582837 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/InterpretationController.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/InterpretationController.kt @@ -1,6 +1,6 @@ -package com.github.butvinmitmo.divinationservice.controller +package com.github.butvinmitmo.divinationservice.api.controller -import com.github.butvinmitmo.divinationservice.service.DivinationService +import com.github.butvinmitmo.divinationservice.application.service.DivinationService import com.github.butvinmitmo.shared.dto.CreateInterpretationRequest import com.github.butvinmitmo.shared.dto.CreateInterpretationResponse import com.github.butvinmitmo.shared.dto.InterpretationDto @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono import java.util.UUID @RestController @@ -83,7 +84,7 @@ class InterpretationController( @Min(1) @Max(50) size: Int, - ): reactor.core.publisher.Mono>> = + ): Mono>> = divinationService .getInterpretations(spreadId, page, size) .map { response -> @@ -123,7 +124,7 @@ class InterpretationController( @Parameter(description = "Interpretation ID", required = true) @PathVariable id: UUID, - ): reactor.core.publisher.Mono> = + ): Mono> = divinationService .getInterpretation(spreadId, id) .map { interpretation -> ResponseEntity.ok(interpretation) } @@ -199,10 +200,12 @@ class InterpretationController( @PathVariable spreadId: UUID, @Valid @RequestBody request: CreateInterpretationRequest, - ): reactor.core.publisher.Mono> = + ): Mono> = divinationService - .addInterpretation(spreadId, request) - .map { response -> ResponseEntity.status(HttpStatus.CREATED).body(response) } + .addInterpretation(spreadId, request.text, request.uploadId) + .map { result -> + ResponseEntity.status(HttpStatus.CREATED).body(CreateInterpretationResponse(id = result.id)) + } @PutMapping("/{id}") @Operation( @@ -265,9 +268,9 @@ class InterpretationController( @PathVariable id: UUID, @Valid @RequestBody request: UpdateInterpretationRequest, - ): reactor.core.publisher.Mono> = + ): Mono> = divinationService - .updateInterpretation(spreadId, id, request) + .updateInterpretation(spreadId, id, request.text) .map { updatedInterpretation -> ResponseEntity.ok(updatedInterpretation) } @DeleteMapping("/{id}") @@ -314,11 +317,8 @@ class InterpretationController( @Parameter(description = "Interpretation ID to delete", required = true) @PathVariable id: UUID, - ): reactor.core.publisher.Mono> = + ): Mono> = divinationService .deleteInterpretation(spreadId, id) - .then( - reactor.core.publisher.Mono - .just(ResponseEntity.noContent().build()), - ) + .then(Mono.just(ResponseEntity.noContent().build())) } diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/SpreadController.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/SpreadController.kt similarity index 96% rename from divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/SpreadController.kt rename to divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/SpreadController.kt index 278c927..9a7f30b 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/SpreadController.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/api/controller/SpreadController.kt @@ -1,6 +1,6 @@ -package com.github.butvinmitmo.divinationservice.controller +package com.github.butvinmitmo.divinationservice.api.controller -import com.github.butvinmitmo.divinationservice.service.DivinationService +import com.github.butvinmitmo.divinationservice.application.service.DivinationService import com.github.butvinmitmo.shared.dto.CreateSpreadRequest import com.github.butvinmitmo.shared.dto.CreateSpreadResponse import com.github.butvinmitmo.shared.dto.SpreadDto @@ -88,8 +88,8 @@ class SpreadController( @Valid @RequestBody request: CreateSpreadRequest, ): Mono> = divinationService - .createSpread(request) - .map { response -> ResponseEntity.status(HttpStatus.CREATED).body(response) } + .createSpread(request.question, request.layoutTypeId) + .map { result -> ResponseEntity.status(HttpStatus.CREATED).body(CreateSpreadResponse(id = result.id)) } @GetMapping @Operation( diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/CardProvider.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/CardProvider.kt new file mode 100644 index 0000000..f1ceb90 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/CardProvider.kt @@ -0,0 +1,24 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.provider + +import com.github.butvinmitmo.shared.dto.CardDto +import com.github.butvinmitmo.shared.dto.LayoutTypeDto +import reactor.core.publisher.Mono +import java.util.UUID + +interface CardProvider { + fun getLayoutTypeById( + requesterId: UUID, + requesterRole: String, + layoutTypeId: UUID, + ): Mono + + fun getRandomCards( + requesterId: UUID, + requesterRole: String, + count: Int, + ): Mono> + + fun getAllCards(): Mono> + + fun getSystemLayoutType(layoutTypeId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/CurrentUserProvider.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/CurrentUserProvider.kt new file mode 100644 index 0000000..d0c9e60 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/CurrentUserProvider.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.provider + +import reactor.core.publisher.Mono +import java.util.UUID + +interface CurrentUserProvider { + fun getCurrentUserId(): Mono + + fun getCurrentRole(): Mono + + fun canModify(authorId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/FileProvider.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/FileProvider.kt new file mode 100644 index 0000000..a3064db --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/FileProvider.kt @@ -0,0 +1,39 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.provider + +import com.github.butvinmitmo.shared.dto.FileUploadMetadataDto +import reactor.core.publisher.Mono +import java.util.UUID + +/** + * Provider interface for file operations via files-service. + * Used to verify uploads, get metadata, and generate download URLs. + */ +interface FileProvider { + /** + * Verifies that an upload exists, belongs to the user, and marks it as completed. + * + * @param uploadId The ID of the upload to verify + * @param userId The ID of the user who should own the upload + * @return File metadata if verification succeeds + */ + fun verifyAndCompleteUpload( + uploadId: UUID, + userId: UUID, + ): Mono + + /** + * Gets metadata for a completed file upload. + * + * @param uploadId The ID of the upload + * @return File metadata + */ + fun getUploadMetadata(uploadId: UUID): Mono + + /** + * Gets a presigned download URL for a file. + * + * @param uploadId The ID of the upload + * @return Presigned download URL + */ + fun getDownloadUrl(uploadId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/UserProvider.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/UserProvider.kt new file mode 100644 index 0000000..fb6abca --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/provider/UserProvider.kt @@ -0,0 +1,15 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.provider + +import com.github.butvinmitmo.shared.dto.UserDto +import reactor.core.publisher.Mono +import java.util.UUID + +interface UserProvider { + fun getUserById( + requesterId: UUID, + requesterRole: String, + userId: UUID, + ): Mono + + fun getSystemUser(userId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/publisher/InterpretationEventPublisher.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/publisher/InterpretationEventPublisher.kt new file mode 100644 index 0000000..5ca4c95 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/publisher/InterpretationEventPublisher.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.publisher + +import com.github.butvinmitmo.divinationservice.domain.model.Interpretation +import reactor.core.publisher.Mono + +interface InterpretationEventPublisher { + fun publishCreated(interpretation: Interpretation): Mono + + fun publishUpdated(interpretation: Interpretation): Mono + + fun publishDeleted(interpretation: Interpretation): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/publisher/SpreadEventPublisher.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/publisher/SpreadEventPublisher.kt new file mode 100644 index 0000000..20b64dc --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/publisher/SpreadEventPublisher.kt @@ -0,0 +1,10 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.publisher + +import com.github.butvinmitmo.divinationservice.domain.model.Spread +import reactor.core.publisher.Mono + +interface SpreadEventPublisher { + fun publishCreated(spread: Spread): Mono + + fun publishDeleted(spread: Spread): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/InterpretationAttachmentRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/InterpretationAttachmentRepository.kt new file mode 100644 index 0000000..9bf67d8 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/InterpretationAttachmentRepository.kt @@ -0,0 +1,18 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.repository + +import com.github.butvinmitmo.divinationservice.domain.model.InterpretationAttachment +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface InterpretationAttachmentRepository { + fun save(attachment: InterpretationAttachment): Mono + + fun findByInterpretationId(interpretationId: UUID): Mono + + fun findByInterpretationIds(interpretationIds: List): Flux + + fun deleteByInterpretationId(interpretationId: UUID): Mono + + fun existsByInterpretationId(interpretationId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/InterpretationRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/InterpretationRepository.kt new file mode 100644 index 0000000..fbbd224 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/InterpretationRepository.kt @@ -0,0 +1,31 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.repository + +import com.github.butvinmitmo.divinationservice.domain.model.Interpretation +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface InterpretationRepository { + fun save(interpretation: Interpretation): Mono + + fun findById(id: UUID): Mono + + fun findBySpreadIdOrderByCreatedAtDesc( + spreadId: UUID, + offset: Long, + limit: Int, + ): Flux + + fun existsByAuthorAndSpread( + authorId: UUID, + spreadId: UUID, + ): Mono + + fun countBySpreadId(spreadId: UUID): Mono + + fun countBySpreadIds(spreadIds: List): Flux> + + fun deleteById(id: UUID): Mono + + fun deleteByAuthorId(authorId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/SpreadCardRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/SpreadCardRepository.kt new file mode 100644 index 0000000..5235497 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/SpreadCardRepository.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.repository + +import com.github.butvinmitmo.divinationservice.domain.model.SpreadCard +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface SpreadCardRepository { + fun save(spreadCard: SpreadCard): Mono + + fun findBySpreadId(spreadId: UUID): Flux +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/SpreadRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/SpreadRepository.kt new file mode 100644 index 0000000..81c06a5 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/interfaces/repository/SpreadRepository.kt @@ -0,0 +1,30 @@ +package com.github.butvinmitmo.divinationservice.application.interfaces.repository + +import com.github.butvinmitmo.divinationservice.domain.model.Spread +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface SpreadRepository { + fun save(spread: Spread): Mono + + fun findById(id: UUID): Mono + + fun findAllOrderByCreatedAtDesc( + offset: Long, + limit: Int, + ): Flux + + fun findSpreadsAfterCursor( + spreadId: UUID, + limit: Int, + ): Flux + + fun findLatestSpreads(limit: Int): Flux + + fun count(): Mono + + fun deleteById(id: UUID): Mono + + fun deleteByAuthorId(authorId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/service/DivinationService.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/service/DivinationService.kt new file mode 100644 index 0000000..e01ec07 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/application/service/DivinationService.kt @@ -0,0 +1,566 @@ +package com.github.butvinmitmo.divinationservice.application.service + +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.CardProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.CurrentUserProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.FileProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.UserProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.InterpretationEventPublisher +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.SpreadEventPublisher +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.InterpretationAttachmentRepository +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.InterpretationRepository +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.SpreadCardRepository +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.SpreadRepository +import com.github.butvinmitmo.divinationservice.domain.model.Interpretation +import com.github.butvinmitmo.divinationservice.domain.model.InterpretationAttachment +import com.github.butvinmitmo.divinationservice.domain.model.Spread +import com.github.butvinmitmo.divinationservice.domain.model.SpreadCard +import com.github.butvinmitmo.divinationservice.exception.ConflictException +import com.github.butvinmitmo.divinationservice.exception.ForbiddenException +import com.github.butvinmitmo.divinationservice.exception.NotFoundException +import com.github.butvinmitmo.shared.dto.CardDto +import com.github.butvinmitmo.shared.dto.InterpretationAttachmentDto +import com.github.butvinmitmo.shared.dto.InterpretationDto +import com.github.butvinmitmo.shared.dto.SpreadCardDto +import com.github.butvinmitmo.shared.dto.SpreadDto +import com.github.butvinmitmo.shared.dto.SpreadSummaryDto +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Instant +import java.util.UUID +import kotlin.random.Random + +data class PageResult( + val content: List, + val totalElements: Long, + val totalPages: Int, + val page: Int, + val size: Int, + val isFirst: Boolean, + val isLast: Boolean, +) + +data class ScrollResult( + val items: List, + val nextCursor: UUID?, +) + +data class CreateSpreadResult( + val id: UUID, +) + +data class CreateInterpretationResult( + val id: UUID, +) + +@Service +class DivinationService( + private val spreadRepository: SpreadRepository, + private val spreadCardRepository: SpreadCardRepository, + private val interpretationRepository: InterpretationRepository, + private val interpretationAttachmentRepository: InterpretationAttachmentRepository, + private val userProvider: UserProvider, + private val cardProvider: CardProvider, + private val currentUserProvider: CurrentUserProvider, + private val fileProvider: FileProvider, + private val spreadEventPublisher: SpreadEventPublisher, + private val interpretationEventPublisher: InterpretationEventPublisher, +) { + @Transactional + fun createSpread( + question: String?, + layoutTypeId: UUID, + ): Mono = + currentUserProvider.getCurrentUserId().flatMap { authorId -> + currentUserProvider.getCurrentRole().flatMap { role -> + // Validate user exists + userProvider + .getUserById(authorId, role, authorId) + .flatMap { + // Validate layout type exists and get cards count + cardProvider.getLayoutTypeById(authorId, role, layoutTypeId) + }.flatMap { layoutType -> + val spread = + Spread( + id = null, + question = question, + authorId = authorId, + layoutTypeId = layoutTypeId, + createdAt = Instant.now(), + ) + spreadRepository + .save(spread) + .flatMap { savedSpread -> + // Get random cards + cardProvider + .getRandomCards(authorId, role, layoutType.cardsCount) + .flatMapMany { cards -> + // Save all spread cards + Flux.fromIterable( + cards.mapIndexed { index, card -> + SpreadCard( + id = null, + spreadId = savedSpread.id!!, + cardId = card.id, + positionInSpread = index + 1, + isReversed = Random.nextBoolean(), + ) + }, + ) + }.flatMap { spreadCard -> + spreadCardRepository.save(spreadCard) + }.then(spreadEventPublisher.publishCreated(savedSpread)) + .then(Mono.just(CreateSpreadResult(id = savedSpread.id!!))) + } + } + } + } + + fun getSpreads( + page: Int, + size: Int, + ): Mono> { + val offset = page.toLong() * size + return spreadRepository + .count() + .flatMap { totalElements -> + spreadRepository + .findAllOrderByCreatedAtDesc(offset, size) + .collectList() + .flatMap { spreads -> + val spreadIds = spreads.map { it.id!! } + getInterpretationCounts(spreadIds) + .flatMap { interpretationCounts -> + val totalPages = ((totalElements + size - 1) / size).toInt() + buildSpreadSummaries(spreads, interpretationCounts) + .map { summaries -> + PageResult( + content = summaries, + page = page, + size = size, + totalElements = totalElements, + totalPages = totalPages, + isFirst = page == 0, + isLast = page >= totalPages - 1, + ) + } + } + } + } + } + + @Transactional(readOnly = true) + fun getSpreadsByScroll( + after: UUID?, + size: Int, + ): Mono> { + val spreadsFlux = + if (after != null) { + spreadRepository.findSpreadsAfterCursor(after, size + 1) + } else { + spreadRepository.findLatestSpreads(size + 1) + } + + return spreadsFlux + .collectList() + .flatMap { spreads -> + val hasMore = spreads.size > size + val itemsToReturn = if (hasMore) spreads.take(size) else spreads + val nextCursor = if (hasMore) itemsToReturn.last().id else null + + val spreadIds = itemsToReturn.map { it.id!! } + getInterpretationCounts(spreadIds) + .flatMap { interpretationCounts -> + buildSpreadSummaries(itemsToReturn, interpretationCounts) + .map { summaries -> + ScrollResult( + items = summaries, + nextCursor = nextCursor, + ) + } + } + } + } + + private fun getInterpretationCounts(spreadIds: List): Mono> { + if (spreadIds.isEmpty()) return Mono.just(emptyMap()) + return interpretationRepository + .countBySpreadIds(spreadIds) + .collectMap({ it.first }, { it.second.toInt() }) + } + + private fun buildSpreadSummaries( + spreads: List, + interpretationCounts: Map, + ): Mono> = + Flux + .fromIterable(spreads) + .flatMap { spread -> + Mono + .zip( + userProvider.getSystemUser(spread.authorId), + cardProvider.getSystemLayoutType(spread.layoutTypeId), + ).map { tuple -> + val author = tuple.t1 + val layoutType = tuple.t2 + SpreadSummaryDto( + id = spread.id!!, + question = spread.question, + layoutTypeName = layoutType.name, + cardsCount = layoutType.cardsCount, + interpretationsCount = interpretationCounts[spread.id] ?: 0, + authorUsername = author.username, + createdAt = spread.createdAt!!, + ) + } + }.collectList() + + @Transactional + fun getSpread(id: UUID): Mono = + spreadRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("Spread not found"))) + .flatMap { spread -> + // Load spread cards + val spreadCardsFlux = spreadCardRepository.findBySpreadId(spread.id!!) + // Load interpretations (limit 50) + val interpretationsFlux = + interpretationRepository + .findBySpreadIdOrderByCreatedAtDesc(spread.id!!, 0, 50) + + Mono + .zip(spreadCardsFlux.collectList(), interpretationsFlux.collectList()) + .flatMap { tuple -> + val spreadCards = tuple.t1 + val interpretations = tuple.t2 + + // Get all cards for this spread + val cardIds = spreadCards.map { it.cardId }.toSet() + buildCardCache(cardIds) + .flatMap { cardCache -> + buildSpreadDto(spread, spreadCards, interpretations, cardCache) + } + } + } + + private fun buildCardCache(cardIds: Set): Mono> { + if (cardIds.isEmpty()) return Mono.just(emptyMap()) + return cardProvider + .getAllCards() + .map { allCards -> allCards.filter { it.id in cardIds }.associateBy { it.id } } + } + + private fun buildSpreadDto( + spread: Spread, + spreadCards: List, + interpretations: List, + cardCache: Map, + ): Mono { + val authorMono = userProvider.getSystemUser(spread.authorId) + val layoutTypeMono = cardProvider.getSystemLayoutType(spread.layoutTypeId) + + val spreadCardsMono = + Flux + .fromIterable(spreadCards.sortedBy { it.positionInSpread }) + .flatMap { spreadCard -> + val cardMono = + if (cardCache.containsKey(spreadCard.cardId)) { + Mono.just(cardCache[spreadCard.cardId]!!) + } else { + fetchCardById(spreadCard.cardId) + } + cardMono.map { card -> + SpreadCardDto( + id = spreadCard.id!!, + card = card, + positionInSpread = spreadCard.positionInSpread, + isReversed = spreadCard.isReversed, + ) + } + }.collectList() + + val interpretationsMono = + Flux + .fromIterable(interpretations) + .flatMap { interpretation -> buildInterpretationDto(interpretation) } + .collectList() + + return Mono + .zip(authorMono, layoutTypeMono, spreadCardsMono, interpretationsMono) + .map { tuple -> + SpreadDto( + id = spread.id!!, + question = spread.question, + layoutType = tuple.t2, + cards = tuple.t3, + interpretations = tuple.t4, + author = tuple.t1, + createdAt = spread.createdAt!!, + ) + } + } + + private fun fetchCardById(cardId: UUID): Mono = + cardProvider + .getAllCards() + .map { allCards -> + allCards.find { it.id == cardId } + ?: throw IllegalStateException("Could not find card with id $cardId") + } + + @Transactional + fun deleteSpread(id: UUID): Mono = + spreadRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("Spread not found"))) + .flatMap { spread -> + currentUserProvider.canModify(spread.authorId).flatMap { canModify -> + if (!canModify) { + Mono.error(ForbiddenException("You can only delete your own spreads")) + } else { + spreadRepository + .deleteById(id) + .then(spreadEventPublisher.publishDeleted(spread)) + } + } + } + + fun getSpreadEntity(id: UUID): Mono = + spreadRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("Spread not found"))) + + @Transactional + fun addInterpretation( + spreadId: UUID, + text: String, + uploadId: UUID? = null, + ): Mono = + currentUserProvider.getCurrentUserId().flatMap { authorId -> + currentUserProvider.getCurrentRole().flatMap { role -> + getSpreadEntity(spreadId) + .flatMap { + // Validate user exists + userProvider.getUserById(authorId, role, authorId) + }.flatMap { + interpretationRepository.existsByAuthorAndSpread(authorId, spreadId) + }.flatMap { exists -> + if (exists) { + Mono.error(ConflictException("You already have an interpretation for this spread")) + } else { + val interpretation = + Interpretation( + id = null, + text = text, + authorId = authorId, + spreadId = spreadId, + createdAt = Instant.now(), + ) + interpretationRepository + .save(interpretation) + .flatMap { saved -> + // Handle optional file attachment + val attachmentMono = + if (uploadId != null) { + createAttachment(saved.id!!, uploadId, authorId) + } else { + Mono.empty() + } + + attachmentMono + .then(interpretationEventPublisher.publishCreated(saved)) + .then(Mono.just(CreateInterpretationResult(id = saved.id!!))) + } + } + } + } + } + + private fun createAttachment( + interpretationId: UUID, + uploadId: UUID, + userId: UUID, + ): Mono = + fileProvider + .verifyAndCompleteUpload(uploadId, userId) + .flatMap { metadata -> + val attachment = + InterpretationAttachment( + id = null, + interpretationId = interpretationId, + fileUploadId = metadata.uploadId, + originalFileName = metadata.originalFileName, + contentType = metadata.contentType, + fileSize = metadata.fileSize, + createdAt = null, + ) + interpretationAttachmentRepository.save(attachment) + } + + private fun toAttachmentDto(attachment: InterpretationAttachment): Mono = + fileProvider.getDownloadUrl(attachment.fileUploadId).map { downloadUrl -> + InterpretationAttachmentDto( + id = attachment.id!!, + originalFileName = attachment.originalFileName, + contentType = attachment.contentType, + fileSize = attachment.fileSize, + downloadUrl = downloadUrl, + ) + } + + private fun getAttachmentsForInterpretations( + interpretationIds: List, + ): Mono> { + if (interpretationIds.isEmpty()) return Mono.just(emptyMap()) + return interpretationAttachmentRepository + .findByInterpretationIds(interpretationIds) + .collectMap { it.interpretationId } + } + + private fun buildInterpretationDto(interpretation: Interpretation): Mono = + userProvider.getSystemUser(interpretation.authorId).flatMap { author -> + interpretationAttachmentRepository + .findByInterpretationId(interpretation.id!!) + .flatMap { toAttachmentDto(it) } + .map { attachmentDto -> + InterpretationDto( + id = interpretation.id!!, + text = interpretation.text, + author = author, + spreadId = interpretation.spreadId, + createdAt = interpretation.createdAt!!, + attachment = attachmentDto, + ) + }.switchIfEmpty( + Mono.just( + InterpretationDto( + id = interpretation.id!!, + text = interpretation.text, + author = author, + spreadId = interpretation.spreadId, + createdAt = interpretation.createdAt!!, + attachment = null, + ), + ), + ) + } + + @Transactional + fun updateInterpretation( + spreadId: UUID, + id: UUID, + newText: String, + ): Mono = + interpretationRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("Interpretation not found"))) + .flatMap { interpretation -> + currentUserProvider.canModify(interpretation.authorId).flatMap { canModify -> + if (!canModify) { + Mono.error(ForbiddenException("You can only edit your own interpretations")) + } else { + val updated = interpretation.copy(text = newText) + interpretationRepository + .save(updated) + .flatMap { saved -> + interpretationEventPublisher + .publishUpdated(saved) + .then(buildInterpretationDto(saved)) + } + } + } + } + + @Transactional + fun deleteInterpretation( + spreadId: UUID, + id: UUID, + ): Mono = + interpretationRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("Interpretation not found"))) + .flatMap { interpretation -> + currentUserProvider.canModify(interpretation.authorId).flatMap { canModify -> + if (!canModify) { + Mono.error(ForbiddenException("You can only delete your own interpretations")) + } else { + interpretationRepository + .deleteById(id) + .then(interpretationEventPublisher.publishDeleted(interpretation)) + } + } + } + + @Transactional(readOnly = true) + fun getInterpretation( + spreadId: UUID, + id: UUID, + ): Mono = + interpretationRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("Interpretation not found"))) + .flatMap { interpretation -> + if (interpretation.spreadId != spreadId) { + Mono.error(NotFoundException("Interpretation not found in this spread")) + } else { + buildInterpretationDto(interpretation) + } + } + + @Transactional(readOnly = true) + fun getInterpretations( + spreadId: UUID, + page: Int, + size: Int, + ): Mono> = + getSpreadEntity(spreadId) + .flatMap { + val offset = page.toLong() * size + val interpretationsFlux = + interpretationRepository.findBySpreadIdOrderByCreatedAtDesc( + spreadId, + offset, + size, + ) + val countMono = interpretationRepository.countBySpreadId(spreadId) + + Mono + .zip(interpretationsFlux.collectList(), countMono) + .flatMap { tuple -> + val interpretations = tuple.t1 + val totalElements = tuple.t2 + val totalPages = ((totalElements + size - 1) / size).toInt() + + Flux + .fromIterable(interpretations) + .flatMap { interpretation -> buildInterpretationDto(interpretation) } + .collectList() + .map { dtos -> + PageResult( + content = dtos, + page = page, + size = size, + totalElements = totalElements, + totalPages = totalPages, + isFirst = page == 0, + isLast = page >= totalPages - 1, + ) + } + } + } + + @Transactional + fun deleteUserData(userId: UUID): Mono = + // First delete all interpretations by this user + interpretationRepository + .deleteByAuthorId(userId) + // Then delete all spreads by this user (spread_cards cascade via internal FK) + .then(spreadRepository.deleteByAuthorId(userId)) + .then() + + fun getSpreadAuthorId(spreadId: UUID): Mono = + spreadRepository + .findById(spreadId) + .map { it.authorId } +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/config/KafkaConfig.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/config/KafkaConfig.kt new file mode 100644 index 0000000..3ea810f --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/config/KafkaConfig.kt @@ -0,0 +1,93 @@ +package com.github.butvinmitmo.divinationservice.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.butvinmitmo.shared.dto.events.InterpretationEventData +import com.github.butvinmitmo.shared.dto.events.SpreadEventData +import com.github.butvinmitmo.shared.dto.events.UserEventData +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.boot.autoconfigure.kafka.KafkaProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonDeserializer +import org.springframework.kafka.support.serializer.JsonSerializer + +@Configuration +class KafkaConfig { + @Bean + fun spreadProducerFactory( + kafkaProperties: KafkaProperties, + objectMapper: ObjectMapper, + ): ProducerFactory { + val props = kafkaProperties.buildProducerProperties(null) + val jsonSerializer = + JsonSerializer(objectMapper).apply { + isAddTypeInfo = false + } + return DefaultKafkaProducerFactory( + props, + StringSerializer(), + jsonSerializer, + ) + } + + @Bean + fun spreadKafkaTemplate( + spreadProducerFactory: ProducerFactory, + ): KafkaTemplate = KafkaTemplate(spreadProducerFactory) + + @Bean + fun interpretationProducerFactory( + kafkaProperties: KafkaProperties, + objectMapper: ObjectMapper, + ): ProducerFactory { + val props = kafkaProperties.buildProducerProperties(null) + val jsonSerializer = + JsonSerializer(objectMapper).apply { + isAddTypeInfo = false + } + return DefaultKafkaProducerFactory( + props, + StringSerializer(), + jsonSerializer, + ) + } + + @Bean + fun interpretationKafkaTemplate( + interpretationProducerFactory: ProducerFactory, + ): KafkaTemplate = KafkaTemplate(interpretationProducerFactory) + + @Bean + fun userConsumerFactory( + kafkaProperties: KafkaProperties, + objectMapper: ObjectMapper, + ): ConsumerFactory { + val props = kafkaProperties.buildConsumerProperties(null) + val jsonDeserializer = + JsonDeserializer(UserEventData::class.java, objectMapper).apply { + setRemoveTypeHeaders(false) + addTrustedPackages("*") + setUseTypeMapperForKey(false) + } + return DefaultKafkaConsumerFactory( + props, + StringDeserializer(), + jsonDeserializer, + ) + } + + @Bean + fun userKafkaListenerContainerFactory( + userConsumerFactory: ConsumerFactory, + ): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { + consumerFactory = userConsumerFactory + } +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/config/SecurityConfig.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/config/SecurityConfig.kt index 01a3f74..84d0b16 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/config/SecurityConfig.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/config/SecurityConfig.kt @@ -1,6 +1,6 @@ package com.github.butvinmitmo.divinationservice.config -import com.github.butvinmitmo.divinationservice.security.GatewayAuthenticationWebFilter +import com.github.butvinmitmo.divinationservice.infrastructure.security.GatewayAuthenticationWebFilter import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/InternalController.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/InternalController.kt deleted file mode 100644 index 68fafa8..0000000 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/controller/InternalController.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.butvinmitmo.divinationservice.controller - -import com.github.butvinmitmo.divinationservice.service.DivinationService -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import java.util.UUID - -/** - * Internal API controller for service-to-service communication. - * These endpoints are not exposed through the gateway and are only accessible - * via Eureka service discovery for internal operations. - */ -@RestController -@RequestMapping("/internal") -class InternalController( - private val divinationService: DivinationService, -) { - /** - * Deletes all data associated with a user (spreads and interpretations). - * Called by user-service before deleting a user to ensure cascade cleanup. - * - * @param userId The ID of the user whose data should be deleted - * @return 204 No Content on success - */ - @DeleteMapping("/users/{userId}/data") - fun deleteUserData( - @PathVariable userId: UUID, - ): Mono> = - divinationService - .deleteUserData(userId) - .then(Mono.just(ResponseEntity.noContent().build())) -} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/Interpretation.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/Interpretation.kt new file mode 100644 index 0000000..0ce497a --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/Interpretation.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.divinationservice.domain.model + +import java.time.Instant +import java.util.UUID + +data class Interpretation( + val id: UUID?, + val text: String, + val authorId: UUID, + val spreadId: UUID, + val createdAt: Instant?, +) diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/InterpretationAttachment.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/InterpretationAttachment.kt new file mode 100644 index 0000000..8c2154f --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/InterpretationAttachment.kt @@ -0,0 +1,14 @@ +package com.github.butvinmitmo.divinationservice.domain.model + +import java.time.Instant +import java.util.UUID + +data class InterpretationAttachment( + val id: UUID?, + val interpretationId: UUID, + val fileUploadId: UUID, + val originalFileName: String, + val contentType: String, + val fileSize: Long, + val createdAt: Instant?, +) diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/Spread.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/Spread.kt new file mode 100644 index 0000000..63f71ae --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/Spread.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.divinationservice.domain.model + +import java.time.Instant +import java.util.UUID + +data class Spread( + val id: UUID?, + val question: String?, + val layoutTypeId: UUID, + val authorId: UUID, + val createdAt: Instant?, +) diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/SpreadCard.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/SpreadCard.kt new file mode 100644 index 0000000..9655010 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/domain/model/SpreadCard.kt @@ -0,0 +1,11 @@ +package com.github.butvinmitmo.divinationservice.domain.model + +import java.util.UUID + +data class SpreadCard( + val id: UUID?, + val spreadId: UUID, + val cardId: UUID, + val positionInSpread: Int, + val isReversed: Boolean, +) diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/exception/GlobalExceptionHandler.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/exception/GlobalExceptionHandler.kt index 578c2c9..57b7d17 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/exception/GlobalExceptionHandler.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/exception/GlobalExceptionHandler.kt @@ -17,6 +17,8 @@ import java.time.Instant @RestControllerAdvice class GlobalExceptionHandler { + private val logger = org.slf4j.LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + @ExceptionHandler(WebExchangeBindException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) fun handleValidationExceptions( @@ -161,6 +163,7 @@ class GlobalExceptionHandler { ex: Exception, exchange: ServerWebExchange, ): ResponseEntity { + logger.error("Unexpected error on ${exchange.request.path.value()}", ex) val response = ErrorResponse( error = "INTERNAL_SERVER_ERROR", diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignCardProvider.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignCardProvider.kt new file mode 100644 index 0000000..13f5565 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignCardProvider.kt @@ -0,0 +1,56 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.external + +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.CardProvider +import com.github.butvinmitmo.shared.client.TarotServiceClient +import com.github.butvinmitmo.shared.dto.CardDto +import com.github.butvinmitmo.shared.dto.LayoutTypeDto +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.util.UUID + +@Component +class FeignCardProvider( + private val tarotServiceClient: TarotServiceClient, +) : CardProvider { + private val systemUserId = UUID.fromString("00000000-0000-0000-0000-000000000000") + private val systemRole = "SYSTEM" + + override fun getLayoutTypeById( + requesterId: UUID, + requesterRole: String, + layoutTypeId: UUID, + ): Mono = + Mono + .fromCallable { tarotServiceClient.getLayoutTypeById(requesterId, requesterRole, layoutTypeId).body!! } + .subscribeOn(Schedulers.boundedElastic()) + + override fun getRandomCards( + requesterId: UUID, + requesterRole: String, + count: Int, + ): Mono> = + Mono + .fromCallable { tarotServiceClient.getRandomCards(requesterId, requesterRole, count).body!! } + .subscribeOn(Schedulers.boundedElastic()) + + override fun getAllCards(): Mono> = + Mono + .fromCallable { + val allCards = mutableListOf() + val pageSize = 50 + var page = 0 + var fetched: List + do { + fetched = tarotServiceClient.getCards(systemUserId, systemRole, page, pageSize).body!! + allCards.addAll(fetched) + page++ + } while (fetched.size == pageSize) + allCards.toList() + }.subscribeOn(Schedulers.boundedElastic()) + + override fun getSystemLayoutType(layoutTypeId: UUID): Mono = + Mono + .fromCallable { tarotServiceClient.getLayoutTypeById(systemUserId, systemRole, layoutTypeId).body!! } + .subscribeOn(Schedulers.boundedElastic()) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignFileProvider.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignFileProvider.kt new file mode 100644 index 0000000..8ef1835 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignFileProvider.kt @@ -0,0 +1,32 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.external + +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.FileProvider +import com.github.butvinmitmo.shared.client.FilesServiceInternalClient +import com.github.butvinmitmo.shared.dto.FileUploadMetadataDto +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.util.UUID + +@Component +class FeignFileProvider( + private val filesServiceClient: FilesServiceInternalClient, +) : FileProvider { + override fun verifyAndCompleteUpload( + uploadId: UUID, + userId: UUID, + ): Mono = + Mono + .fromCallable { filesServiceClient.verifyAndCompleteUpload(uploadId, userId).body!! } + .subscribeOn(Schedulers.boundedElastic()) + + override fun getUploadMetadata(uploadId: UUID): Mono = + Mono + .fromCallable { filesServiceClient.getUploadMetadata(uploadId).body!! } + .subscribeOn(Schedulers.boundedElastic()) + + override fun getDownloadUrl(uploadId: UUID): Mono = + Mono + .fromCallable { filesServiceClient.getDownloadUrl(uploadId).body!!["downloadUrl"]!! } + .subscribeOn(Schedulers.boundedElastic()) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignUserProvider.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignUserProvider.kt new file mode 100644 index 0000000..82c1b06 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/external/FeignUserProvider.kt @@ -0,0 +1,31 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.external + +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.UserProvider +import com.github.butvinmitmo.shared.client.UserServiceClient +import com.github.butvinmitmo.shared.dto.UserDto +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.util.UUID + +@Component +class FeignUserProvider( + private val userServiceClient: UserServiceClient, +) : UserProvider { + private val systemUserId = UUID.fromString("00000000-0000-0000-0000-000000000000") + private val systemRole = "SYSTEM" + + override fun getUserById( + requesterId: UUID, + requesterRole: String, + userId: UUID, + ): Mono = + Mono + .fromCallable { userServiceClient.getUserById(requesterId, requesterRole, userId).body!! } + .subscribeOn(Schedulers.boundedElastic()) + + override fun getSystemUser(userId: UUID): Mono = + Mono + .fromCallable { userServiceClient.getUserById(systemUserId, systemRole, userId).body!! } + .subscribeOn(Schedulers.boundedElastic()) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/KafkaInterpretationEventPublisher.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/KafkaInterpretationEventPublisher.kt new file mode 100644 index 0000000..060d8a2 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/KafkaInterpretationEventPublisher.kt @@ -0,0 +1,60 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.messaging + +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.InterpretationEventPublisher +import com.github.butvinmitmo.divinationservice.domain.model.Interpretation +import com.github.butvinmitmo.divinationservice.infrastructure.messaging.mapper.InterpretationEventDataMapper +import com.github.butvinmitmo.shared.dto.events.EventType +import com.github.butvinmitmo.shared.dto.events.InterpretationEventData +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.time.Instant + +@Component +class KafkaInterpretationEventPublisher( + @Qualifier("interpretationKafkaTemplate") private val kafkaTemplate: KafkaTemplate, + private val mapper: InterpretationEventDataMapper, + @Value("\${kafka.topics.interpretations-events}") private val topic: String, +) : InterpretationEventPublisher { + private val log = LoggerFactory.getLogger(javaClass) + + override fun publishCreated(interpretation: Interpretation): Mono = publish(interpretation, EventType.CREATED) + + override fun publishUpdated(interpretation: Interpretation): Mono = publish(interpretation, EventType.UPDATED) + + override fun publishDeleted(interpretation: Interpretation): Mono = publish(interpretation, EventType.DELETED) + + private fun publish( + interpretation: Interpretation, + eventType: EventType, + ): Mono = + Mono + .fromCallable { + val eventData = mapper.toEventData(interpretation) + val record = + ProducerRecord( + topic, + null, + eventData.id.toString(), + eventData, + ).apply { + headers().add("eventType", eventType.name.toByteArray()) + headers().add("timestamp", Instant.now().toString().toByteArray()) + } + kafkaTemplate.send(record).get() + log.debug("Published {} event for interpretation {}", eventType, interpretation.id) + }.subscribeOn(Schedulers.boundedElastic()) + .doOnError { e -> + log.error( + "Failed to publish {} event for interpretation {}: {}", + eventType, + interpretation.id, + e.message, + ) + }.then() +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/KafkaSpreadEventPublisher.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/KafkaSpreadEventPublisher.kt new file mode 100644 index 0000000..149e432 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/KafkaSpreadEventPublisher.kt @@ -0,0 +1,48 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.messaging + +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.SpreadEventPublisher +import com.github.butvinmitmo.divinationservice.domain.model.Spread +import com.github.butvinmitmo.divinationservice.infrastructure.messaging.mapper.SpreadEventDataMapper +import com.github.butvinmitmo.shared.dto.events.EventType +import com.github.butvinmitmo.shared.dto.events.SpreadEventData +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.time.Instant + +@Component +class KafkaSpreadEventPublisher( + @Qualifier("spreadKafkaTemplate") private val kafkaTemplate: KafkaTemplate, + private val mapper: SpreadEventDataMapper, + @Value("\${kafka.topics.spreads-events}") private val topic: String, +) : SpreadEventPublisher { + private val log = LoggerFactory.getLogger(javaClass) + + override fun publishCreated(spread: Spread): Mono = publish(spread, EventType.CREATED) + + override fun publishDeleted(spread: Spread): Mono = publish(spread, EventType.DELETED) + + private fun publish( + spread: Spread, + eventType: EventType, + ): Mono = + Mono + .fromCallable { + val eventData = mapper.toEventData(spread) + val record = + ProducerRecord(topic, null, eventData.id.toString(), eventData).apply { + headers().add("eventType", eventType.name.toByteArray()) + headers().add("timestamp", Instant.now().toString().toByteArray()) + } + kafkaTemplate.send(record).get() + log.debug("Published {} event for spread {}", eventType, spread.id) + }.subscribeOn(Schedulers.boundedElastic()) + .doOnError { e -> + log.error("Failed to publish {} event for spread {}: {}", eventType, spread.id, e.message) + }.then() +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/UserEventConsumer.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/UserEventConsumer.kt new file mode 100644 index 0000000..c68cb04 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/UserEventConsumer.kt @@ -0,0 +1,50 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.messaging + +import com.github.butvinmitmo.divinationservice.application.service.DivinationService +import com.github.butvinmitmo.shared.dto.events.EventType +import com.github.butvinmitmo.shared.dto.events.UserEventData +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.messaging.handler.annotation.Header +import org.springframework.messaging.handler.annotation.Payload +import org.springframework.stereotype.Component + +@Component +class UserEventConsumer( + private val divinationService: DivinationService, +) { + private val logger = LoggerFactory.getLogger(UserEventConsumer::class.java) + + @KafkaListener( + topics = ["\${kafka.topics.users-events:users-events}"], + containerFactory = "userKafkaListenerContainerFactory", + ) + fun onUserEvent( + @Payload event: UserEventData, + @Header("eventType") eventTypeBytes: ByteArray, + ) { + val eventType = + try { + EventType.valueOf(String(eventTypeBytes)) + } catch (e: IllegalArgumentException) { + logger.warn("Unknown event type: {}, skipping", String(eventTypeBytes)) + return + } + + // Only process DELETED events for user data cleanup + if (eventType != EventType.DELETED) { + logger.debug("Ignoring {} event for user {}", eventType, event.id) + return + } + + logger.info("Processing DELETED event for user {}", event.id) + + divinationService + .deleteUserData(event.id) + .doOnSuccess { + logger.info("Successfully deleted all data for user {}", event.id) + }.doOnError { e -> + logger.error("Failed to delete data for user {}: {}", event.id, e.message) + }.subscribe() + } +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/mapper/InterpretationEventDataMapper.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/mapper/InterpretationEventDataMapper.kt new file mode 100644 index 0000000..473428a --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/mapper/InterpretationEventDataMapper.kt @@ -0,0 +1,17 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.messaging.mapper + +import com.github.butvinmitmo.divinationservice.domain.model.Interpretation +import com.github.butvinmitmo.shared.dto.events.InterpretationEventData +import org.springframework.stereotype.Component + +@Component +class InterpretationEventDataMapper { + fun toEventData(interpretation: Interpretation): InterpretationEventData = + InterpretationEventData( + id = interpretation.id!!, + text = interpretation.text, + authorId = interpretation.authorId, + spreadId = interpretation.spreadId, + createdAt = interpretation.createdAt!!, + ) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/mapper/SpreadEventDataMapper.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/mapper/SpreadEventDataMapper.kt new file mode 100644 index 0000000..9807cc6 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/messaging/mapper/SpreadEventDataMapper.kt @@ -0,0 +1,17 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.messaging.mapper + +import com.github.butvinmitmo.divinationservice.domain.model.Spread +import com.github.butvinmitmo.shared.dto.events.SpreadEventData +import org.springframework.stereotype.Component + +@Component +class SpreadEventDataMapper { + fun toEventData(spread: Spread): SpreadEventData = + SpreadEventData( + id = spread.id!!, + question = spread.question, + layoutTypeId = spread.layoutTypeId, + authorId = spread.authorId, + createdAt = spread.createdAt!!, + ) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcInterpretationAttachmentRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcInterpretationAttachmentRepository.kt new file mode 100644 index 0000000..d9f4e18 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcInterpretationAttachmentRepository.kt @@ -0,0 +1,39 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence + +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.InterpretationAttachmentRepository +import com.github.butvinmitmo.divinationservice.domain.model.InterpretationAttachment +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.mapper.InterpretationAttachmentEntityMapper +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataInterpretationAttachmentRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +class R2dbcInterpretationAttachmentRepository( + private val springDataRepository: SpringDataInterpretationAttachmentRepository, + private val mapper: InterpretationAttachmentEntityMapper, +) : InterpretationAttachmentRepository { + override fun save(attachment: InterpretationAttachment): Mono = + springDataRepository + .save(mapper.toEntity(attachment)) + .map { mapper.toDomain(it) } + + override fun findByInterpretationId(interpretationId: UUID): Mono = + springDataRepository + .findByInterpretationId(interpretationId) + .map { mapper.toDomain(it) } + + override fun findByInterpretationIds(interpretationIds: List): Flux { + if (interpretationIds.isEmpty()) return Flux.empty() + return springDataRepository + .findByInterpretationIdIn(interpretationIds) + .map { mapper.toDomain(it) } + } + + override fun deleteByInterpretationId(interpretationId: UUID): Mono = + springDataRepository.deleteByInterpretationId(interpretationId) + + override fun existsByInterpretationId(interpretationId: UUID): Mono = + springDataRepository.existsByInterpretationId(interpretationId) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcInterpretationRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcInterpretationRepository.kt new file mode 100644 index 0000000..9350d57 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcInterpretationRepository.kt @@ -0,0 +1,69 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence + +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.InterpretationRepository +import com.github.butvinmitmo.divinationservice.domain.model.Interpretation +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.mapper.InterpretationEntityMapper +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataInterpretationRepository +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +class R2dbcInterpretationRepository( + private val springDataInterpretationRepository: SpringDataInterpretationRepository, + private val interpretationEntityMapper: InterpretationEntityMapper, + private val databaseClient: DatabaseClient, +) : InterpretationRepository { + override fun save(interpretation: Interpretation): Mono = + springDataInterpretationRepository + .save(interpretationEntityMapper.toEntity(interpretation)) + .map { interpretationEntityMapper.toDomain(it) } + + override fun findById(id: UUID): Mono = + springDataInterpretationRepository + .findById(id) + .map { interpretationEntityMapper.toDomain(it) } + + override fun findBySpreadIdOrderByCreatedAtDesc( + spreadId: UUID, + offset: Long, + limit: Int, + ): Flux = + springDataInterpretationRepository + .findBySpreadIdOrderByCreatedAtDesc(spreadId, offset, limit) + .map { interpretationEntityMapper.toDomain(it) } + + override fun existsByAuthorAndSpread( + authorId: UUID, + spreadId: UUID, + ): Mono = springDataInterpretationRepository.existsByAuthorAndSpread(authorId, spreadId) + + override fun countBySpreadId(spreadId: UUID): Mono = + springDataInterpretationRepository.countBySpreadId(spreadId) + + override fun countBySpreadIds(spreadIds: List): Flux> { + if (spreadIds.isEmpty()) return Flux.empty() + + return databaseClient + .sql( + """ + SELECT spread_id, COUNT(*) as count + FROM interpretation + WHERE spread_id = ANY(:spreadIds) + GROUP BY spread_id + """.trimIndent(), + ).bind("spreadIds", spreadIds.toTypedArray()) + .map { row, _ -> + val spreadId = row.get("spread_id", UUID::class.java)!! + val count = (row.get("count", java.lang.Long::class.java) ?: 0L).toLong() + spreadId to count + }.all() + } + + override fun deleteById(id: UUID): Mono = springDataInterpretationRepository.deleteById(id) + + override fun deleteByAuthorId(authorId: UUID): Mono = + springDataInterpretationRepository.deleteByAuthorId(authorId) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcSpreadCardRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcSpreadCardRepository.kt new file mode 100644 index 0000000..90c1bbd --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcSpreadCardRepository.kt @@ -0,0 +1,26 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence + +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.SpreadCardRepository +import com.github.butvinmitmo.divinationservice.domain.model.SpreadCard +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.mapper.SpreadCardEntityMapper +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataSpreadCardRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +class R2dbcSpreadCardRepository( + private val springDataSpreadCardRepository: SpringDataSpreadCardRepository, + private val spreadCardEntityMapper: SpreadCardEntityMapper, +) : SpreadCardRepository { + override fun save(spreadCard: SpreadCard): Mono = + springDataSpreadCardRepository + .save(spreadCardEntityMapper.toEntity(spreadCard)) + .map { spreadCardEntityMapper.toDomain(it) } + + override fun findBySpreadId(spreadId: UUID): Flux = + springDataSpreadCardRepository + .findBySpreadId(spreadId) + .map { spreadCardEntityMapper.toDomain(it) } +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/SpreadRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcSpreadRepository.kt similarity index 52% rename from divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/SpreadRepository.kt rename to divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcSpreadRepository.kt index 9e0626d..f1c66ce 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/SpreadRepository.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/R2dbcSpreadRepository.kt @@ -1,11 +1,11 @@ -package com.github.butvinmitmo.divinationservice.repository +package com.github.butvinmitmo.divinationservice.infrastructure.persistence -import com.github.butvinmitmo.divinationservice.entity.Spread -import org.springframework.data.r2dbc.repository.Modifying -import org.springframework.data.r2dbc.repository.Query -import org.springframework.data.r2dbc.repository.R2dbcRepository +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.SpreadRepository +import com.github.butvinmitmo.divinationservice.domain.model.Spread +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.SpreadEntity +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.mapper.SpreadEntityMapper +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataSpreadRepository import org.springframework.r2dbc.core.DatabaseClient -import org.springframework.stereotype.Component import org.springframework.stereotype.Repository import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -13,39 +13,29 @@ import java.time.Instant import java.util.UUID @Repository -interface SpreadRepository : - R2dbcRepository, - SpreadRepositoryCustom { - @Query("SELECT * FROM spread WHERE id = :id") - fun findByIdWithCards(id: UUID): Mono - - @Query("SELECT * FROM spread ORDER BY created_at DESC LIMIT :limit OFFSET :offset") - fun findAllOrderByCreatedAtDesc( - offset: Long, - limit: Int, - ): Flux - - @Query("SELECT COUNT(*) FROM spread") - override fun count(): Mono +class R2dbcSpreadRepository( + private val springDataSpreadRepository: SpringDataSpreadRepository, + private val spreadEntityMapper: SpreadEntityMapper, + private val databaseClient: DatabaseClient, +) : SpreadRepository { + override fun save(spread: Spread): Mono = + springDataSpreadRepository + .save(spreadEntityMapper.toEntity(spread)) + .map { spreadEntityMapper.toDomain(it) } - @Modifying - @Query("DELETE FROM spread WHERE author_id = :authorId") - fun deleteByAuthorId(authorId: UUID): Mono -} + override fun findById(id: UUID): Mono = + springDataSpreadRepository + .findById(id) + .map { spreadEntityMapper.toDomain(it) } -interface SpreadRepositoryCustom { - fun findSpreadsAfterCursor( - spreadId: UUID, + override fun findAllOrderByCreatedAtDesc( + offset: Long, limit: Int, - ): Flux - - fun findLatestSpreads(limit: Int): Flux -} + ): Flux = + springDataSpreadRepository + .findAllOrderByCreatedAtDesc(offset, limit) + .map { spreadEntityMapper.toDomain(it) } -@Component -class SpreadRepositoryCustomImpl( - private val databaseClient: DatabaseClient, -) : SpreadRepositoryCustom { override fun findSpreadsAfterCursor( spreadId: UUID, limit: Int, @@ -62,7 +52,7 @@ class SpreadRepositoryCustomImpl( ).bind("spreadId", spreadId) .bind("limit", limit) .map { row, _ -> - Spread( + SpreadEntity( id = row.get("id", UUID::class.java), question = row.get("question", String::class.java), layoutTypeId = row.get("layout_type_id", UUID::class.java)!!, @@ -70,13 +60,14 @@ class SpreadRepositoryCustomImpl( createdAt = row.get("created_at", Instant::class.java), ) }.all() + .map { spreadEntityMapper.toDomain(it) } override fun findLatestSpreads(limit: Int): Flux = databaseClient .sql("SELECT * FROM spread ORDER BY created_at DESC, id DESC LIMIT :limit") .bind("limit", limit) .map { row, _ -> - Spread( + SpreadEntity( id = row.get("id", UUID::class.java), question = row.get("question", String::class.java), layoutTypeId = row.get("layout_type_id", UUID::class.java)!!, @@ -84,4 +75,11 @@ class SpreadRepositoryCustomImpl( createdAt = row.get("created_at", Instant::class.java), ) }.all() + .map { spreadEntityMapper.toDomain(it) } + + override fun count(): Mono = springDataSpreadRepository.count() + + override fun deleteById(id: UUID): Mono = springDataSpreadRepository.deleteById(id) + + override fun deleteByAuthorId(authorId: UUID): Mono = springDataSpreadRepository.deleteByAuthorId(authorId) } diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/InterpretationAttachmentEntity.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/InterpretationAttachmentEntity.kt new file mode 100644 index 0000000..004c97e --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/InterpretationAttachmentEntity.kt @@ -0,0 +1,25 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant +import java.util.UUID + +@Table("interpretation_attachment") +data class InterpretationAttachmentEntity( + @Id + val id: UUID? = null, + @Column("interpretation_id") + val interpretationId: UUID, + @Column("file_upload_id") + val fileUploadId: UUID, + @Column("original_file_name") + val originalFileName: String, + @Column("content_type") + val contentType: String, + @Column("file_size") + val fileSize: Long, + @Column("created_at") + val createdAt: Instant? = null, +) diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/Interpretation.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/InterpretationEntity.kt similarity index 76% rename from divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/Interpretation.kt rename to divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/InterpretationEntity.kt index c7627ac..c71441f 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/Interpretation.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/InterpretationEntity.kt @@ -1,4 +1,4 @@ -package com.github.butvinmitmo.divinationservice.entity +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Column @@ -7,11 +7,11 @@ import java.time.Instant import java.util.UUID @Table("interpretation") -data class Interpretation( +data class InterpretationEntity( @Id val id: UUID? = null, @Column("text") - var text: String, + val text: String, @Column("author_id") val authorId: UUID, @Column("spread_id") diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/SpreadCard.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/SpreadCardEntity.kt similarity index 80% rename from divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/SpreadCard.kt rename to divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/SpreadCardEntity.kt index 8e0b364..1e021b2 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/SpreadCard.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/SpreadCardEntity.kt @@ -1,4 +1,4 @@ -package com.github.butvinmitmo.divinationservice.entity +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Column @@ -6,7 +6,7 @@ import org.springframework.data.relational.core.mapping.Table import java.util.UUID @Table("spread_card") -data class SpreadCard( +data class SpreadCardEntity( @Id val id: UUID? = null, @Column("spread_id") diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/Spread.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/SpreadEntity.kt similarity index 82% rename from divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/Spread.kt rename to divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/SpreadEntity.kt index da8ac0c..d92d376 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/entity/Spread.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/entity/SpreadEntity.kt @@ -1,4 +1,4 @@ -package com.github.butvinmitmo.divinationservice.entity +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Column @@ -7,7 +7,7 @@ import java.time.Instant import java.util.UUID @Table("spread") -data class Spread( +data class SpreadEntity( @Id val id: UUID? = null, @Column("question") diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/InterpretationAttachmentEntityMapper.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/InterpretationAttachmentEntityMapper.kt new file mode 100644 index 0000000..ae0cd98 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/InterpretationAttachmentEntityMapper.kt @@ -0,0 +1,30 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.divinationservice.domain.model.InterpretationAttachment +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.InterpretationAttachmentEntity +import org.springframework.stereotype.Component + +@Component +class InterpretationAttachmentEntityMapper { + fun toDomain(entity: InterpretationAttachmentEntity): InterpretationAttachment = + InterpretationAttachment( + id = entity.id, + interpretationId = entity.interpretationId, + fileUploadId = entity.fileUploadId, + originalFileName = entity.originalFileName, + contentType = entity.contentType, + fileSize = entity.fileSize, + createdAt = entity.createdAt, + ) + + fun toEntity(domain: InterpretationAttachment): InterpretationAttachmentEntity = + InterpretationAttachmentEntity( + id = domain.id, + interpretationId = domain.interpretationId, + fileUploadId = domain.fileUploadId, + originalFileName = domain.originalFileName, + contentType = domain.contentType, + fileSize = domain.fileSize, + createdAt = domain.createdAt, + ) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/InterpretationEntityMapper.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/InterpretationEntityMapper.kt new file mode 100644 index 0000000..83cf816 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/InterpretationEntityMapper.kt @@ -0,0 +1,26 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.divinationservice.domain.model.Interpretation +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.InterpretationEntity +import org.springframework.stereotype.Component + +@Component +class InterpretationEntityMapper { + fun toDomain(entity: InterpretationEntity): Interpretation = + Interpretation( + id = entity.id, + text = entity.text, + authorId = entity.authorId, + spreadId = entity.spreadId, + createdAt = entity.createdAt, + ) + + fun toEntity(domain: Interpretation): InterpretationEntity = + InterpretationEntity( + id = domain.id, + text = domain.text, + authorId = domain.authorId, + spreadId = domain.spreadId, + createdAt = domain.createdAt, + ) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/SpreadCardEntityMapper.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/SpreadCardEntityMapper.kt new file mode 100644 index 0000000..2944a7e --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/SpreadCardEntityMapper.kt @@ -0,0 +1,26 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.divinationservice.domain.model.SpreadCard +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.SpreadCardEntity +import org.springframework.stereotype.Component + +@Component +class SpreadCardEntityMapper { + fun toDomain(entity: SpreadCardEntity): SpreadCard = + SpreadCard( + id = entity.id, + spreadId = entity.spreadId, + cardId = entity.cardId, + positionInSpread = entity.positionInSpread, + isReversed = entity.isReversed, + ) + + fun toEntity(domain: SpreadCard): SpreadCardEntity = + SpreadCardEntity( + id = domain.id, + spreadId = domain.spreadId, + cardId = domain.cardId, + positionInSpread = domain.positionInSpread, + isReversed = domain.isReversed, + ) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/SpreadEntityMapper.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/SpreadEntityMapper.kt new file mode 100644 index 0000000..fd50cde --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/mapper/SpreadEntityMapper.kt @@ -0,0 +1,26 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.divinationservice.domain.model.Spread +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.SpreadEntity +import org.springframework.stereotype.Component + +@Component +class SpreadEntityMapper { + fun toDomain(entity: SpreadEntity): Spread = + Spread( + id = entity.id, + question = entity.question, + layoutTypeId = entity.layoutTypeId, + authorId = entity.authorId, + createdAt = entity.createdAt, + ) + + fun toEntity(domain: Spread): SpreadEntity = + SpreadEntity( + id = domain.id, + question = domain.question, + layoutTypeId = domain.layoutTypeId, + authorId = domain.authorId, + createdAt = domain.createdAt, + ) +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataInterpretationAttachmentRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataInterpretationAttachmentRepository.kt new file mode 100644 index 0000000..f751046 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataInterpretationAttachmentRepository.kt @@ -0,0 +1,24 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.InterpretationAttachmentEntity +import org.springframework.data.r2dbc.repository.Modifying +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +interface SpringDataInterpretationAttachmentRepository : R2dbcRepository { + fun findByInterpretationId(interpretationId: UUID): Mono + + @Query("SELECT * FROM interpretation_attachment WHERE interpretation_id IN (:interpretationIds)") + fun findByInterpretationIdIn(interpretationIds: List): Flux + + @Modifying + @Query("DELETE FROM interpretation_attachment WHERE interpretation_id = :interpretationId") + fun deleteByInterpretationId(interpretationId: UUID): Mono + + fun existsByInterpretationId(interpretationId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataInterpretationRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataInterpretationRepository.kt new file mode 100644 index 0000000..7f19a66 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataInterpretationRepository.kt @@ -0,0 +1,43 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.InterpretationEntity +import org.springframework.data.r2dbc.repository.Modifying +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +interface SpringDataInterpretationRepository : R2dbcRepository { + @Query( + "SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END " + + "FROM interpretation WHERE author_id = :authorId AND spread_id = :spreadId", + ) + fun existsByAuthorAndSpread( + authorId: UUID, + spreadId: UUID, + ): Mono + + @Query( + """ + SELECT * FROM interpretation + WHERE spread_id = :spreadId + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset + """, + ) + fun findBySpreadIdOrderByCreatedAtDesc( + spreadId: UUID, + offset: Long, + limit: Int, + ): Flux + + @Query("SELECT COUNT(*) FROM interpretation WHERE spread_id = :spreadId") + fun countBySpreadId(spreadId: UUID): Mono + + @Modifying + @Query("DELETE FROM interpretation WHERE author_id = :authorId") + fun deleteByAuthorId(authorId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataSpreadCardRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataSpreadCardRepository.kt new file mode 100644 index 0000000..c8710f3 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataSpreadCardRepository.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.SpreadCardEntity +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import java.util.UUID + +@Repository +interface SpringDataSpreadCardRepository : R2dbcRepository { + fun findBySpreadId(spreadId: UUID): Flux +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataSpreadRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataSpreadRepository.kt new file mode 100644 index 0000000..899ccf8 --- /dev/null +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/persistence/repository/SpringDataSpreadRepository.kt @@ -0,0 +1,26 @@ +package com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.SpreadEntity +import org.springframework.data.r2dbc.repository.Modifying +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +interface SpringDataSpreadRepository : R2dbcRepository { + @Query("SELECT * FROM spread ORDER BY created_at DESC LIMIT :limit OFFSET :offset") + fun findAllOrderByCreatedAtDesc( + offset: Long, + limit: Int, + ): Flux + + @Query("SELECT COUNT(*) FROM spread") + override fun count(): Mono + + @Modifying + @Query("DELETE FROM spread WHERE author_id = :authorId") + fun deleteByAuthorId(authorId: UUID): Mono +} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/security/GatewayAuthenticationWebFilter.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/security/GatewayAuthenticationWebFilter.kt similarity index 95% rename from divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/security/GatewayAuthenticationWebFilter.kt rename to divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/security/GatewayAuthenticationWebFilter.kt index 778209f..618b06c 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/security/GatewayAuthenticationWebFilter.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/security/GatewayAuthenticationWebFilter.kt @@ -1,4 +1,4 @@ -package com.github.butvinmitmo.divinationservice.security +package com.github.butvinmitmo.divinationservice.infrastructure.security import com.github.butvinmitmo.shared.security.GatewayAuthenticationToken import org.springframework.security.core.context.ReactiveSecurityContextHolder diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/security/AuthorizationService.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/security/SecurityContextCurrentUserProvider.kt similarity index 62% rename from divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/security/AuthorizationService.kt rename to divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/security/SecurityContextCurrentUserProvider.kt index d6627c2..25136b2 100644 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/security/AuthorizationService.kt +++ b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/infrastructure/security/SecurityContextCurrentUserProvider.kt @@ -1,24 +1,25 @@ -package com.github.butvinmitmo.divinationservice.security +package com.github.butvinmitmo.divinationservice.infrastructure.security +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.CurrentUserProvider import com.github.butvinmitmo.shared.security.GatewayAuthenticationToken import org.springframework.security.core.context.ReactiveSecurityContextHolder -import org.springframework.stereotype.Service +import org.springframework.stereotype.Component import reactor.core.publisher.Mono import java.util.UUID -@Service -class AuthorizationService { - fun getCurrentUserId(): Mono = +@Component +class SecurityContextCurrentUserProvider : CurrentUserProvider { + override fun getCurrentUserId(): Mono = ReactiveSecurityContextHolder .getContext() .map { (it.authentication as GatewayAuthenticationToken).userId } - fun getCurrentRole(): Mono = + override fun getCurrentRole(): Mono = ReactiveSecurityContextHolder .getContext() .map { (it.authentication as GatewayAuthenticationToken).role } - fun canModify(authorId: UUID): Mono = + override fun canModify(authorId: UUID): Mono = ReactiveSecurityContextHolder .getContext() .map { auth -> diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/mapper/InterpretationMapper.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/mapper/InterpretationMapper.kt deleted file mode 100644 index 3798ce5..0000000 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/mapper/InterpretationMapper.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.butvinmitmo.divinationservice.mapper - -import com.github.butvinmitmo.divinationservice.entity.Interpretation -import com.github.butvinmitmo.shared.client.UserServiceClient -import com.github.butvinmitmo.shared.dto.InterpretationDto -import org.springframework.stereotype.Component -import java.util.UUID - -@Component -class InterpretationMapper( - private val userServiceClient: UserServiceClient, -) { - // System context for internal service-to-service calls - private val systemUserId = UUID.fromString("00000000-0000-0000-0000-000000000000") - private val systemRole = "SYSTEM" - - fun toDto(interpretation: Interpretation): InterpretationDto { - val author = userServiceClient.getUserById(systemUserId, systemRole, interpretation.authorId).body!! - return InterpretationDto( - id = interpretation.id!!, - text = interpretation.text, - author = author, - spreadId = interpretation.spreadId, - createdAt = interpretation.createdAt!!, - ) - } -} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/mapper/SpreadMapper.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/mapper/SpreadMapper.kt deleted file mode 100644 index 9fb62e1..0000000 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/mapper/SpreadMapper.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.github.butvinmitmo.divinationservice.mapper - -import com.github.butvinmitmo.divinationservice.entity.Interpretation -import com.github.butvinmitmo.divinationservice.entity.Spread -import com.github.butvinmitmo.divinationservice.entity.SpreadCard -import com.github.butvinmitmo.shared.client.TarotServiceClient -import com.github.butvinmitmo.shared.client.UserServiceClient -import com.github.butvinmitmo.shared.dto.CardDto -import com.github.butvinmitmo.shared.dto.InterpretationDto -import com.github.butvinmitmo.shared.dto.SpreadCardDto -import com.github.butvinmitmo.shared.dto.SpreadDto -import com.github.butvinmitmo.shared.dto.SpreadSummaryDto -import org.springframework.stereotype.Component -import java.util.UUID - -@Component -class SpreadMapper( - private val userServiceClient: UserServiceClient, - private val tarotServiceClient: TarotServiceClient, -) { - // System context for internal service-to-service calls - private val systemUserId = UUID.fromString("00000000-0000-0000-0000-000000000000") - private val systemRole = "SYSTEM" - - fun toDto( - spread: Spread, - spreadCards: List, - interpretations: List, - cardCache: Map = emptyMap(), - ): SpreadDto { - val author = userServiceClient.getUserById(systemUserId, systemRole, spread.authorId).body!! - val layoutType = tarotServiceClient.getLayoutTypeById(systemUserId, systemRole, spread.layoutTypeId).body!! - - return SpreadDto( - id = spread.id!!, - question = spread.question, - layoutType = layoutType, - cards = - spreadCards.sortedBy { it.positionInSpread }.map { spreadCard -> - val card = cardCache[spreadCard.cardId] ?: fetchCard(spreadCard.cardId) - SpreadCardDto( - id = spreadCard.id!!, - card = card, - positionInSpread = spreadCard.positionInSpread, - isReversed = spreadCard.isReversed, - ) - }, - interpretations = - interpretations.map { interpretation -> - val interpAuthor = - userServiceClient - .getUserById( - systemUserId, - systemRole, - interpretation.authorId, - ).body!! - InterpretationDto( - id = interpretation.id!!, - text = interpretation.text, - author = interpAuthor, - spreadId = interpretation.spreadId, - createdAt = interpretation.createdAt!!, - ) - }, - author = author, - createdAt = spread.createdAt!!, - ) - } - - fun toSummaryDto( - spread: Spread, - interpretationsCount: Int = 0, - ): SpreadSummaryDto { - val author = userServiceClient.getUserById(systemUserId, systemRole, spread.authorId).body!! - val layoutType = tarotServiceClient.getLayoutTypeById(systemUserId, systemRole, spread.layoutTypeId).body!! - - return SpreadSummaryDto( - id = spread.id!!, - question = spread.question, - layoutTypeName = layoutType.name, - cardsCount = layoutType.cardsCount, - interpretationsCount = interpretationsCount, - authorUsername = author.username, - createdAt = spread.createdAt!!, - ) - } - - private fun fetchCard(cardId: UUID): CardDto { - val allCards = mutableListOf() - val pageSize = 50 - var page = 0 - var fetched: List - do { - fetched = tarotServiceClient.getCards(systemUserId, systemRole, page, pageSize).body!! - allCards.addAll(fetched) - page++ - } while (fetched.size == pageSize) - return allCards.find { it.id == cardId } - ?: throw IllegalStateException("Could not fetch card with id $cardId") - } -} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/InterpretationRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/InterpretationRepository.kt deleted file mode 100644 index a55bd58..0000000 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/InterpretationRepository.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.github.butvinmitmo.divinationservice.repository - -import com.github.butvinmitmo.divinationservice.entity.Interpretation -import org.springframework.data.r2dbc.repository.Modifying -import org.springframework.data.r2dbc.repository.Query -import org.springframework.data.r2dbc.repository.R2dbcRepository -import org.springframework.r2dbc.core.DatabaseClient -import org.springframework.stereotype.Component -import org.springframework.stereotype.Repository -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import java.util.UUID - -@Repository -interface InterpretationRepository : - R2dbcRepository, - InterpretationRepositoryCustom { - @Query( - "SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END " + - "FROM interpretation WHERE author_id = :authorId AND spread_id = :spreadId", - ) - fun existsByAuthorAndSpread( - authorId: UUID, - spreadId: UUID, - ): Mono - - @Query( - """ - SELECT * FROM interpretation - WHERE spread_id = :spreadId - ORDER BY created_at DESC - LIMIT :limit OFFSET :offset - """, - ) - fun findBySpreadIdOrderByCreatedAtDesc( - spreadId: UUID, - offset: Long, - limit: Int, - ): Flux - - @Query("SELECT COUNT(*) FROM interpretation WHERE spread_id = :spreadId") - fun countBySpreadId(spreadId: UUID): Mono - - @Modifying - @Query("DELETE FROM interpretation WHERE author_id = :authorId") - fun deleteByAuthorId(authorId: UUID): Mono -} - -interface InterpretationRepositoryCustom { - fun countBySpreadIds(spreadIds: List): Flux> -} - -@Component -class InterpretationRepositoryCustomImpl( - private val databaseClient: DatabaseClient, -) : InterpretationRepositoryCustom { - override fun countBySpreadIds(spreadIds: List): Flux> { - if (spreadIds.isEmpty()) return Flux.empty() - - return databaseClient - .sql( - """ - SELECT spread_id, COUNT(*) as count - FROM interpretation - WHERE spread_id = ANY(:spreadIds) - GROUP BY spread_id - """.trimIndent(), - ).bind("spreadIds", spreadIds.toTypedArray()) - .map { row, _ -> - val spreadId = row.get("spread_id", UUID::class.java)!! - val count = (row.get("count", java.lang.Long::class.java) ?: 0L).toLong() - spreadId to count - }.all() - } -} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/SpreadCardRepository.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/SpreadCardRepository.kt deleted file mode 100644 index 6aa1918..0000000 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/repository/SpreadCardRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.butvinmitmo.divinationservice.repository - -import com.github.butvinmitmo.divinationservice.entity.SpreadCard -import org.springframework.data.r2dbc.repository.R2dbcRepository -import org.springframework.stereotype.Repository -import reactor.core.publisher.Flux -import java.util.UUID - -@Repository -interface SpreadCardRepository : R2dbcRepository { - fun findBySpreadId(spreadId: UUID): Flux -} diff --git a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/service/DivinationService.kt b/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/service/DivinationService.kt deleted file mode 100644 index b5e7678..0000000 --- a/divination-service/src/main/kotlin/com/github/butvinmitmo/divinationservice/service/DivinationService.kt +++ /dev/null @@ -1,386 +0,0 @@ -package com.github.butvinmitmo.divinationservice.service - -import com.github.butvinmitmo.divinationservice.entity.Interpretation -import com.github.butvinmitmo.divinationservice.entity.Spread -import com.github.butvinmitmo.divinationservice.entity.SpreadCard -import com.github.butvinmitmo.divinationservice.exception.ConflictException -import com.github.butvinmitmo.divinationservice.exception.ForbiddenException -import com.github.butvinmitmo.divinationservice.exception.NotFoundException -import com.github.butvinmitmo.divinationservice.mapper.InterpretationMapper -import com.github.butvinmitmo.divinationservice.mapper.SpreadMapper -import com.github.butvinmitmo.divinationservice.repository.InterpretationRepository -import com.github.butvinmitmo.divinationservice.repository.SpreadCardRepository -import com.github.butvinmitmo.divinationservice.repository.SpreadRepository -import com.github.butvinmitmo.divinationservice.security.AuthorizationService -import com.github.butvinmitmo.shared.client.TarotServiceClient -import com.github.butvinmitmo.shared.client.UserServiceClient -import com.github.butvinmitmo.shared.dto.CardDto -import com.github.butvinmitmo.shared.dto.CreateInterpretationRequest -import com.github.butvinmitmo.shared.dto.CreateInterpretationResponse -import com.github.butvinmitmo.shared.dto.CreateSpreadRequest -import com.github.butvinmitmo.shared.dto.CreateSpreadResponse -import com.github.butvinmitmo.shared.dto.InterpretationDto -import com.github.butvinmitmo.shared.dto.PageResponse -import com.github.butvinmitmo.shared.dto.ScrollResponse -import com.github.butvinmitmo.shared.dto.SpreadDto -import com.github.butvinmitmo.shared.dto.SpreadSummaryDto -import com.github.butvinmitmo.shared.dto.UpdateInterpretationRequest -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers -import java.util.UUID -import kotlin.random.Random - -@Service -class DivinationService( - private val spreadRepository: SpreadRepository, - private val spreadCardRepository: SpreadCardRepository, - private val interpretationRepository: InterpretationRepository, - private val userServiceClient: UserServiceClient, - private val tarotServiceClient: TarotServiceClient, - private val spreadMapper: SpreadMapper, - private val interpretationMapper: InterpretationMapper, - private val authorizationService: AuthorizationService, -) { - @Transactional - fun createSpread(request: CreateSpreadRequest): Mono = - authorizationService.getCurrentUserId().flatMap { authorId -> - authorizationService.getCurrentRole().flatMap { role -> - // Validate user exists via Feign (blocking call on boundedElastic) - Mono - .fromCallable { userServiceClient.getUserById(authorId, role, authorId) } - .subscribeOn(Schedulers.boundedElastic()) - .flatMap { - // Validate layout type exists via Feign (blocking call on boundedElastic) - Mono - .fromCallable { - tarotServiceClient - .getLayoutTypeById( - authorId, - role, - request.layoutTypeId, - ).body!! - }.subscribeOn(Schedulers.boundedElastic()) - }.flatMap { layoutType -> - val spread = - Spread( - question = request.question, - authorId = authorId, - layoutTypeId = request.layoutTypeId, - ) - spreadRepository - .save(spread) - .flatMap { savedSpread -> - // Get random cards via Feign - Mono - .fromCallable { - tarotServiceClient - .getRandomCards( - authorId, - role, - layoutType.cardsCount, - ).body!! - }.subscribeOn(Schedulers.boundedElastic()) - .flatMapMany { cards -> - // Save all spread cards reactively - Flux.fromIterable( - cards.mapIndexed { index, card -> - SpreadCard( - spreadId = savedSpread.id!!, - cardId = card.id, - positionInSpread = index + 1, - isReversed = Random.nextBoolean(), - ) - }, - ) - }.flatMap { spreadCard -> - spreadCardRepository.save(spreadCard) - }.then(Mono.just(CreateSpreadResponse(id = savedSpread.id!!))) - } - } - } - } - - fun getSpreads( - page: Int, - size: Int, - ): Mono> { - val offset = page.toLong() * size - return spreadRepository - .count() - .flatMap { totalElements -> - spreadRepository - .findAllOrderByCreatedAtDesc(offset, size) - .collectList() - .flatMap { spreads -> - val spreadIds = spreads.map { it.id!! } - getInterpretationCounts(spreadIds) - .map { interpretationCounts -> - val totalPages = (totalElements + size - 1) / size - PageResponse( - content = - spreads.map { spread -> - spreadMapper.toSummaryDto(spread, interpretationCounts[spread.id] ?: 0) - }, - page = page, - size = size, - totalElements = totalElements, - totalPages = totalPages.toInt(), - isFirst = page == 0, - isLast = page >= totalPages - 1, - ) - } - } - } - } - - @Transactional(readOnly = true) - fun getSpreadsByScroll( - after: UUID?, - size: Int, - ): Mono> { - val spreadsFlux = - if (after != null) { - spreadRepository.findSpreadsAfterCursor(after, size + 1) - } else { - spreadRepository.findLatestSpreads(size + 1) - } - - return spreadsFlux - .collectList() - .flatMap { spreads -> - val hasMore = spreads.size > size - val itemsToReturn = if (hasMore) spreads.take(size) else spreads - val nextCursor = if (hasMore) itemsToReturn.last().id else null - - val spreadIds = itemsToReturn.map { it.id!! } - getInterpretationCounts(spreadIds) - .map { interpretationCounts -> - ScrollResponse( - items = - itemsToReturn.map { spread -> - spreadMapper.toSummaryDto(spread, interpretationCounts[spread.id] ?: 0) - }, - nextCursor = nextCursor, - ) - } - } - } - - private fun getInterpretationCounts(spreadIds: List): Mono> { - if (spreadIds.isEmpty()) return Mono.just(emptyMap()) - return interpretationRepository - .countBySpreadIds(spreadIds) - .collectMap({ it.first }, { it.second.toInt() }) - } - - @Transactional - fun getSpread(id: UUID): Mono = - spreadRepository - .findByIdWithCards(id) - .switchIfEmpty(Mono.error(NotFoundException("Spread not found"))) - .flatMap { spread -> - // Load spread cards separately (R2DBC doesn't support @OneToMany) - val spreadCardsFlux = spreadCardRepository.findBySpreadId(spread.id!!) - // Load interpretations (limit 50) - val interpretationsFlux = - interpretationRepository - .findBySpreadIdOrderByCreatedAtDesc(spread.id!!, 0, 50) - - Mono - .zip(spreadCardsFlux.collectList(), interpretationsFlux.collectList()) - .flatMap { tuple -> - val spreadCards = tuple.t1 - val interpretations = tuple.t2 - - // Build card cache - val cardIds = spreadCards.map { it.cardId }.toSet() - buildCardCache(cardIds) - .map { cardCache -> - spreadMapper.toDto(spread, spreadCards, interpretations, cardCache) - } - } - } - - @Transactional - fun deleteSpread(id: UUID): Mono = - spreadRepository - .findById(id) - .switchIfEmpty(Mono.error(NotFoundException("Spread not found"))) - .flatMap { spread -> - authorizationService.canModify(spread.authorId).flatMap { canModify -> - if (!canModify) { - Mono.error(ForbiddenException("You can only delete your own spreads")) - } else { - spreadRepository.deleteById(id) - } - } - } - - fun getSpreadEntity(id: UUID): Mono = - spreadRepository - .findById(id) - .switchIfEmpty(Mono.error(NotFoundException("Spread not found"))) - - @Transactional - fun addInterpretation( - spreadId: UUID, - request: CreateInterpretationRequest, - ): Mono = - authorizationService.getCurrentUserId().flatMap { authorId -> - authorizationService.getCurrentRole().flatMap { role -> - getSpreadEntity(spreadId) - .flatMap { - // Validate user exists via Feign (blocking call on boundedElastic) - Mono - .fromCallable { userServiceClient.getUserById(authorId, role, authorId) } - .subscribeOn(Schedulers.boundedElastic()) - }.flatMap { - interpretationRepository.existsByAuthorAndSpread(authorId, spreadId) - }.flatMap { exists -> - if (exists) { - Mono.error(ConflictException("You already have an interpretation for this spread")) - } else { - val interpretation = - Interpretation( - text = request.text, - authorId = authorId, - spreadId = spreadId, - ) - interpretationRepository - .save(interpretation) - .map { saved -> CreateInterpretationResponse(id = saved.id!!) } - } - } - } - } - - @Transactional - fun updateInterpretation( - spreadId: UUID, - id: UUID, - request: UpdateInterpretationRequest, - ): Mono = - interpretationRepository - .findById(id) - .switchIfEmpty(Mono.error(NotFoundException("Interpretation not found"))) - .flatMap { interpretation -> - authorizationService.canModify(interpretation.authorId).flatMap { canModify -> - if (!canModify) { - Mono.error(ForbiddenException("You can only edit your own interpretations")) - } else { - interpretation.text = request.text - interpretationRepository - .save(interpretation) - .map { saved -> interpretationMapper.toDto(saved) } - } - } - } - - @Transactional - fun deleteInterpretation( - spreadId: UUID, - id: UUID, - ): Mono = - interpretationRepository - .findById(id) - .switchIfEmpty(Mono.error(NotFoundException("Interpretation not found"))) - .flatMap { interpretation -> - authorizationService.canModify(interpretation.authorId).flatMap { canModify -> - if (!canModify) { - Mono.error(ForbiddenException("You can only delete your own interpretations")) - } else { - interpretationRepository.deleteById(id) - } - } - } - - @Transactional(readOnly = true) - fun getInterpretation( - spreadId: UUID, - id: UUID, - ): Mono = - interpretationRepository - .findById(id) - .switchIfEmpty(Mono.error(NotFoundException("Interpretation not found"))) - .flatMap { interpretation -> - if (interpretation.spreadId != spreadId) { - Mono.error(NotFoundException("Interpretation not found in this spread")) - } else { - Mono.just(interpretationMapper.toDto(interpretation)) - } - } - - @Transactional(readOnly = true) - fun getInterpretations( - spreadId: UUID, - page: Int, - size: Int, - ): Mono> = - getSpreadEntity(spreadId) - .flatMap { - val offset = page.toLong() * size - val interpretationsFlux = - interpretationRepository.findBySpreadIdOrderByCreatedAtDesc( - spreadId, - offset, - size, - ) - val countMono = interpretationRepository.countBySpreadId(spreadId) - - Mono - .zip(interpretationsFlux.collectList(), countMono) - .map { tuple -> - val interpretations = tuple.t1 - val totalElements = tuple.t2 - val totalPages = (totalElements + size - 1) / size - - PageResponse( - content = interpretations.map { interpretationMapper.toDto(it) }, - page = page, - size = size, - totalElements = totalElements, - totalPages = totalPages.toInt(), - isFirst = page == 0, - isLast = page >= totalPages - 1, - ) - } - } - - private fun buildCardCache(cardIds: Set): Mono> { - if (cardIds.isEmpty()) return Mono.just(emptyMap()) - val systemUserId = UUID.fromString("00000000-0000-0000-0000-000000000000") - return Mono - .fromCallable { - val allCards = mutableListOf() - val pageSize = 50 - var page = 0 - var fetched: List - do { - fetched = tarotServiceClient.getCards(systemUserId, "SYSTEM", page, pageSize).body!! - allCards.addAll(fetched) - page++ - } while (fetched.size == pageSize) - allCards.filter { it.id in cardIds }.associateBy { it.id } - }.subscribeOn(Schedulers.boundedElastic()) - } - - /** - * Deletes all data associated with a user (spreads and interpretations). - * Called by user-service before deleting a user to ensure cascade cleanup. - * Spread cards are automatically deleted via database cascade (internal FK). - * - * @param userId The ID of the user whose data should be deleted - * @return Mono that completes when deletion is done - */ - @Transactional - fun deleteUserData(userId: UUID): Mono = - // First delete all interpretations by this user - interpretationRepository - .deleteByAuthorId(userId) - // Then delete all spreads by this user (spread_cards cascade via internal FK) - .then(spreadRepository.deleteByAuthorId(userId)) - .then() -} diff --git a/divination-service/src/main/resources/db/migration/V5__create_interpretation_attachment_table.sql b/divination-service/src/main/resources/db/migration/V5__create_interpretation_attachment_table.sql new file mode 100644 index 0000000..9bee25a --- /dev/null +++ b/divination-service/src/main/resources/db/migration/V5__create_interpretation_attachment_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS interpretation_attachment ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + interpretation_id UUID NOT NULL UNIQUE, + file_upload_id UUID NOT NULL, + original_file_name VARCHAR(256) NOT NULL, + content_type VARCHAR(128) NOT NULL, + file_size BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_attachment_interpretation FOREIGN KEY (interpretation_id) + REFERENCES interpretation(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_attachment_interpretation_id ON interpretation_attachment(interpretation_id); +CREATE INDEX IF NOT EXISTS idx_attachment_file_upload_id ON interpretation_attachment(file_upload_id); diff --git a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/TestEntityFactory.kt b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/TestEntityFactory.kt index 946830f..223eb33 100644 --- a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/TestEntityFactory.kt +++ b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/TestEntityFactory.kt @@ -1,8 +1,12 @@ package com.github.butvinmitmo.divinationservice -import com.github.butvinmitmo.divinationservice.entity.Interpretation -import com.github.butvinmitmo.divinationservice.entity.Spread -import com.github.butvinmitmo.divinationservice.entity.SpreadCard +import com.github.butvinmitmo.divinationservice.domain.model.Interpretation +import com.github.butvinmitmo.divinationservice.domain.model.InterpretationAttachment +import com.github.butvinmitmo.divinationservice.domain.model.Spread +import com.github.butvinmitmo.divinationservice.domain.model.SpreadCard +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.InterpretationEntity +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.SpreadCardEntity +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.entity.SpreadEntity import java.time.Instant import java.util.UUID @@ -22,6 +26,21 @@ object TestEntityFactory { createdAt = createdAt, ) + fun createSpreadEntity( + id: UUID? = UUID.randomUUID(), + question: String? = "Test question", + layoutTypeId: UUID = UUID.randomUUID(), + authorId: UUID = UUID.randomUUID(), + createdAt: Instant? = Instant.now(), + ): SpreadEntity = + SpreadEntity( + id = id, + question = question, + layoutTypeId = layoutTypeId, + authorId = authorId, + createdAt = createdAt, + ) + fun createSpreadCard( id: UUID = UUID.randomUUID(), spreadId: UUID, @@ -37,6 +56,21 @@ object TestEntityFactory { isReversed = isReversed, ) + fun createSpreadCardEntity( + id: UUID? = UUID.randomUUID(), + spreadId: UUID, + cardId: UUID = UUID.randomUUID(), + positionInSpread: Int = 1, + isReversed: Boolean = false, + ): SpreadCardEntity = + SpreadCardEntity( + id = id, + spreadId = spreadId, + cardId = cardId, + positionInSpread = positionInSpread, + isReversed = isReversed, + ) + fun createInterpretation( id: UUID = UUID.randomUUID(), text: String = "Test interpretation", @@ -51,4 +85,38 @@ object TestEntityFactory { spreadId = spreadId, createdAt = createdAt, ) + + fun createInterpretationEntity( + id: UUID? = UUID.randomUUID(), + text: String = "Test interpretation", + authorId: UUID = UUID.randomUUID(), + spreadId: UUID, + createdAt: Instant? = Instant.now(), + ): InterpretationEntity = + InterpretationEntity( + id = id, + text = text, + authorId = authorId, + spreadId = spreadId, + createdAt = createdAt, + ) + + fun createInterpretationAttachment( + id: UUID = UUID.randomUUID(), + interpretationId: UUID, + fileUploadId: UUID = UUID.randomUUID(), + originalFileName: String = "test.jpg", + contentType: String = "image/jpeg", + fileSize: Long = 12345L, + createdAt: Instant = Instant.now(), + ): InterpretationAttachment = + InterpretationAttachment( + id = id, + interpretationId = interpretationId, + fileUploadId = fileUploadId, + originalFileName = originalFileName, + contentType = contentType, + fileSize = fileSize, + createdAt = createdAt, + ) } diff --git a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/BaseIntegrationTest.kt b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/BaseIntegrationTest.kt index f68412e..871b32d 100644 --- a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/BaseIntegrationTest.kt +++ b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/BaseIntegrationTest.kt @@ -1,13 +1,19 @@ package com.github.butvinmitmo.divinationservice.integration -import com.github.butvinmitmo.divinationservice.repository.InterpretationRepository -import com.github.butvinmitmo.divinationservice.repository.SpreadCardRepository -import com.github.butvinmitmo.divinationservice.repository.SpreadRepository +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.InterpretationEventPublisher +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.SpreadEventPublisher +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataInterpretationRepository +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataSpreadCardRepository +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataSpreadRepository import com.github.tomakehurst.wiremock.junit5.WireMockExtension import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource @@ -24,13 +30,28 @@ abstract class BaseIntegrationTest { protected lateinit var webTestClient: WebTestClient @Autowired - protected lateinit var spreadRepository: SpreadRepository + protected lateinit var spreadRepository: SpringDataSpreadRepository @Autowired - protected lateinit var spreadCardRepository: SpreadCardRepository + protected lateinit var spreadCardRepository: SpringDataSpreadCardRepository @Autowired - protected lateinit var interpretationRepository: InterpretationRepository + protected lateinit var interpretationRepository: SpringDataInterpretationRepository + + @MockBean + protected lateinit var spreadEventPublisher: SpreadEventPublisher + + @MockBean + protected lateinit var interpretationEventPublisher: InterpretationEventPublisher + + @BeforeEach + fun setupPublisherMocks() { + whenever(spreadEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + whenever(spreadEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) + whenever(interpretationEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + whenever(interpretationEventPublisher.publishUpdated(any())).thenReturn(Mono.empty()) + whenever(interpretationEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) + } @AfterEach fun cleanupDatabase() { diff --git a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/BaseControllerIntegrationTest.kt b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/BaseControllerIntegrationTest.kt index a1877d6..49cf4cb 100644 --- a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/BaseControllerIntegrationTest.kt +++ b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/BaseControllerIntegrationTest.kt @@ -1,15 +1,21 @@ package com.github.butvinmitmo.divinationservice.integration.controller import com.fasterxml.jackson.databind.ObjectMapper +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.FileProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.InterpretationEventPublisher +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.SpreadEventPublisher import com.github.butvinmitmo.divinationservice.config.TestFeignConfiguration -import com.github.butvinmitmo.divinationservice.repository.InterpretationRepository -import com.github.butvinmitmo.divinationservice.repository.SpreadCardRepository -import com.github.butvinmitmo.divinationservice.repository.SpreadRepository +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataInterpretationAttachmentRepository +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataInterpretationRepository +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataSpreadCardRepository +import com.github.butvinmitmo.divinationservice.infrastructure.persistence.repository.SpringDataSpreadRepository import com.github.butvinmitmo.shared.client.TarotServiceClient import com.github.butvinmitmo.shared.client.UserServiceClient import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Import @@ -34,13 +40,16 @@ abstract class BaseControllerIntegrationTest { protected lateinit var objectMapper: ObjectMapper @Autowired - protected lateinit var spreadRepository: SpreadRepository + protected lateinit var spreadRepository: SpringDataSpreadRepository @Autowired - protected lateinit var spreadCardRepository: SpreadCardRepository + protected lateinit var spreadCardRepository: SpringDataSpreadCardRepository @Autowired - protected lateinit var interpretationRepository: InterpretationRepository + protected lateinit var interpretationRepository: SpringDataInterpretationRepository + + @Autowired + protected lateinit var interpretationAttachmentRepository: SpringDataInterpretationAttachmentRepository @org.springframework.boot.test.mock.mockito.MockBean protected lateinit var userServiceClient: UserServiceClient @@ -48,24 +57,39 @@ abstract class BaseControllerIntegrationTest { @org.springframework.boot.test.mock.mockito.MockBean protected lateinit var tarotServiceClient: TarotServiceClient + @org.springframework.boot.test.mock.mockito.MockBean + protected lateinit var spreadEventPublisher: SpreadEventPublisher + + @org.springframework.boot.test.mock.mockito.MockBean + protected lateinit var interpretationEventPublisher: InterpretationEventPublisher + + @org.springframework.boot.test.mock.mockito.MockBean + protected lateinit var fileProvider: FileProvider + protected val testUserId: UUID = UUID.fromString("00000000-0000-0000-0000-000000000001") protected val oneCardLayoutId: UUID = UUID.fromString("00000000-0000-0000-0000-000000000020") protected val threeCardsLayoutId: UUID = UUID.fromString("00000000-0000-0000-0000-000000000021") protected val crossLayoutId: UUID = UUID.fromString("00000000-0000-0000-0000-000000000022") - // System context used by mappers for internal Feign calls + // System context used by providers for internal Feign calls protected val systemUserId: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000") protected val systemRole: String = "SYSTEM" @BeforeEach fun resetMocks() { Mockito.reset(userServiceClient, tarotServiceClient) + whenever(spreadEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + whenever(spreadEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) + whenever(interpretationEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + whenever(interpretationEventPublisher.publishUpdated(any())).thenReturn(Mono.empty()) + whenever(interpretationEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) } @AfterEach fun cleanupDatabase() { Mono .`when`( + interpretationAttachmentRepository.deleteAll(), interpretationRepository.deleteAll(), spreadCardRepository.deleteAll(), spreadRepository.deleteAll(), diff --git a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/InternalControllerIntegrationTest.kt b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/InternalControllerIntegrationTest.kt deleted file mode 100644 index 5c17ff6..0000000 --- a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/InternalControllerIntegrationTest.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.github.butvinmitmo.divinationservice.integration.controller - -import com.github.butvinmitmo.divinationservice.entity.Interpretation -import com.github.butvinmitmo.divinationservice.entity.Spread -import com.github.butvinmitmo.divinationservice.entity.SpreadCard -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import java.util.UUID - -class InternalControllerIntegrationTest : BaseControllerIntegrationTest() { - @Test - fun `deleteUserData should delete all spreads and interpretations for a user`() { - // Given: a user with spreads and interpretations - val userId = UUID.randomUUID() - val otherUserId = UUID.randomUUID() - - // Create spread for user - val spread1 = - spreadRepository - .save( - Spread( - question = "Test question", - authorId = userId, - layoutTypeId = oneCardLayoutId, - ), - ).block()!! - - // Create spread cards for spread1 - spreadCardRepository - .save( - SpreadCard( - spreadId = spread1.id!!, - cardId = UUID.fromString("00000000-0000-0000-0000-000000000030"), - positionInSpread = 1, - isReversed = false, - ), - ).block() - - // Create another spread for same user - val spread2 = - spreadRepository - .save( - Spread( - question = "Another question", - authorId = userId, - layoutTypeId = oneCardLayoutId, - ), - ).block()!! - - // Create spread for other user - val otherUserSpread = - spreadRepository - .save( - Spread( - question = "Other user question", - authorId = otherUserId, - layoutTypeId = oneCardLayoutId, - ), - ).block()!! - - // Create interpretation by userId on spread1 - interpretationRepository - .save( - Interpretation( - text = "User interpretation", - authorId = userId, - spreadId = spread1.id!!, - ), - ).block() - - // Create interpretation by otherUser on spread1 - interpretationRepository - .save( - Interpretation( - text = "Other user interpretation on spread1", - authorId = otherUserId, - spreadId = spread1.id!!, - ), - ).block() - - // Create interpretation by userId on otherUser's spread - interpretationRepository - .save( - Interpretation( - text = "User interpretation on other spread", - authorId = userId, - spreadId = otherUserSpread.id!!, - ), - ).block() - - // Create interpretation by otherUser on their own spread (this should remain) - interpretationRepository - .save( - Interpretation( - text = "Other user interpretation on their own spread", - authorId = otherUserId, - spreadId = otherUserSpread.id!!, - ), - ).block() - - // Verify initial state - assertEquals(3, spreadRepository.count().block()) - assertEquals( - 4, - interpretationRepository - .findAll() - .collectList() - .block() - ?.size, - ) - - // When: delete user data - webTestClient - .delete() - .uri("/internal/users/$userId/data") - .exchange() - .expectStatus() - .isNoContent - - // Then: user's spreads are deleted (cascade deletes spread_cards) - val remainingSpreads = spreadRepository.findAll().collectList().block()!! - assertEquals(1, remainingSpreads.size) - assertEquals(otherUserId, remainingSpreads[0].authorId) - - // And: user's interpretations are deleted, but other user's interpretation on other spread remains - val remainingInterpretations = interpretationRepository.findAll().collectList().block()!! - assertEquals(1, remainingInterpretations.size) - assertEquals(otherUserId, remainingInterpretations[0].authorId) - } - - @Test - fun `deleteUserData should return 204 even if user has no data`() { - // Given: a user with no spreads or interpretations - val userId = UUID.randomUUID() - - // When: delete user data - webTestClient - .delete() - .uri("/internal/users/$userId/data") - .exchange() - .expectStatus() - .isNoContent - - // Then: no error occurred - assertTrue(true) - } - - @Test - fun `deleteUserData should cascade delete spread cards when spread is deleted`() { - // Given: a user with a spread and spread cards - val userId = UUID.randomUUID() - - val spread = - spreadRepository - .save( - Spread( - question = "Test question", - authorId = userId, - layoutTypeId = threeCardsLayoutId, - ), - ).block()!! - - // Create 3 spread cards - repeat(3) { index -> - spreadCardRepository - .save( - SpreadCard( - spreadId = spread.id!!, - cardId = UUID.fromString("00000000-0000-0000-0000-00000000003$index"), - positionInSpread = index + 1, - isReversed = false, - ), - ).block() - } - - assertEquals( - 3, - spreadCardRepository - .findBySpreadId(spread.id!!) - .collectList() - .block() - ?.size, - ) - - // When: delete user data - webTestClient - .delete() - .uri("/internal/users/$userId/data") - .exchange() - .expectStatus() - .isNoContent - - // Then: spread and all spread cards are deleted - assertEquals(0, spreadRepository.count().block()) - assertEquals(0, spreadCardRepository.count().block()) - } -} diff --git a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/InterpretationControllerIntegrationTest.kt b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/InterpretationControllerIntegrationTest.kt index 726feca..78e34c8 100644 --- a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/InterpretationControllerIntegrationTest.kt +++ b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/integration/controller/InterpretationControllerIntegrationTest.kt @@ -4,6 +4,7 @@ import com.github.butvinmitmo.shared.dto.ArcanaTypeDto import com.github.butvinmitmo.shared.dto.CardDto import com.github.butvinmitmo.shared.dto.CreateInterpretationRequest import com.github.butvinmitmo.shared.dto.CreateSpreadRequest +import com.github.butvinmitmo.shared.dto.FileUploadMetadataDto import com.github.butvinmitmo.shared.dto.LayoutTypeDto import com.github.butvinmitmo.shared.dto.UpdateInterpretationRequest import com.github.butvinmitmo.shared.dto.UserDto @@ -13,6 +14,7 @@ import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ActiveProfiles +import reactor.core.publisher.Mono import java.time.Instant import java.util.UUID @@ -368,4 +370,191 @@ class InterpretationControllerIntegrationTest : BaseControllerIntegrationTest() .expectStatus() .isForbidden } + + @Test + fun `addInterpretation with uploadId should create interpretation with attachment`() { + val spreadId = createSpread("MEDIUM") + val uploadId = UUID.randomUUID() + val request = CreateInterpretationRequest(text = "Test interpretation", uploadId = uploadId) + + // Mock file provider for upload verification + val fileMetadata = + FileUploadMetadataDto( + uploadId = uploadId, + filePath = "interpretation-attachments/$uploadId/test.jpg", + originalFileName = "test.jpg", + contentType = "image/jpeg", + fileSize = 12345L, + completedAt = Instant.now(), + ) + `when`(fileProvider.verifyAndCompleteUpload(uploadId, testUserId)).thenReturn(Mono.just(fileMetadata)) + `when`( + fileProvider.getDownloadUrl(uploadId), + ).thenReturn(Mono.just("https://minio.local/test.jpg?signature=xyz")) + + webTestClient + .post() + .uri("/api/v0.0.1/spreads/$spreadId/interpretations") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "MEDIUM") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isCreated + .expectBody() + .jsonPath("$.id") + .exists() + } + + @Test + fun `getInterpretation should include attachment with download url`() { + val spreadId = createSpread("MEDIUM") + val uploadId = UUID.randomUUID() + val request = CreateInterpretationRequest(text = "Test interpretation", uploadId = uploadId) + + // Mock file provider for upload verification + val fileMetadata = + FileUploadMetadataDto( + uploadId = uploadId, + filePath = "interpretation-attachments/$uploadId/test.jpg", + originalFileName = "test.jpg", + contentType = "image/jpeg", + fileSize = 12345L, + completedAt = Instant.now(), + ) + `when`(fileProvider.verifyAndCompleteUpload(uploadId, testUserId)).thenReturn(Mono.just(fileMetadata)) + `when`( + fileProvider.getDownloadUrl(uploadId), + ).thenReturn(Mono.just("https://minio.local/test.jpg?signature=xyz")) + + val interpretationId = + webTestClient + .post() + .uri("/api/v0.0.1/spreads/$spreadId/interpretations") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "MEDIUM") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isCreated + .expectBody() + .returnResult() + .responseBody + ?.let { body -> + objectMapper.readTree(body).get("id").asText() + }!! + + webTestClient + .get() + .uri("/api/v0.0.1/spreads/$spreadId/interpretations/$interpretationId") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.id") + .isEqualTo(interpretationId) + .jsonPath("$.attachment.originalFileName") + .isEqualTo("test.jpg") + .jsonPath("$.attachment.contentType") + .isEqualTo("image/jpeg") + .jsonPath("$.attachment.fileSize") + .isEqualTo(12345) + .jsonPath("$.attachment.downloadUrl") + .isEqualTo("https://minio.local/test.jpg?signature=xyz") + } + + @Test + fun `addInterpretation without uploadId should create interpretation without attachment`() { + val spreadId = createSpread("MEDIUM") + val request = CreateInterpretationRequest(text = "Test interpretation without attachment") + + val interpretationId = + webTestClient + .post() + .uri("/api/v0.0.1/spreads/$spreadId/interpretations") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "MEDIUM") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isCreated + .expectBody() + .returnResult() + .responseBody + ?.let { body -> + objectMapper.readTree(body).get("id").asText() + }!! + + webTestClient + .get() + .uri("/api/v0.0.1/spreads/$spreadId/interpretations/$interpretationId") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.id") + .isEqualTo(interpretationId) + .jsonPath("$.attachment") + .doesNotExist() + } + + @Test + fun `getSpread should include interpretations with attachments`() { + val spreadId = createSpread("MEDIUM") + val uploadId = UUID.randomUUID() + val request = CreateInterpretationRequest(text = "Test interpretation", uploadId = uploadId) + + // Mock file provider + val fileMetadata = + FileUploadMetadataDto( + uploadId = uploadId, + filePath = "interpretation-attachments/$uploadId/test.png", + originalFileName = "test.png", + contentType = "image/png", + fileSize = 54321L, + completedAt = Instant.now(), + ) + `when`(fileProvider.verifyAndCompleteUpload(uploadId, testUserId)).thenReturn(Mono.just(fileMetadata)) + `when`(fileProvider.getDownloadUrl(uploadId)).thenReturn(Mono.just("https://minio.local/test.png?sig=abc")) + + // Mock cards lookup for getSpread (system context) + val arcanaType = ArcanaTypeDto(id = UUID.fromString("00000000-0000-0000-0000-000000000010"), name = "MAJOR") + val cards = + listOf( + CardDto( + id = UUID.fromString("00000000-0000-0000-0000-000000000030"), + name = "The Fool", + arcanaType = arcanaType, + ), + ) + `when`(tarotServiceClient.getCards(systemUserId, systemRole, 0, 50)).thenReturn(ResponseEntity.ok(cards)) + + webTestClient + .post() + .uri("/api/v0.0.1/spreads/$spreadId/interpretations") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "MEDIUM") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isCreated + + webTestClient + .get() + .uri("/api/v0.0.1/spreads/$spreadId") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.interpretations[0].attachment.originalFileName") + .isEqualTo("test.png") + .jsonPath("$.interpretations[0].attachment.contentType") + .isEqualTo("image/png") + .jsonPath("$.interpretations[0].attachment.downloadUrl") + .exists() + } } diff --git a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/unit/messaging/UserEventConsumerTest.kt b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/unit/messaging/UserEventConsumerTest.kt new file mode 100644 index 0000000..0f521c7 --- /dev/null +++ b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/unit/messaging/UserEventConsumerTest.kt @@ -0,0 +1,82 @@ +package com.github.butvinmitmo.divinationservice.unit.messaging + +import com.github.butvinmitmo.divinationservice.application.service.DivinationService +import com.github.butvinmitmo.divinationservice.infrastructure.messaging.UserEventConsumer +import com.github.butvinmitmo.shared.dto.events.EventType +import com.github.butvinmitmo.shared.dto.events.UserEventData +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import reactor.core.publisher.Mono +import java.time.Instant +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class UserEventConsumerTest { + @Mock + private lateinit var divinationService: DivinationService + + private lateinit var userEventConsumer: UserEventConsumer + + private val userId = UUID.randomUUID() + private val testUserEvent = + UserEventData( + id = userId, + username = "testuser", + role = "USER", + createdAt = Instant.now(), + ) + + @BeforeEach + fun setup() { + userEventConsumer = UserEventConsumer(divinationService) + } + + @Test + fun `onUserEvent should call deleteUserData for DELETED event`() { + whenever(divinationService.deleteUserData(userId)).thenReturn(Mono.empty()) + + userEventConsumer.onUserEvent( + event = testUserEvent, + eventTypeBytes = EventType.DELETED.name.toByteArray(), + ) + + verify(divinationService).deleteUserData(userId) + } + + @Test + fun `onUserEvent should ignore CREATED event`() { + userEventConsumer.onUserEvent( + event = testUserEvent, + eventTypeBytes = EventType.CREATED.name.toByteArray(), + ) + + verify(divinationService, never()).deleteUserData(any()) + } + + @Test + fun `onUserEvent should ignore UPDATED event`() { + userEventConsumer.onUserEvent( + event = testUserEvent, + eventTypeBytes = EventType.UPDATED.name.toByteArray(), + ) + + verify(divinationService, never()).deleteUserData(any()) + } + + @Test + fun `onUserEvent should handle unknown event type gracefully`() { + userEventConsumer.onUserEvent( + event = testUserEvent, + eventTypeBytes = "UNKNOWN_EVENT".toByteArray(), + ) + + verify(divinationService, never()).deleteUserData(any()) + } +} diff --git a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/unit/service/DivinationServiceTest.kt b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/unit/service/DivinationServiceTest.kt index 03a3152..40fe806 100644 --- a/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/unit/service/DivinationServiceTest.kt +++ b/divination-service/src/test/kotlin/com/github/butvinmitmo/divinationservice/unit/service/DivinationServiceTest.kt @@ -1,25 +1,24 @@ package com.github.butvinmitmo.divinationservice.unit.service import com.github.butvinmitmo.divinationservice.TestEntityFactory +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.CardProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.CurrentUserProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.FileProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.provider.UserProvider +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.InterpretationEventPublisher +import com.github.butvinmitmo.divinationservice.application.interfaces.publisher.SpreadEventPublisher +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.InterpretationAttachmentRepository +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.InterpretationRepository +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.SpreadCardRepository +import com.github.butvinmitmo.divinationservice.application.interfaces.repository.SpreadRepository +import com.github.butvinmitmo.divinationservice.application.service.DivinationService import com.github.butvinmitmo.divinationservice.exception.ConflictException import com.github.butvinmitmo.divinationservice.exception.ForbiddenException import com.github.butvinmitmo.divinationservice.exception.NotFoundException -import com.github.butvinmitmo.divinationservice.mapper.InterpretationMapper -import com.github.butvinmitmo.divinationservice.mapper.SpreadMapper -import com.github.butvinmitmo.divinationservice.repository.InterpretationRepository -import com.github.butvinmitmo.divinationservice.repository.SpreadCardRepository -import com.github.butvinmitmo.divinationservice.repository.SpreadRepository -import com.github.butvinmitmo.divinationservice.security.AuthorizationService -import com.github.butvinmitmo.divinationservice.service.DivinationService -import com.github.butvinmitmo.shared.client.TarotServiceClient -import com.github.butvinmitmo.shared.client.UserServiceClient import com.github.butvinmitmo.shared.dto.ArcanaTypeDto import com.github.butvinmitmo.shared.dto.CardDto -import com.github.butvinmitmo.shared.dto.CreateInterpretationRequest -import com.github.butvinmitmo.shared.dto.CreateSpreadRequest -import com.github.butvinmitmo.shared.dto.InterpretationDto +import com.github.butvinmitmo.shared.dto.FileUploadMetadataDto import com.github.butvinmitmo.shared.dto.LayoutTypeDto -import com.github.butvinmitmo.shared.dto.UpdateInterpretationRequest import com.github.butvinmitmo.shared.dto.UserDto import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull @@ -50,19 +49,25 @@ class DivinationServiceTest { private lateinit var interpretationRepository: InterpretationRepository @Mock - private lateinit var userServiceClient: UserServiceClient + private lateinit var interpretationAttachmentRepository: InterpretationAttachmentRepository @Mock - private lateinit var tarotServiceClient: TarotServiceClient + private lateinit var userProvider: UserProvider @Mock - private lateinit var spreadMapper: SpreadMapper + private lateinit var cardProvider: CardProvider @Mock - private lateinit var interpretationMapper: InterpretationMapper + private lateinit var currentUserProvider: CurrentUserProvider @Mock - private lateinit var authorizationService: AuthorizationService + private lateinit var fileProvider: FileProvider + + @Mock + private lateinit var spreadEventPublisher: SpreadEventPublisher + + @Mock + private lateinit var interpretationEventPublisher: InterpretationEventPublisher private lateinit var divinationService: DivinationService @@ -89,17 +94,18 @@ class DivinationServiceTest { spreadRepository, spreadCardRepository, interpretationRepository, - userServiceClient, - tarotServiceClient, - spreadMapper, - interpretationMapper, - authorizationService, + interpretationAttachmentRepository, + userProvider, + cardProvider, + currentUserProvider, + fileProvider, + spreadEventPublisher, + interpretationEventPublisher, ) } @Test fun `createSpread should create new spread successfully`() { - val request = CreateSpreadRequest(question = "Test question", layoutTypeId = layoutTypeId) val savedSpread = TestEntityFactory.createSpread( id = spreadId, @@ -109,34 +115,25 @@ class DivinationServiceTest { createdAt = createdAt, ) - whenever(authorizationService.getCurrentUserId()).thenReturn(Mono.just(userId)) - whenever(authorizationService.getCurrentRole()).thenReturn(Mono.just("USER")) - whenever(userServiceClient.getUserById(userId, "USER", userId)).thenReturn( - org.springframework.http.ResponseEntity - .ok(testUser), - ) - whenever( - tarotServiceClient.getLayoutTypeById(userId, "USER", layoutTypeId), - ).thenReturn( - org.springframework.http.ResponseEntity - .ok(testLayoutType), - ) + whenever(currentUserProvider.getCurrentUserId()).thenReturn(Mono.just(userId)) + whenever(currentUserProvider.getCurrentRole()).thenReturn(Mono.just("USER")) + whenever(userProvider.getUserById(userId, "USER", userId)).thenReturn(Mono.just(testUser)) + whenever(cardProvider.getLayoutTypeById(userId, "USER", layoutTypeId)).thenReturn(Mono.just(testLayoutType)) whenever(spreadRepository.save(any())).thenReturn(Mono.just(savedSpread)) - whenever( - spreadCardRepository.save(any()), - ).thenReturn(Mono.just(TestEntityFactory.createSpreadCard(spreadId = spreadId))) - whenever(tarotServiceClient.getRandomCards(userId, "USER", 3)).thenReturn( - org.springframework.http.ResponseEntity - .ok(testCards), + whenever(spreadCardRepository.save(any())).thenReturn( + Mono.just(TestEntityFactory.createSpreadCard(spreadId = spreadId)), ) + whenever(cardProvider.getRandomCards(userId, "USER", 3)).thenReturn(Mono.just(testCards)) + whenever(spreadEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) - val result = divinationService.createSpread(request).block() + val result = divinationService.createSpread("Test question", layoutTypeId).block() assertNotNull(result) assertEquals(spreadId, result!!.id) - verify(userServiceClient).getUserById(userId, "USER", userId) - verify(tarotServiceClient).getLayoutTypeById(userId, "USER", layoutTypeId) + verify(userProvider).getUserById(userId, "USER", userId) + verify(cardProvider).getLayoutTypeById(userId, "USER", layoutTypeId) verify(spreadRepository).save(any()) + verify(spreadEventPublisher).publishCreated(any()) } @Test @@ -150,6 +147,8 @@ class DivinationServiceTest { whenever(spreadRepository.count()).thenReturn(Mono.just(2L)) whenever(spreadRepository.findAllOrderByCreatedAtDesc(0L, 2)).thenReturn(Flux.fromIterable(spreads)) whenever(interpretationRepository.countBySpreadIds(any())).thenReturn(Flux.empty()) + whenever(userProvider.getSystemUser(any())).thenReturn(Mono.just(testUser)) + whenever(cardProvider.getSystemLayoutType(any())).thenReturn(Mono.just(testLayoutType)) val result = divinationService.getSpreads(0, 2).block() @@ -159,7 +158,7 @@ class DivinationServiceTest { @Test fun `getSpread should throw NotFoundException when spread not found`() { - whenever(spreadRepository.findByIdWithCards(spreadId)).thenReturn(Mono.empty()) + whenever(spreadRepository.findById(spreadId)).thenReturn(Mono.empty()) val exception = assertThrows { @@ -173,12 +172,14 @@ class DivinationServiceTest { val spread = TestEntityFactory.createSpread(id = spreadId, layoutTypeId = layoutTypeId, authorId = userId) whenever(spreadRepository.findById(spreadId)).thenReturn(Mono.just(spread)) - whenever(authorizationService.canModify(userId)).thenReturn(Mono.just(true)) + whenever(currentUserProvider.canModify(userId)).thenReturn(Mono.just(true)) whenever(spreadRepository.deleteById(spreadId)).thenReturn(Mono.empty()) + whenever(spreadEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) divinationService.deleteSpread(spreadId).block() verify(spreadRepository).deleteById(spreadId) + verify(spreadEventPublisher).publishDeleted(any()) } @Test @@ -187,7 +188,7 @@ class DivinationServiceTest { val spread = TestEntityFactory.createSpread(id = spreadId, layoutTypeId = layoutTypeId, authorId = otherUserId) whenever(spreadRepository.findById(spreadId)).thenReturn(Mono.just(spread)) - whenever(authorizationService.canModify(otherUserId)).thenReturn(Mono.just(false)) + whenever(currentUserProvider.canModify(otherUserId)).thenReturn(Mono.just(false)) val exception = assertThrows { @@ -214,7 +215,6 @@ class DivinationServiceTest { @Test fun `addInterpretation should create new interpretation successfully`() { val spread = TestEntityFactory.createSpread(id = spreadId, layoutTypeId = layoutTypeId) - val request = CreateInterpretationRequest(text = "Test interpretation") val savedInterpretation = TestEntityFactory.createInterpretation( id = interpretationId, @@ -224,39 +224,34 @@ class DivinationServiceTest { createdAt = createdAt, ) - whenever(authorizationService.getCurrentUserId()).thenReturn(Mono.just(userId)) - whenever(authorizationService.getCurrentRole()).thenReturn(Mono.just("USER")) + whenever(currentUserProvider.getCurrentUserId()).thenReturn(Mono.just(userId)) + whenever(currentUserProvider.getCurrentRole()).thenReturn(Mono.just("USER")) whenever(spreadRepository.findById(spreadId)).thenReturn(Mono.just(spread)) - whenever(userServiceClient.getUserById(userId, "USER", userId)).thenReturn( - org.springframework.http.ResponseEntity - .ok(testUser), - ) + whenever(userProvider.getUserById(userId, "USER", userId)).thenReturn(Mono.just(testUser)) whenever(interpretationRepository.existsByAuthorAndSpread(userId, spreadId)).thenReturn(Mono.just(false)) whenever(interpretationRepository.save(any())).thenReturn(Mono.just(savedInterpretation)) + whenever(interpretationEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) - val result = divinationService.addInterpretation(spreadId, request).block() + val result = divinationService.addInterpretation(spreadId, "Test interpretation").block() assertNotNull(result) assertEquals(interpretationId, result!!.id) + verify(interpretationEventPublisher).publishCreated(any()) } @Test fun `addInterpretation should throw ConflictException when user already has interpretation`() { val spread = TestEntityFactory.createSpread(id = spreadId, layoutTypeId = layoutTypeId) - val request = CreateInterpretationRequest(text = "Test interpretation") - whenever(authorizationService.getCurrentUserId()).thenReturn(Mono.just(userId)) - whenever(authorizationService.getCurrentRole()).thenReturn(Mono.just("USER")) + whenever(currentUserProvider.getCurrentUserId()).thenReturn(Mono.just(userId)) + whenever(currentUserProvider.getCurrentRole()).thenReturn(Mono.just("USER")) whenever(spreadRepository.findById(spreadId)).thenReturn(Mono.just(spread)) - whenever(userServiceClient.getUserById(userId, "USER", userId)).thenReturn( - org.springframework.http.ResponseEntity - .ok(testUser), - ) + whenever(userProvider.getUserById(userId, "USER", userId)).thenReturn(Mono.just(testUser)) whenever(interpretationRepository.existsByAuthorAndSpread(userId, spreadId)).thenReturn(Mono.just(true)) val exception = assertThrows { - divinationService.addInterpretation(spreadId, request).block() + divinationService.addInterpretation(spreadId, "Test interpretation").block() } assertEquals("You already have an interpretation for this spread", exception.message) @@ -273,18 +268,18 @@ class DivinationServiceTest { spreadId = spreadId, createdAt = createdAt, ) - val request = UpdateInterpretationRequest(text = "Updated text") - - val interpretationDto = InterpretationDto(interpretationId, "Updated text", createdAt, testUser, spreadId) whenever(interpretationRepository.findById(interpretationId)).thenReturn(Mono.just(interpretation)) - whenever(authorizationService.canModify(userId)).thenReturn(Mono.just(true)) - whenever(interpretationRepository.save(any())).thenReturn(Mono.just(interpretation)) - whenever(interpretationMapper.toDto(any())).thenReturn(interpretationDto) + whenever(currentUserProvider.canModify(userId)).thenReturn(Mono.just(true)) + whenever(interpretationRepository.save(any())).thenReturn(Mono.just(interpretation.copy(text = "Updated text"))) + whenever(interpretationEventPublisher.publishUpdated(any())).thenReturn(Mono.empty()) + whenever(userProvider.getSystemUser(userId)).thenReturn(Mono.just(testUser)) + whenever(interpretationAttachmentRepository.findByInterpretationId(interpretationId)).thenReturn(Mono.empty()) - divinationService.updateInterpretation(spreadId, interpretationId, request).block() + divinationService.updateInterpretation(spreadId, interpretationId, "Updated text").block() verify(interpretationRepository).save(any()) + verify(interpretationEventPublisher).publishUpdated(any()) } @Test @@ -298,14 +293,13 @@ class DivinationServiceTest { spreadId = spreadId, createdAt = createdAt, ) - val request = UpdateInterpretationRequest(text = "Updated text") whenever(interpretationRepository.findById(interpretationId)).thenReturn(Mono.just(interpretation)) - whenever(authorizationService.canModify(otherUserId)).thenReturn(Mono.just(false)) + whenever(currentUserProvider.canModify(otherUserId)).thenReturn(Mono.just(false)) val exception = assertThrows { - divinationService.updateInterpretation(spreadId, interpretationId, request).block() + divinationService.updateInterpretation(spreadId, interpretationId, "Updated text").block() } assertEquals("You can only edit your own interpretations", exception.message) @@ -324,12 +318,14 @@ class DivinationServiceTest { ) whenever(interpretationRepository.findById(interpretationId)).thenReturn(Mono.just(interpretation)) - whenever(authorizationService.canModify(userId)).thenReturn(Mono.just(true)) + whenever(currentUserProvider.canModify(userId)).thenReturn(Mono.just(true)) whenever(interpretationRepository.deleteById(interpretationId)).thenReturn(Mono.empty()) + whenever(interpretationEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) divinationService.deleteInterpretation(spreadId, interpretationId).block() verify(interpretationRepository).deleteById(interpretationId) + verify(interpretationEventPublisher).publishDeleted(any()) } @Test @@ -345,7 +341,7 @@ class DivinationServiceTest { ) whenever(interpretationRepository.findById(interpretationId)).thenReturn(Mono.just(interpretation)) - whenever(authorizationService.canModify(otherUserId)).thenReturn(Mono.just(false)) + whenever(currentUserProvider.canModify(otherUserId)).thenReturn(Mono.just(false)) val exception = assertThrows { @@ -367,14 +363,14 @@ class DivinationServiceTest { createdAt = createdAt, ) - val interpretationDto = InterpretationDto(interpretationId, "Test", createdAt, testUser, spreadId) - whenever(interpretationRepository.findById(interpretationId)).thenReturn(Mono.just(interpretation)) - whenever(interpretationMapper.toDto(interpretation)).thenReturn(interpretationDto) + whenever(userProvider.getSystemUser(userId)).thenReturn(Mono.just(testUser)) + whenever(interpretationAttachmentRepository.findByInterpretationId(interpretationId)).thenReturn(Mono.empty()) - divinationService.getInterpretation(spreadId, interpretationId).block() + val result = divinationService.getInterpretation(spreadId, interpretationId).block() - verify(interpretationMapper).toDto(interpretation) + assertNotNull(result) + assertEquals(interpretationId, result!!.id) } @Test @@ -411,6 +407,8 @@ class DivinationServiceTest { whenever(interpretationRepository.findBySpreadIdOrderByCreatedAtDesc(spreadId, 0L, 2)) .thenReturn(Flux.fromIterable(interpretations)) whenever(interpretationRepository.countBySpreadId(spreadId)).thenReturn(Mono.just(2L)) + whenever(userProvider.getSystemUser(any())).thenReturn(Mono.just(testUser)) + whenever(interpretationAttachmentRepository.findByInterpretationId(any())).thenReturn(Mono.empty()) val result = divinationService.getInterpretations(spreadId, 0, 2).block() @@ -440,4 +438,121 @@ class DivinationServiceTest { verify(interpretationRepository).deleteByAuthorId(userId) verify(spreadRepository).deleteByAuthorId(userId) } + + @Test + fun `addInterpretation with uploadId should create interpretation with attachment`() { + val spread = TestEntityFactory.createSpread(id = spreadId, layoutTypeId = layoutTypeId) + val uploadId = UUID.randomUUID() + val savedInterpretation = + TestEntityFactory.createInterpretation( + id = interpretationId, + text = "Test interpretation", + authorId = userId, + spreadId = spreadId, + createdAt = createdAt, + ) + + val fileMetadata = + FileUploadMetadataDto( + uploadId = uploadId, + filePath = "interpretation-attachments/$uploadId/test.jpg", + originalFileName = "test.jpg", + contentType = "image/jpeg", + fileSize = 12345L, + completedAt = createdAt, + ) + + val savedAttachment = + TestEntityFactory.createInterpretationAttachment( + interpretationId = interpretationId, + fileUploadId = uploadId, + originalFileName = "test.jpg", + contentType = "image/jpeg", + fileSize = 12345L, + ) + + whenever(currentUserProvider.getCurrentUserId()).thenReturn(Mono.just(userId)) + whenever(currentUserProvider.getCurrentRole()).thenReturn(Mono.just("MEDIUM")) + whenever(spreadRepository.findById(spreadId)).thenReturn(Mono.just(spread)) + whenever(userProvider.getUserById(userId, "MEDIUM", userId)).thenReturn(Mono.just(testUser)) + whenever(interpretationRepository.existsByAuthorAndSpread(userId, spreadId)).thenReturn(Mono.just(false)) + whenever(interpretationRepository.save(any())).thenReturn(Mono.just(savedInterpretation)) + whenever(fileProvider.verifyAndCompleteUpload(uploadId, userId)).thenReturn(Mono.just(fileMetadata)) + whenever(interpretationAttachmentRepository.save(any())).thenReturn(Mono.just(savedAttachment)) + whenever(interpretationEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + + val result = divinationService.addInterpretation(spreadId, "Test interpretation", uploadId).block() + + assertNotNull(result) + assertEquals(interpretationId, result!!.id) + verify(fileProvider).verifyAndCompleteUpload(uploadId, userId) + verify(interpretationAttachmentRepository).save(any()) + verify(interpretationEventPublisher).publishCreated(any()) + } + + @Test + fun `addInterpretation without uploadId should not create attachment`() { + val spread = TestEntityFactory.createSpread(id = spreadId, layoutTypeId = layoutTypeId) + val savedInterpretation = + TestEntityFactory.createInterpretation( + id = interpretationId, + text = "Test interpretation", + authorId = userId, + spreadId = spreadId, + createdAt = createdAt, + ) + + whenever(currentUserProvider.getCurrentUserId()).thenReturn(Mono.just(userId)) + whenever(currentUserProvider.getCurrentRole()).thenReturn(Mono.just("MEDIUM")) + whenever(spreadRepository.findById(spreadId)).thenReturn(Mono.just(spread)) + whenever(userProvider.getUserById(userId, "MEDIUM", userId)).thenReturn(Mono.just(testUser)) + whenever(interpretationRepository.existsByAuthorAndSpread(userId, spreadId)).thenReturn(Mono.just(false)) + whenever(interpretationRepository.save(any())).thenReturn(Mono.just(savedInterpretation)) + whenever(interpretationEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + + val result = divinationService.addInterpretation(spreadId, "Test interpretation", null).block() + + assertNotNull(result) + assertEquals(interpretationId, result!!.id) + verify(fileProvider, never()).verifyAndCompleteUpload(any(), any()) + verify(interpretationAttachmentRepository, never()).save(any()) + } + + @Test + fun `getInterpretation should include attachment with download url`() { + val interpretation = + TestEntityFactory.createInterpretation( + id = interpretationId, + text = "Test", + authorId = userId, + spreadId = spreadId, + createdAt = createdAt, + ) + + val uploadId = UUID.randomUUID() + val attachment = + TestEntityFactory.createInterpretationAttachment( + interpretationId = interpretationId, + fileUploadId = uploadId, + originalFileName = "test.jpg", + contentType = "image/jpeg", + fileSize = 12345L, + ) + + whenever(interpretationRepository.findById(interpretationId)).thenReturn(Mono.just(interpretation)) + whenever(userProvider.getSystemUser(userId)).thenReturn(Mono.just(testUser)) + whenever( + interpretationAttachmentRepository.findByInterpretationId(interpretationId), + ).thenReturn(Mono.just(attachment)) + whenever(fileProvider.getDownloadUrl(uploadId)).thenReturn(Mono.just("https://minio.local/test.jpg?sig=xyz")) + + val result = divinationService.getInterpretation(spreadId, interpretationId).block() + + assertNotNull(result) + assertNotNull(result!!.attachment) + assertEquals("test.jpg", result.attachment!!.originalFileName) + assertEquals("image/jpeg", result.attachment!!.contentType) + assertEquals(12345L, result.attachment!!.fileSize) + assertEquals("https://minio.local/test.jpg?sig=xyz", result.attachment!!.downloadUrl) + } } diff --git a/divination-service/src/test/resources/application-test.yml b/divination-service/src/test/resources/application-test.yml index da6ad1e..5dda3b0 100644 --- a/divination-service/src/test/resources/application-test.yml +++ b/divination-service/src/test/resources/application-test.yml @@ -33,6 +33,16 @@ spring: user: test_user password: test_password +spring.kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: divination-service-test + auto-offset-reset: earliest + +kafka: + topics: + users-events: users-events + eureka: client: enabled: false diff --git a/docker-compose.yml b/docker-compose.yml index 02f15e2..f11209a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,117 @@ services: + # Kafka cluster (KRaft mode - no Zookeeper) + kafka-1: + image: confluentinc/cp-kafka:7.5.0 + hostname: kafka-1 + container_name: kafka-1 + ports: + - "9092:9092" + environment: + KAFKA_NODE_ID: 1 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka-1:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENERS: PLAINTEXT://kafka-1:29092,CONTROLLER://kafka-1:29093,PLAINTEXT_HOST://0.0.0.0:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 3 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2 + KAFKA_DEFAULT_REPLICATION_FACTOR: 3 + KAFKA_MIN_INSYNC_REPLICAS: 2 + CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk + KAFKA_LOG_DIRS: /var/lib/kafka/data + volumes: + - kafka-1-data:/var/lib/kafka/data + healthcheck: + test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092 || exit 1"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s + + kafka-2: + image: confluentinc/cp-kafka:7.5.0 + hostname: kafka-2 + container_name: kafka-2 + ports: + - "9093:9092" + environment: + KAFKA_NODE_ID: 2 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka-2:29092,PLAINTEXT_HOST://localhost:9093 + KAFKA_LISTENERS: PLAINTEXT://kafka-2:29092,CONTROLLER://kafka-2:29093,PLAINTEXT_HOST://0.0.0.0:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 3 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2 + KAFKA_DEFAULT_REPLICATION_FACTOR: 3 + KAFKA_MIN_INSYNC_REPLICAS: 2 + CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk + KAFKA_LOG_DIRS: /var/lib/kafka/data + volumes: + - kafka-2-data:/var/lib/kafka/data + healthcheck: + test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092 || exit 1"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s + + kafka-3: + image: confluentinc/cp-kafka:7.5.0 + hostname: kafka-3 + container_name: kafka-3 + ports: + - "9094:9092" + environment: + KAFKA_NODE_ID: 3 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka-3:29092,PLAINTEXT_HOST://localhost:9094 + KAFKA_LISTENERS: PLAINTEXT://kafka-3:29092,CONTROLLER://kafka-3:29093,PLAINTEXT_HOST://0.0.0.0:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 3 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 3 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 2 + KAFKA_DEFAULT_REPLICATION_FACTOR: 3 + KAFKA_MIN_INSYNC_REPLICAS: 2 + CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk + KAFKA_LOG_DIRS: /var/lib/kafka/data + volumes: + - kafka-3-data:/var/lib/kafka/data + healthcheck: + test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092 || exit 1"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 30s + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: kafka-ui + ports: + - "8090:8080" + environment: + KAFKA_CLUSTERS_0_NAME: tarot-cluster + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka-1:29092,kafka-2:29092,kafka-3:29092 + depends_on: + kafka-1: + condition: service_healthy + kafka-2: + condition: service_healthy + kafka-3: + condition: service_healthy + config-server: build: context: . @@ -84,6 +197,7 @@ services: DB_PASSWORD: ${DB_PASSWORD:-tarot_password} EUREKA_URL: http://eureka-server:8761/eureka/ JWT_SECRET: ${JWT_SECRET:-my-secret-key-for-development-only-change-in-production} + KAFKA_BOOTSTRAP_SERVERS: kafka-1:29092,kafka-2:29092,kafka-3:29092 depends_on: config-server: condition: service_healthy @@ -91,6 +205,8 @@ services: condition: service_healthy postgres: condition: service_healthy + kafka-1: + condition: service_healthy healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8081/actuator/health || exit 1"] interval: 10s @@ -140,6 +256,7 @@ services: DB_USER: ${DB_USER:-tarot_user} DB_PASSWORD: ${DB_PASSWORD:-tarot_password} EUREKA_URL: http://eureka-server:8761/eureka/ + KAFKA_BOOTSTRAP_SERVERS: kafka-1:29092,kafka-2:29092,kafka-3:29092 depends_on: config-server: condition: service_healthy @@ -147,6 +264,8 @@ services: condition: service_healthy postgres: condition: service_healthy + kafka-1: + condition: service_healthy user-service: condition: service_healthy tarot-service: @@ -158,5 +277,111 @@ services: retries: 10 start_period: 30s + notification-service: + build: + context: . + dockerfile: notification-service/Dockerfile + ports: + - "8084:8084" + environment: + CONFIG_SERVER_URL: http://config-server:8888 + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-tarot_db} + DB_USER: ${DB_USER:-tarot_user} + DB_PASSWORD: ${DB_PASSWORD:-tarot_password} + EUREKA_URL: http://eureka-server:8761/eureka/ + KAFKA_BOOTSTRAP_SERVERS: kafka-1:29092,kafka-2:29092,kafka-3:29092 + depends_on: + config-server: + condition: service_healthy + eureka-server: + condition: service_healthy + postgres: + condition: service_healthy + kafka-1: + condition: service_healthy + divination-service: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8084/actuator/health || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + files-service: + build: + context: . + dockerfile: files-service/Dockerfile + ports: + - "8085:8085" + environment: + CONFIG_SERVER_URL: http://config-server:8888 + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-tarot_db} + DB_USER: ${DB_USER:-tarot_user} + DB_PASSWORD: ${DB_PASSWORD:-tarot_password} + EUREKA_URL: http://eureka-server:8761/eureka/ + MINIO_HOST: minio + MINIO_PORT: 9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + KAFKA_BOOTSTRAP_SERVERS: kafka-1:29092,kafka-2:29092,kafka-3:29092 + depends_on: + config-server: + condition: service_healthy + eureka-server: + condition: service_healthy + postgres: + condition: service_healthy + minio: + condition: service_healthy + kafka-1: + condition: service_healthy + kafka-2: + condition: service_healthy + kafka-3: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "curl -f http://localhost:8085/actuator/health || exit 1", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + minio: + image: minio/minio:latest + hostname: minio + container_name: minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: + [ + "CMD-SHELL", + "curl -f http://localhost:9000/minio/health/live || exit 1", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + volumes: postgres_data: + kafka-1-data: + kafka-2-data: + kafka-3-data: + minio_data: diff --git a/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/BaseE2ETest.kt b/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/BaseE2ETest.kt index de52416..5fb55bd 100644 --- a/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/BaseE2ETest.kt +++ b/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/BaseE2ETest.kt @@ -2,12 +2,14 @@ package com.github.butvinmitmo.e2e import com.github.butvinmitmo.e2e.config.AuthContext import com.github.butvinmitmo.shared.client.DivinationServiceClient +import com.github.butvinmitmo.shared.client.FilesServiceClient import com.github.butvinmitmo.shared.client.TarotServiceClient import com.github.butvinmitmo.shared.client.UserServiceClient import com.github.butvinmitmo.shared.dto.LoginRequest import feign.FeignException import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.assertThrows @@ -33,6 +35,9 @@ abstract class BaseE2ETest { @Autowired protected lateinit var divinationClient: DivinationServiceClient + @Autowired + protected lateinit var filesClient: FilesServiceClient + // Admin user context for Feign header parameters protected val adminUserId: java.util.UUID = java.util.UUID.fromString("10000000-0000-0000-0000-000000000001") protected val adminRole: String = "ADMIN" @@ -173,4 +178,70 @@ abstract class BaseE2ETest { ) return exception } + + /** + * Assert that a block throws FeignException with one of the expected HTTP status codes. + * Useful when the exact error status may vary (e.g., 400 vs 502 for backend failures). + */ + protected fun assertThrowsWithStatus( + vararg expectedStatuses: Int, + block: () -> Any, + ): FeignException { + val exception = assertThrows { block() } + assertTrue( + exception.status() in expectedStatuses, + "Expected HTTP status in ${expectedStatuses.toList()} but got ${exception.status()}", + ) + return exception + } + + /** + * Poll until a condition returns true or timeout is reached. + * Used for eventual consistency scenarios where async processing may take time. + * + * @param maxRetries Maximum number of attempts + * @param delayMs Delay between attempts in milliseconds + * @param timeoutMessage Message to include in assertion failure + * @param condition Lambda that returns true when the expected state is reached + */ + protected fun awaitCondition( + maxRetries: Int = 10, + delayMs: Long = 500, + timeoutMessage: String = "Condition not met within timeout", + condition: () -> Boolean, + ) { + for (attempt in 1..maxRetries) { + if (condition()) { + return + } + if (attempt < maxRetries) { + Thread.sleep(delayMs) + } + } + throw AssertionError("$timeoutMessage after $maxRetries attempts") + } + + /** + * Wait until a Feign call returns the expected HTTP status code. + * Useful for eventual consistency where a resource should eventually return 404. + */ + protected fun awaitStatus( + expectedStatus: Int, + maxRetries: Int = 10, + delayMs: Long = 500, + block: () -> Any, + ) { + awaitCondition( + maxRetries = maxRetries, + delayMs = delayMs, + timeoutMessage = "Expected HTTP status $expectedStatus", + ) { + try { + block() + false // Call succeeded without exception, condition not met if expecting error status + } catch (e: FeignException) { + e.status() == expectedStatus + } + } + } } diff --git a/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/FileAttachmentE2ETest.kt b/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/FileAttachmentE2ETest.kt new file mode 100644 index 0000000..ac41630 --- /dev/null +++ b/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/FileAttachmentE2ETest.kt @@ -0,0 +1,488 @@ +package com.github.butvinmitmo.e2e + +import com.github.butvinmitmo.shared.dto.CreateInterpretationRequest +import com.github.butvinmitmo.shared.dto.CreateSpreadRequest +import com.github.butvinmitmo.shared.dto.CreateUserRequest +import com.github.butvinmitmo.shared.dto.PresignedUploadRequest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.web.client.RestTemplate +import java.util.UUID + +/** + * E2E tests for file attachment functionality. + * + * Tests the API contract for file uploads and attachments. + * + * Note: Tests requiring direct MinIO access (upload/download via presigned URLs) + * are disabled by default because the presigned URLs contain the internal Docker + * hostname ('minio') which is not resolvable from the host machine. + * + * To run full tests including MinIO direct access: + * 1. Add '127.0.0.1 minio' to /etc/hosts, OR + * 2. Run E2E tests from within the Docker network + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class FileAttachmentE2ETest : BaseE2ETest() { + companion object { + private lateinit var mediumUserId: UUID + private lateinit var mediumUsername: String + private lateinit var otherMediumUserId: UUID + private lateinit var otherMediumUsername: String + private lateinit var oneCardLayoutId: UUID + private lateinit var spreadId: UUID + + // Small valid PNG image bytes (1x1 pixel) + private val TEST_PNG_BYTES = + byteArrayOf( + 0x89.toByte(), + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x02, + 0x00, + 0x00, + 0x00, + 0x90.toByte(), + 0x77, + 0x53, + 0xDE.toByte(), + 0x00, + 0x00, + 0x00, + 0x0C, + 0x49, + 0x44, + 0x41, + 0x54, + 0x08, + 0xD7.toByte(), + 0x63, + 0xF8.toByte(), + 0xCF.toByte(), + 0xC0.toByte(), + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + 0x01, + 0x00, + 0x18, + 0xDD.toByte(), + 0x8D.toByte(), + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE.toByte(), + 0x42, + 0x60, + 0x82.toByte(), + ) + + /** + * Checks if 'minio' hostname is resolvable (e.g., via /etc/hosts entry). + * Returns true if direct MinIO access is possible. + */ + private fun isMinioAccessible(): Boolean = + try { + java.net.InetAddress.getByName("minio") + true + } catch (e: java.net.UnknownHostException) { + false + } + } + + @BeforeAll + fun setupTestData() { + loginAsAdmin() + + // Create MEDIUM user for testing file uploads + mediumUsername = "e2e_medium_user_${System.currentTimeMillis()}" + val mediumUserResponse = + userClient.createUser( + currentUserId, + currentRole, + CreateUserRequest( + username = mediumUsername, + password = "Medium@123", + role = "MEDIUM", + ), + ) + mediumUserId = mediumUserResponse.body!!.id + + // Create another MEDIUM user to test cross-user upload rejection + otherMediumUsername = "e2e_other_medium_${System.currentTimeMillis()}" + val otherMediumUserResponse = + userClient.createUser( + currentUserId, + currentRole, + CreateUserRequest( + username = otherMediumUsername, + password = "Other@123", + role = "MEDIUM", + ), + ) + otherMediumUserId = otherMediumUserResponse.body!!.id + + // Get layout type + val layoutTypes = tarotClient.getLayoutTypes(currentUserId, currentRole).body!! + oneCardLayoutId = layoutTypes.find { it.name == "ONE_CARD" }!!.id + + // Create a spread for interpretations (as MEDIUM user) + loginAndSetToken(mediumUsername, "Medium@123") + val spreadResponse = + divinationClient.createSpread( + CreateSpreadRequest( + question = "E2E file attachment test spread", + layoutTypeId = oneCardLayoutId, + ), + ) + spreadId = spreadResponse.body!!.id + } + + @AfterAll + fun cleanup() { + loginAsAdmin() + // Delete test users (cascades to spreads and interpretations) + runCatching { userClient.deleteUser(currentUserId, currentRole, mediumUserId) } + runCatching { userClient.deleteUser(currentUserId, currentRole, otherMediumUserId) } + } + + @Test + @Order(1) + fun `POST presigned-upload should return upload URL and ID`() { + loginAndSetToken(mediumUsername, "Medium@123") + + val request = + PresignedUploadRequest( + fileName = "test-image.png", + contentType = "image/png", + ) + val response = filesClient.requestPresignedUpload(request) + + assertEquals(200, response.statusCode.value()) + assertNotNull(response.body?.uploadId, "uploadId should not be null") + assertNotNull(response.body?.uploadUrl, "uploadUrl should not be null") + assertNotNull(response.body?.expiresAt, "expiresAt should not be null") + + // Verify uploadUrl format (contains MinIO reference and bucket name) + val uploadUrl = response.body!!.uploadUrl + assertTrue(uploadUrl.contains("interpretation-attachments"), "Upload URL should contain bucket name") + assertTrue( + uploadUrl.contains("X-Amz-Signature"), + "Upload URL should be signed", + ) + } + + @Test + @Order(2) + fun `POST presigned-upload with invalid content type should return 400`() { + loginAndSetToken(mediumUsername, "Medium@123") + + val request = + PresignedUploadRequest( + fileName = "test.txt", + contentType = "text/plain", + ) + + assertThrowsWithStatus(400) { + filesClient.requestPresignedUpload(request) + } + } + + @Test + @Order(3) + fun `POST interpretation without uploadId should create interpretation without attachment`() { + loginAndSetToken(mediumUsername, "Medium@123") + + // Create interpretation without uploadId + val request = + CreateInterpretationRequest( + text = "Interpretation without file attachment - E2E test", + ) + val response = divinationClient.createInterpretation(spreadId, request) + + assertEquals(201, response.statusCode.value()) + assertNotNull(response.body?.id) + + // Verify no attachment in spread response + val spread = divinationClient.getSpreadById(spreadId).body!! + val interpretation = spread.interpretations.find { it.text.contains("E2E test") } + assertNotNull(interpretation, "Interpretation should exist") + assertNull(interpretation!!.attachment, "Interpretation should not have attachment") + } + + @Test + @Order(4) + fun `POST interpretation with non-existent uploadId should fail`() { + loginAndSetToken(mediumUsername, "Medium@123") + + // Create a new spread (because the previous test already added interpretation to the main spreadId) + val newSpread = + divinationClient.createSpread( + CreateSpreadRequest( + question = "Spread for non-existent uploadId test", + layoutTypeId = oneCardLayoutId, + ), + ) + val newSpreadId = newSpread.body!!.id + + val nonExistentUploadId = UUID.randomUUID() + val request = + CreateInterpretationRequest( + text = "Interpretation with fake uploadId", + uploadId = nonExistentUploadId, + ) + + // Should fail because upload doesn't exist (400 from files-service or 502 gateway error) + assertThrowsWithStatus(400, 502) { + divinationClient.createInterpretation(newSpreadId, request) + } + } + + @Test + @Order(5) + @Disabled("Requires 'minio' hostname to be resolvable. Add '127.0.0.1 minio' to /etc/hosts to enable.") + fun `should upload file to MinIO via presigned URL`() { + org.junit.jupiter.api.Assumptions.assumeTrue( + isMinioAccessible(), + "MinIO not accessible - add '127.0.0.1 minio' to /etc/hosts", + ) + + loginAndSetToken(mediumUsername, "Medium@123") + + val request = + PresignedUploadRequest( + fileName = "test-upload.png", + contentType = "image/png", + ) + val presignedResponse = filesClient.requestPresignedUpload(request) + val uploadUrl = presignedResponse.body!!.uploadUrl + + // Upload directly to MinIO + val restTemplate = RestTemplate() + val headers = HttpHeaders() + headers.contentType = MediaType.IMAGE_PNG + + val httpEntity = HttpEntity(TEST_PNG_BYTES, headers) + val uploadResponse = + restTemplate.exchange( + uploadUrl, + HttpMethod.PUT, + httpEntity, + String::class.java, + ) + + assertTrue( + uploadResponse.statusCode.is2xxSuccessful, + "File upload to MinIO should succeed, got: ${uploadResponse.statusCode}", + ) + } + + @Test + @Order(6) + @Disabled("Requires 'minio' hostname to be resolvable. Add '127.0.0.1 minio' to /etc/hosts to enable.") + fun `POST interpretation with uploadId should create interpretation with attachment`() { + org.junit.jupiter.api.Assumptions.assumeTrue( + isMinioAccessible(), + "MinIO not accessible - add '127.0.0.1 minio' to /etc/hosts", + ) + + loginAndSetToken(mediumUsername, "Medium@123") + + // First upload a file + val uploadRequest = + PresignedUploadRequest( + fileName = "attachment-test.png", + contentType = "image/png", + ) + val presignedResponse = filesClient.requestPresignedUpload(uploadRequest) + val uploadId = presignedResponse.body!!.uploadId + + // Upload to MinIO + val restTemplate = RestTemplate() + val headers = HttpHeaders() + headers.contentType = MediaType.IMAGE_PNG + restTemplate.exchange( + presignedResponse.body!!.uploadUrl, + HttpMethod.PUT, + HttpEntity(TEST_PNG_BYTES, headers), + String::class.java, + ) + + // Create interpretation with uploadId + val interpretationRequest = + CreateInterpretationRequest( + text = "Interpretation with attachment - full E2E test", + uploadId = uploadId, + ) + val response = divinationClient.createInterpretation(spreadId, interpretationRequest) + + assertEquals(201, response.statusCode.value()) + + // Verify attachment in spread response + val spread = divinationClient.getSpreadById(spreadId).body!! + val interpretation = spread.interpretations.find { it.text.contains("full E2E test") } + assertNotNull(interpretation?.attachment, "Interpretation should have attachment") + assertEquals("attachment-test.png", interpretation!!.attachment!!.originalFileName) + } + + @Test + @Order(7) + @Disabled("Requires 'minio' hostname to be resolvable. Add '127.0.0.1 minio' to /etc/hosts to enable.") + fun `POST interpretation with uploadId from another user should return 403`() { + org.junit.jupiter.api.Assumptions.assumeTrue( + isMinioAccessible(), + "MinIO not accessible - add '127.0.0.1 minio' to /etc/hosts", + ) + + // Login as first MEDIUM user and request upload + loginAndSetToken(mediumUsername, "Medium@123") + + val request = + PresignedUploadRequest( + fileName = "user1-image.png", + contentType = "image/png", + ) + val presignedResponse = filesClient.requestPresignedUpload(request) + val user1UploadId = presignedResponse.body!!.uploadId + + // Upload the file + val restTemplate = RestTemplate() + val headers = HttpHeaders() + headers.contentType = MediaType.IMAGE_PNG + restTemplate.exchange( + presignedResponse.body!!.uploadUrl, + HttpMethod.PUT, + HttpEntity(TEST_PNG_BYTES, headers), + String::class.java, + ) + + // Create a new spread for the other user + loginAndSetToken(otherMediumUsername, "Other@123") + val spreadResponse = + divinationClient.createSpread( + CreateSpreadRequest( + question = "Other user's spread for auth test", + layoutTypeId = oneCardLayoutId, + ), + ) + val otherSpreadId = spreadResponse.body!!.id + + // Try to create interpretation with user1's uploadId - should fail + val interpretationRequest = + CreateInterpretationRequest( + text = "Attempting to use another user's upload", + uploadId = user1UploadId, + ) + + assertThrowsWithStatus(403) { + divinationClient.createInterpretation(otherSpreadId, interpretationRequest) + } + } + + @Test + @Order(8) + @Disabled("Requires 'minio' hostname to be resolvable. Add '127.0.0.1 minio' to /etc/hosts to enable.") + fun `DELETE file upload should remove file`() { + org.junit.jupiter.api.Assumptions.assumeTrue( + isMinioAccessible(), + "MinIO not accessible - add '127.0.0.1 minio' to /etc/hosts", + ) + + loginAndSetToken(mediumUsername, "Medium@123") + + // Request a new upload + val request = + PresignedUploadRequest( + fileName = "to-delete.png", + contentType = "image/png", + ) + val presignedResponse = filesClient.requestPresignedUpload(request) + val deleteUploadId = presignedResponse.body!!.uploadId + + // Upload the file + val restTemplate = RestTemplate() + val headers = HttpHeaders() + headers.contentType = MediaType.IMAGE_PNG + restTemplate.exchange( + presignedResponse.body!!.uploadUrl, + HttpMethod.PUT, + HttpEntity(TEST_PNG_BYTES, headers), + String::class.java, + ) + + // Delete the upload + val deleteResponse = filesClient.deleteUpload(deleteUploadId) + assertEquals(204, deleteResponse.statusCode.value()) + + // Verify it's gone + assertThrowsWithStatus(404) { + filesClient.getUploadMetadata(deleteUploadId) + } + } + + @Test + @Order(9) + fun `DELETE pending upload should work`() { + loginAndSetToken(mediumUsername, "Medium@123") + + // Request an upload (but don't actually upload the file) + val request = + PresignedUploadRequest( + fileName = "pending-delete.png", + contentType = "image/png", + ) + val presignedResponse = filesClient.requestPresignedUpload(request) + val pendingUploadId = presignedResponse.body!!.uploadId + + // Delete the pending upload (without uploading file) + val deleteResponse = filesClient.deleteUpload(pendingUploadId) + assertEquals(204, deleteResponse.statusCode.value()) + + // Verify it's gone (returns 400 for "not found" - could be improved to 404) + assertThrowsWithStatus(400) { + filesClient.getUploadMetadata(pendingUploadId) + } + } +} diff --git a/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/UserCascadeDeleteE2ETest.kt b/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/UserCascadeDeleteE2ETest.kt index 22fee69..d00f62b 100644 --- a/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/UserCascadeDeleteE2ETest.kt +++ b/e2e-tests/src/test/kotlin/com/github/butvinmitmo/e2e/UserCascadeDeleteE2ETest.kt @@ -10,7 +10,8 @@ import org.junit.jupiter.api.Test * Tests for user deletion cascade behavior. * * Verifies that when a user is deleted, their spreads and interpretations - * are also deleted via the internal API call to divination-service. + * are eventually deleted via Kafka event consumption in divination-service. + * Uses polling since cleanup is now asynchronous (eventual consistency). */ class UserCascadeDeleteE2ETest : BaseE2ETest() { @Test @@ -58,8 +59,8 @@ class UserCascadeDeleteE2ETest : BaseE2ETest() { val deleteResponse = userClient.deleteUser(currentUserId, currentRole, testUserId) assertEquals(204, deleteResponse.statusCode.value()) - // Verify spread is deleted (should return 404) - assertThrowsWithStatus(404) { + // Verify spread is eventually deleted (async via Kafka) + awaitStatus(404) { divinationClient.getSpreadById(spreadId) } @@ -118,8 +119,8 @@ class UserCascadeDeleteE2ETest : BaseE2ETest() { val deleteResponse = userClient.deleteUser(currentUserId, currentRole, userAId) assertEquals(204, deleteResponse.statusCode.value()) - // UserA's spread should be deleted - assertThrowsWithStatus(404) { + // UserA's spread should be eventually deleted (async via Kafka) + awaitStatus(404) { divinationClient.getSpreadById(spreadAId) } diff --git a/e2e-tests/src/test/resources/application.yml b/e2e-tests/src/test/resources/application.yml index b198af4..995aa8c 100644 --- a/e2e-tests/src/test/resources/application.yml +++ b/e2e-tests/src/test/resources/application.yml @@ -20,6 +20,8 @@ services: url: ${gateway.url} divination-service: url: ${gateway.url} + files-service: + url: ${gateway.url} logging: level: diff --git a/files-service/Dockerfile b/files-service/Dockerfile new file mode 100644 index 0000000..34845a9 --- /dev/null +++ b/files-service/Dockerfile @@ -0,0 +1,15 @@ +FROM gradle:8-jdk21 AS build +WORKDIR /app + +ENV GRADLE_USER_HOME=/home/gradle/.gradle + +COPY . . + +RUN --mount=type=cache,target=/home/gradle/.gradle,id=files-service-gradle \ + gradle :files-service:bootJar --no-daemon -x test + +FROM eclipse-temurin:21-jre +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /app/files-service/build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/files-service/build.gradle.kts b/files-service/build.gradle.kts new file mode 100644 index 0000000..da13226 --- /dev/null +++ b/files-service/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + kotlin("jvm") version "2.2.10" + kotlin("plugin.spring") version "2.2.10" + id("org.springframework.boot") version "3.5.6" + id("io.spring.dependency-management") version "1.1.7" + id("org.jlleitschuh.gradle.ktlint") version "12.1.2" +} + +group = "com.github.butvinmitmo" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2025.0.0" + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +dependencies { + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + implementation(project(":shared-dto")) + implementation(project(":shared-clients")) + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") + runtimeOnly("org.postgresql:r2dbc-postgresql") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.springdoc:springdoc-openapi-starter-webflux-api:2.8.4") + + // Spring Security + implementation("org.springframework.boot:spring-boot-starter-security") + + // Kafka + implementation("org.springframework.kafka:spring-kafka") + + // MinIO client + implementation("io.minio:minio:8.5.7") + + // PostgreSQL JDBC driver for Flyway + implementation("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.projectreactor:reactor-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") + testImplementation("org.mockito:mockito-junit-jupiter") + testImplementation("org.testcontainers:testcontainers:2.0.2") + testImplementation("org.testcontainers:testcontainers-postgresql:2.0.2") + testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.2") + testImplementation("org.testcontainers:testcontainers-minio:2.0.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property") + } +} + +tasks.withType { + useJUnitPlatform() +} + +ktlint { + version.set("1.5.0") +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/FilesServiceApplication.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/FilesServiceApplication.kt new file mode 100644 index 0000000..bf1c237 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/FilesServiceApplication.kt @@ -0,0 +1,41 @@ +package com.github.butvinmitmo.filesservice + +import io.swagger.v3.oas.annotations.OpenAPIDefinition +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityScheme +import io.swagger.v3.oas.annotations.servers.Server +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration +import org.springframework.boot.runApplication +import org.springframework.cloud.client.discovery.EnableDiscoveryClient +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication( + exclude = [ + JpaRepositoriesAutoConfiguration::class, + HibernateJpaAutoConfiguration::class, + ], +) +@EnableDiscoveryClient +@EnableR2dbcRepositories( + basePackages = ["com.github.butvinmitmo.filesservice.infrastructure.persistence.repository"], +) +@EnableScheduling +@OpenAPIDefinition( + servers = [Server(url = "http://localhost:8080", description = "API Gateway")], + security = [SecurityRequirement(name = "bearerAuth")], +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", +) +class FilesServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/controller/FileUploadController.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/controller/FileUploadController.kt new file mode 100644 index 0000000..0c24eb4 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/controller/FileUploadController.kt @@ -0,0 +1,149 @@ +package com.github.butvinmitmo.filesservice.api.controller + +import com.github.butvinmitmo.filesservice.api.dto.FileUploadMetadataResponse +import com.github.butvinmitmo.filesservice.api.dto.PresignedUploadRequest +import com.github.butvinmitmo.filesservice.api.dto.PresignedUploadResponse +import com.github.butvinmitmo.filesservice.application.service.FileUploadService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.DeleteMapping +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import java.util.UUID + +@RestController +@RequestMapping("/api/v0.0.1/files") +@Tag(name = "Files", description = "File upload operations") +@Validated +class FileUploadController( + private val fileUploadService: FileUploadService, +) { + @PostMapping("/presigned-upload") + @Operation( + summary = "Request presigned upload URL", + description = + "Generates a presigned URL for direct file upload to object storage. " + + "Client should upload the file directly to the returned URL via HTTP PUT.", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Presigned URL generated successfully", + content = [Content(schema = Schema(implementation = PresignedUploadResponse::class))], + ), + ApiResponse(responseCode = "400", description = "Invalid content type or file name"), + ApiResponse(responseCode = "401", description = "Not authenticated"), + ], + ) + fun requestPresignedUpload( + @Valid @RequestBody request: PresignedUploadRequest, + ): Mono> = + fileUploadService.requestUpload(request.fileName, request.contentType).map { result -> + ResponseEntity.ok( + PresignedUploadResponse( + uploadId = result.uploadId, + uploadUrl = result.uploadUrl, + expiresAt = result.expiresAt, + ), + ) + } + + @GetMapping("/{uploadId}") + @Operation( + summary = "Get file upload metadata", + description = "Retrieves metadata for a completed file upload.", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "File metadata retrieved successfully", + content = [Content(schema = Schema(implementation = FileUploadMetadataResponse::class))], + ), + ApiResponse(responseCode = "404", description = "Upload not found or not completed"), + ApiResponse(responseCode = "401", description = "Not authenticated"), + ], + ) + fun getUploadMetadata( + @Parameter(description = "Upload ID", required = true) + @PathVariable + uploadId: UUID, + ): Mono> = + fileUploadService.getUploadMetadata(uploadId).map { metadata -> + ResponseEntity.ok( + FileUploadMetadataResponse( + uploadId = metadata.uploadId, + originalFileName = metadata.originalFileName, + contentType = metadata.contentType, + fileSize = metadata.fileSize, + completedAt = metadata.completedAt, + ), + ) + } + + @GetMapping("/{uploadId}/download-url") + @Operation( + summary = "Get presigned download URL", + description = "Generates a presigned URL for downloading the file from object storage.", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Download URL generated successfully", + content = [Content(schema = Schema(implementation = DownloadUrlResponse::class))], + ), + ApiResponse(responseCode = "404", description = "Upload not found or not completed"), + ApiResponse(responseCode = "401", description = "Not authenticated"), + ], + ) + fun getDownloadUrl( + @Parameter(description = "Upload ID", required = true) + @PathVariable + uploadId: UUID, + ): Mono> = + fileUploadService.getDownloadUrl(uploadId).map { url -> + ResponseEntity.ok(DownloadUrlResponse(downloadUrl = url)) + } + + @DeleteMapping("/{uploadId}") + @Operation( + summary = "Delete file upload", + description = + "Deletes a file upload and removes the file from object storage. " + + "Only the upload owner can delete their uploads.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "File deleted successfully"), + ApiResponse(responseCode = "404", description = "Upload not found or does not belong to user"), + ApiResponse(responseCode = "401", description = "Not authenticated"), + ], + ) + fun deleteUpload( + @Parameter(description = "Upload ID", required = true) + @PathVariable + uploadId: UUID, + ): Mono> = + fileUploadService.deleteUpload(uploadId).then( + Mono.just(ResponseEntity.noContent().build()), + ) +} + +data class DownloadUrlResponse( + val downloadUrl: String, +) diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/controller/InternalFileController.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/controller/InternalFileController.kt new file mode 100644 index 0000000..2650886 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/controller/InternalFileController.kt @@ -0,0 +1,89 @@ +package com.github.butvinmitmo.filesservice.api.controller + +import com.github.butvinmitmo.filesservice.application.service.FileUploadService +import com.github.butvinmitmo.shared.dto.FileUploadMetadataDto +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.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import java.util.UUID + +/** + * Internal API controller for service-to-service communication. + * These endpoints are not exposed through the gateway and are only accessible + * via Eureka service discovery for internal operations. + */ +@RestController +@RequestMapping("/internal/files") +class InternalFileController( + private val fileUploadService: FileUploadService, +) { + /** + * Verifies that a file upload exists and is completed. + * Called by divination-service when creating an interpretation with an attachment. + * + * @param uploadId The ID of the upload to verify + * @param userId The ID of the user who owns the upload (for ownership validation) + * @return File metadata if upload exists, is completed, and belongs to the user + */ + @PostMapping("/{uploadId}/verify") + fun verifyAndCompleteUpload( + @PathVariable uploadId: UUID, + @RequestParam userId: UUID, + ): Mono> = + fileUploadService.verifyAndCompleteUpload(uploadId, userId).map { metadata -> + ResponseEntity.ok( + FileUploadMetadataDto( + uploadId = metadata.uploadId, + filePath = metadata.filePath, + originalFileName = metadata.originalFileName, + contentType = metadata.contentType, + fileSize = metadata.fileSize, + completedAt = metadata.completedAt, + ), + ) + } + + /** + * Gets metadata for a completed file upload. + * Called by divination-service to retrieve file information. + * + * @param uploadId The ID of the upload + * @return File metadata if upload exists and is completed + */ + @GetMapping("/{uploadId}/metadata") + fun getUploadMetadata( + @PathVariable uploadId: UUID, + ): Mono> = + fileUploadService.getUploadMetadata(uploadId).map { metadata -> + ResponseEntity.ok( + FileUploadMetadataDto( + uploadId = metadata.uploadId, + filePath = metadata.filePath, + originalFileName = metadata.originalFileName, + contentType = metadata.contentType, + fileSize = metadata.fileSize, + completedAt = metadata.completedAt, + ), + ) + } + + /** + * Gets a presigned download URL for a file. + * Called by divination-service to generate download URLs for attachments. + * + * @param uploadId The ID of the upload + * @return Presigned download URL + */ + @GetMapping("/{uploadId}/download-url") + fun getDownloadUrl( + @PathVariable uploadId: UUID, + ): Mono>> = + fileUploadService.getDownloadUrl(uploadId).map { url -> + ResponseEntity.ok(mapOf("downloadUrl" to url)) + } +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/FileUploadMetadataResponse.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/FileUploadMetadataResponse.kt new file mode 100644 index 0000000..9e24626 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/FileUploadMetadataResponse.kt @@ -0,0 +1,15 @@ +package com.github.butvinmitmo.filesservice.api.dto + +import java.time.Instant +import java.util.UUID + +/** + * File upload metadata response for public API. + */ +data class FileUploadMetadataResponse( + val uploadId: UUID, + val originalFileName: String, + val contentType: String, + val fileSize: Long, + val completedAt: Instant, +) diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/PresignedUploadRequest.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/PresignedUploadRequest.kt new file mode 100644 index 0000000..2f452d7 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/PresignedUploadRequest.kt @@ -0,0 +1,10 @@ +package com.github.butvinmitmo.filesservice.api.dto + +import jakarta.validation.constraints.NotBlank + +data class PresignedUploadRequest( + @field:NotBlank(message = "File name is required") + val fileName: String, + @field:NotBlank(message = "Content type is required") + val contentType: String, +) diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/PresignedUploadResponse.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/PresignedUploadResponse.kt new file mode 100644 index 0000000..b7c0228 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/api/dto/PresignedUploadResponse.kt @@ -0,0 +1,10 @@ +package com.github.butvinmitmo.filesservice.api.dto + +import java.time.Instant +import java.util.UUID + +data class PresignedUploadResponse( + val uploadId: UUID, + val uploadUrl: String, + val expiresAt: Instant, +) diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/provider/CurrentUserProvider.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/provider/CurrentUserProvider.kt new file mode 100644 index 0000000..4e318a3 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/provider/CurrentUserProvider.kt @@ -0,0 +1,8 @@ +package com.github.butvinmitmo.filesservice.application.interfaces.provider + +import reactor.core.publisher.Mono +import java.util.UUID + +interface CurrentUserProvider { + fun getCurrentUserId(): Mono +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/publisher/FileEventPublisher.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/publisher/FileEventPublisher.kt new file mode 100644 index 0000000..f460502 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/publisher/FileEventPublisher.kt @@ -0,0 +1,10 @@ +package com.github.butvinmitmo.filesservice.application.interfaces.publisher + +import com.github.butvinmitmo.filesservice.domain.model.FileUpload +import reactor.core.publisher.Mono + +interface FileEventPublisher { + fun publishCompleted(fileUpload: FileUpload): Mono + + fun publishDeleted(fileUpload: FileUpload): Mono +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/repository/FileUploadRepository.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/repository/FileUploadRepository.kt new file mode 100644 index 0000000..90cc009 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/repository/FileUploadRepository.kt @@ -0,0 +1,30 @@ +package com.github.butvinmitmo.filesservice.application.interfaces.repository + +import com.github.butvinmitmo.filesservice.domain.model.FileUpload +import com.github.butvinmitmo.filesservice.domain.model.FileUploadStatus +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Instant +import java.util.UUID + +interface FileUploadRepository { + fun save(fileUpload: FileUpload): Mono + + fun findById(id: UUID): Mono + + fun findByIdAndUserId( + id: UUID, + userId: UUID, + ): Mono + + fun updateStatus( + id: UUID, + status: FileUploadStatus, + fileSize: Long?, + completedAt: Instant?, + ): Mono + + fun findExpiredPending(now: Instant): Flux + + fun deleteById(id: UUID): Mono +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/storage/FileStorage.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/storage/FileStorage.kt new file mode 100644 index 0000000..925c756 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/interfaces/storage/FileStorage.kt @@ -0,0 +1,22 @@ +package com.github.butvinmitmo.filesservice.application.interfaces.storage + +import reactor.core.publisher.Mono + +interface FileStorage { + fun generatePresignedUploadUrl( + path: String, + contentType: String, + expirationMinutes: Int, + ): Mono + + fun generatePresignedDownloadUrl( + path: String, + expirationMinutes: Int, + ): Mono + + fun exists(path: String): Mono + + fun getObjectSize(path: String): Mono + + fun delete(path: String): Mono +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/service/FileUploadService.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/service/FileUploadService.kt new file mode 100644 index 0000000..4d70bd1 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/application/service/FileUploadService.kt @@ -0,0 +1,254 @@ +package com.github.butvinmitmo.filesservice.application.service + +import com.github.butvinmitmo.filesservice.application.interfaces.provider.CurrentUserProvider +import com.github.butvinmitmo.filesservice.application.interfaces.publisher.FileEventPublisher +import com.github.butvinmitmo.filesservice.application.interfaces.repository.FileUploadRepository +import com.github.butvinmitmo.filesservice.application.interfaces.storage.FileStorage +import com.github.butvinmitmo.filesservice.config.UploadProperties +import com.github.butvinmitmo.filesservice.domain.model.FileUpload +import com.github.butvinmitmo.filesservice.domain.model.FileUploadStatus +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.time.Instant +import java.util.UUID + +data class PresignedUploadResult( + val uploadId: UUID, + val uploadUrl: String, + val expiresAt: Instant, +) + +data class FileUploadMetadata( + val uploadId: UUID, + val filePath: String, + val originalFileName: String, + val contentType: String, + val fileSize: Long, + val completedAt: Instant, +) + +@Service +class FileUploadService( + private val fileUploadRepository: FileUploadRepository, + private val fileStorage: FileStorage, + private val currentUserProvider: CurrentUserProvider, + private val uploadProperties: UploadProperties, + private val fileEventPublisher: FileEventPublisher, +) { + private val logger = LoggerFactory.getLogger(FileUploadService::class.java) + + fun requestUpload( + fileName: String, + contentType: String, + ): Mono { + if (contentType !in uploadProperties.allowedContentTypes) { + return Mono.error( + IllegalArgumentException( + "Content type '$contentType' is not allowed. Allowed types: ${uploadProperties.allowedContentTypes}", + ), + ) + } + + return currentUserProvider.getCurrentUserId().flatMap { userId -> + val expiresAt = Instant.now().plusSeconds(uploadProperties.expirationMinutes.toLong() * 60) + val filePath = "$userId/${UUID.randomUUID()}/${sanitizeFileName(fileName)}" + + val fileUpload = + FileUpload( + id = null, + userId = userId, + filePath = filePath, + originalFileName = fileName, + contentType = contentType, + fileSize = null, + status = FileUploadStatus.PENDING, + createdAt = null, + expiresAt = expiresAt, + completedAt = null, + ) + + fileUploadRepository.save(fileUpload).flatMap { savedUpload -> + fileStorage + .generatePresignedUploadUrl(filePath, contentType, uploadProperties.expirationMinutes) + .map { uploadUrl -> + PresignedUploadResult( + uploadId = savedUpload.id!!, + uploadUrl = uploadUrl, + expiresAt = expiresAt, + ) + } + } + } + } + + fun verifyAndCompleteUpload( + uploadId: UUID, + userId: UUID, + ): Mono = + fileUploadRepository + .findByIdAndUserId(uploadId, userId) + .switchIfEmpty( + Mono.error(IllegalArgumentException("Upload not found or does not belong to user")), + ).flatMap { upload -> + if (upload.status != FileUploadStatus.PENDING) { + if (upload.status == FileUploadStatus.COMPLETED) { + return@flatMap Mono.just( + FileUploadMetadata( + uploadId = upload.id!!, + filePath = upload.filePath, + originalFileName = upload.originalFileName, + contentType = upload.contentType, + fileSize = upload.fileSize!!, + completedAt = upload.completedAt!!, + ), + ) + } + return@flatMap Mono.error( + IllegalStateException("Upload is not in PENDING status"), + ) + } + + if (upload.expiresAt.isBefore(Instant.now())) { + return@flatMap Mono.error( + IllegalStateException("Upload has expired"), + ) + } + + fileStorage.exists(upload.filePath).flatMap { exists -> + if (!exists) { + return@flatMap Mono.error( + IllegalStateException("File has not been uploaded to storage"), + ) + } + + fileStorage.getObjectSize(upload.filePath).flatMap { fileSize -> + if (fileSize > uploadProperties.maxFileSize) { + fileStorage.delete(upload.filePath).then( + Mono.error( + IllegalArgumentException( + "File size ($fileSize bytes) exceeds maximum allowed size " + + "(${uploadProperties.maxFileSize} bytes)", + ), + ), + ) + } else { + val completedAt = Instant.now() + val completedUpload = + upload.copy( + status = FileUploadStatus.COMPLETED, + fileSize = fileSize, + completedAt = completedAt, + ) + fileUploadRepository + .updateStatus(uploadId, FileUploadStatus.COMPLETED, fileSize, completedAt) + .then( + fileEventPublisher + .publishCompleted(completedUpload) + .onErrorResume { e -> + logger.warn("Failed to publish COMPLETED event for upload $uploadId", e) + Mono.empty() + }, + ).then( + Mono.just( + FileUploadMetadata( + uploadId = upload.id!!, + filePath = upload.filePath, + originalFileName = upload.originalFileName, + contentType = upload.contentType, + fileSize = fileSize, + completedAt = completedAt, + ), + ), + ) + } + } + } + } + + fun getUploadMetadata(uploadId: UUID): Mono = + fileUploadRepository + .findById(uploadId) + .switchIfEmpty( + Mono.error(IllegalArgumentException("Upload not found")), + ).flatMap { upload -> + if (upload.status != FileUploadStatus.COMPLETED) { + return@flatMap Mono.error( + IllegalStateException("Upload is not completed"), + ) + } + Mono.just( + FileUploadMetadata( + uploadId = upload.id!!, + filePath = upload.filePath, + originalFileName = upload.originalFileName, + contentType = upload.contentType, + fileSize = upload.fileSize!!, + completedAt = upload.completedAt!!, + ), + ) + } + + fun getDownloadUrl(uploadId: UUID): Mono = + fileUploadRepository + .findById(uploadId) + .switchIfEmpty( + Mono.error(IllegalArgumentException("Upload not found")), + ).flatMap { upload -> + if (upload.status != FileUploadStatus.COMPLETED) { + return@flatMap Mono.error( + IllegalStateException("Upload is not completed"), + ) + } + fileStorage.generatePresignedDownloadUrl(upload.filePath, uploadProperties.expirationMinutes) + } + + fun deleteUpload(uploadId: UUID): Mono = + currentUserProvider.getCurrentUserId().flatMap { userId -> + fileUploadRepository + .findByIdAndUserId(uploadId, userId) + .switchIfEmpty( + Mono.error(IllegalArgumentException("Upload not found or does not belong to user")), + ).flatMap { upload -> + fileStorage + .delete(upload.filePath) + .onErrorResume { e -> + logger.warn("Failed to delete file from storage: ${upload.filePath}", e) + Mono.empty() + }.then( + fileEventPublisher + .publishDeleted(upload) + .onErrorResume { e -> + logger.warn("Failed to publish DELETED event for upload $uploadId", e) + Mono.empty() + }, + ).then(fileUploadRepository.deleteById(uploadId)) + } + } + + @Scheduled(fixedDelayString = "\${upload.cleanup-interval-ms:300000}") + fun cleanupExpiredUploads() { + val now = Instant.now() + logger.info("Starting cleanup of expired uploads") + + fileUploadRepository + .findExpiredPending(now) + .flatMap { upload -> + logger.info("Cleaning up expired upload: ${upload.id}, path: ${upload.filePath}") + fileStorage + .delete(upload.filePath) + .onErrorResume { e -> + logger.warn("Failed to delete expired file from storage: ${upload.filePath}", e) + Mono.empty() + }.then(fileUploadRepository.deleteById(upload.id!!)) + }.doOnComplete { + logger.info("Completed cleanup of expired uploads") + }.subscribe() + } + + private fun sanitizeFileName(fileName: String): String = + fileName + .replace(Regex("[^a-zA-Z0-9._-]"), "_") + .take(200) +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/KafkaConfig.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/KafkaConfig.kt new file mode 100644 index 0000000..39f4afa --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/KafkaConfig.kt @@ -0,0 +1,36 @@ +package com.github.butvinmitmo.filesservice.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.butvinmitmo.shared.dto.events.FileEventData +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.boot.autoconfigure.kafka.KafkaProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonSerializer + +@Configuration +class KafkaConfig { + @Bean + fun producerFactory( + kafkaProperties: KafkaProperties, + objectMapper: ObjectMapper, + ): ProducerFactory { + val props = kafkaProperties.buildProducerProperties(null) + val jsonSerializer = + JsonSerializer(objectMapper).apply { + isAddTypeInfo = false + } + return DefaultKafkaProducerFactory( + props, + StringSerializer(), + jsonSerializer, + ) + } + + @Bean + fun kafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate = + KafkaTemplate(producerFactory) +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/MinioConfig.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/MinioConfig.kt new file mode 100644 index 0000000..9c27495 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/MinioConfig.kt @@ -0,0 +1,60 @@ +package com.github.butvinmitmo.filesservice.config + +import io.minio.BucketExistsArgs +import io.minio.MakeBucketArgs +import io.minio.MinioClient +import org.slf4j.LoggerFactory +import org.springframework.boot.ApplicationRunner +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(MinioProperties::class, UploadProperties::class) +class MinioConfig( + private val properties: MinioProperties, +) { + private val logger = LoggerFactory.getLogger(MinioConfig::class.java) + + @Bean + fun minioClient(): MinioClient = + MinioClient + .builder() + .endpoint(properties.endpoint) + .credentials(properties.accessKey, properties.secretKey) + .build() + + @Bean + fun presignedMinioClient(): MinioClient = + MinioClient + .builder() + .endpoint(properties.getPresignedUrlEndpoint()) + .credentials(properties.accessKey, properties.secretKey) + .region("us-east-1") + .build() + + @Bean + fun initializeBucket(minioClient: MinioClient): ApplicationRunner = + ApplicationRunner { + val bucketName = properties.bucket + val bucketExists = + minioClient.bucketExists( + BucketExistsArgs + .builder() + .bucket(bucketName) + .build(), + ) + + if (!bucketExists) { + minioClient.makeBucket( + MakeBucketArgs + .builder() + .bucket(bucketName) + .build(), + ) + logger.info("Created MinIO bucket: {}", bucketName) + } else { + logger.info("MinIO bucket already exists: {}", bucketName) + } + } +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/MinioProperties.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/MinioProperties.kt new file mode 100644 index 0000000..4e5c050 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/MinioProperties.kt @@ -0,0 +1,14 @@ +package com.github.butvinmitmo.filesservice.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "minio") +data class MinioProperties( + val endpoint: String, + val accessKey: String, + val secretKey: String, + val bucket: String, + val externalEndpoint: String? = null, +) { + fun getPresignedUrlEndpoint(): String = externalEndpoint ?: endpoint +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/SecurityConfig.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/SecurityConfig.kt new file mode 100644 index 0000000..5105011 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/SecurityConfig.kt @@ -0,0 +1,35 @@ +package com.github.butvinmitmo.filesservice.config + +import com.github.butvinmitmo.filesservice.infrastructure.security.GatewayAuthenticationWebFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.SecurityWebFiltersOrder +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.web.server.SecurityWebFilterChain + +@Configuration +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity(useAuthorizationManager = true) +class SecurityConfig( + private val gatewayAuthenticationWebFilter: GatewayAuthenticationWebFilter, +) { + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = + http + .csrf { it.disable() } + .authorizeExchange { + it + // Internal endpoints - no auth required for service-to-service calls + .pathMatchers("/internal/**") + .permitAll() + // Health and API docs + .pathMatchers("/actuator/**", "/api-docs/**") + .permitAll() + // All other requests require authentication + .anyExchange() + .authenticated() + }.addFilterAt(gatewayAuthenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .build() +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/UploadProperties.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/UploadProperties.kt new file mode 100644 index 0000000..5ec2bd2 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/config/UploadProperties.kt @@ -0,0 +1,11 @@ +package com.github.butvinmitmo.filesservice.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "upload") +data class UploadProperties( + val expirationMinutes: Int, + val maxFileSize: Long, + val allowedContentTypes: List, + val cleanupIntervalMs: Long, +) diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/domain/model/FileUpload.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/domain/model/FileUpload.kt new file mode 100644 index 0000000..7fd8358 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/domain/model/FileUpload.kt @@ -0,0 +1,17 @@ +package com.github.butvinmitmo.filesservice.domain.model + +import java.time.Instant +import java.util.UUID + +data class FileUpload( + val id: UUID?, + val userId: UUID, + val filePath: String, + val originalFileName: String, + val contentType: String, + val fileSize: Long?, + val status: FileUploadStatus, + val createdAt: Instant?, + val expiresAt: Instant, + val completedAt: Instant?, +) diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/domain/model/FileUploadStatus.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/domain/model/FileUploadStatus.kt new file mode 100644 index 0000000..63b4b84 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/domain/model/FileUploadStatus.kt @@ -0,0 +1,7 @@ +package com.github.butvinmitmo.filesservice.domain.model + +enum class FileUploadStatus { + PENDING, + COMPLETED, + EXPIRED, +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/exception/GlobalExceptionHandler.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..b1e5382 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/exception/GlobalExceptionHandler.kt @@ -0,0 +1,127 @@ +package com.github.butvinmitmo.filesservice.exception + +import com.github.butvinmitmo.shared.dto.ErrorResponse +import com.github.butvinmitmo.shared.dto.ValidationErrorResponse +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.access.AccessDeniedException +import org.springframework.validation.FieldError +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.bind.support.WebExchangeBindException +import org.springframework.web.server.ServerWebExchange +import java.time.Instant + +@RestControllerAdvice +class GlobalExceptionHandler { + private val logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + + @ExceptionHandler(WebExchangeBindException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun handleValidationExceptions( + ex: WebExchangeBindException, + exchange: ServerWebExchange, + ): ResponseEntity { + val errors = mutableMapOf() + + ex.bindingResult.allErrors.forEach { error -> + val fieldName = (error as? FieldError)?.field ?: "unknown" + val errorMessage = error.defaultMessage ?: "Invalid value" + errors[fieldName] = errorMessage + } + + val response = + ValidationErrorResponse( + error = "VALIDATION_ERROR", + message = "Validation failed", + timestamp = Instant.now(), + path = exchange.request.path.value(), + fieldErrors = errors, + ) + + return ResponseEntity(response, HttpStatus.BAD_REQUEST) + } + + @ExceptionHandler(IllegalArgumentException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun handleIllegalArgumentException( + ex: IllegalArgumentException, + exchange: ServerWebExchange, + ): ResponseEntity { + val response = + ErrorResponse( + error = "BAD_REQUEST", + message = ex.message ?: "Invalid request", + timestamp = Instant.now(), + path = exchange.request.path.value(), + ) + return ResponseEntity(response, HttpStatus.BAD_REQUEST) + } + + @ExceptionHandler(IllegalStateException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun handleIllegalStateException( + ex: IllegalStateException, + exchange: ServerWebExchange, + ): ResponseEntity { + val response = + ErrorResponse( + error = "BAD_REQUEST", + message = ex.message ?: "Invalid state", + timestamp = Instant.now(), + path = exchange.request.path.value(), + ) + return ResponseEntity(response, HttpStatus.BAD_REQUEST) + } + + @ExceptionHandler(NoSuchElementException::class) + @ResponseStatus(HttpStatus.NOT_FOUND) + fun handleNotFoundException( + ex: NoSuchElementException, + exchange: ServerWebExchange, + ): ResponseEntity { + val response = + ErrorResponse( + error = "NOT_FOUND", + message = ex.message ?: "Resource not found", + timestamp = Instant.now(), + path = exchange.request.path.value(), + ) + return ResponseEntity(response, HttpStatus.NOT_FOUND) + } + + @ExceptionHandler(AccessDeniedException::class) + @ResponseStatus(HttpStatus.FORBIDDEN) + fun handleAccessDeniedException( + ex: AccessDeniedException, + exchange: ServerWebExchange, + ): ResponseEntity { + val response = + ErrorResponse( + error = "FORBIDDEN", + message = "Access denied", + timestamp = Instant.now(), + path = exchange.request.path.value(), + ) + return ResponseEntity(response, HttpStatus.FORBIDDEN) + } + + @ExceptionHandler(Exception::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleGenericException( + ex: Exception, + exchange: ServerWebExchange, + ): ResponseEntity { + logger.error("Unexpected error on ${exchange.request.path.value()}", ex) + val response = + ErrorResponse( + error = "INTERNAL_SERVER_ERROR", + message = "An unexpected error occurred", + timestamp = Instant.now(), + path = exchange.request.path.value(), + ) + return ResponseEntity(response, HttpStatus.INTERNAL_SERVER_ERROR) + } +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/messaging/KafkaFileEventPublisher.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/messaging/KafkaFileEventPublisher.kt new file mode 100644 index 0000000..721d3bf --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/messaging/KafkaFileEventPublisher.kt @@ -0,0 +1,47 @@ +package com.github.butvinmitmo.filesservice.infrastructure.messaging + +import com.github.butvinmitmo.filesservice.application.interfaces.publisher.FileEventPublisher +import com.github.butvinmitmo.filesservice.domain.model.FileUpload +import com.github.butvinmitmo.filesservice.infrastructure.messaging.mapper.FileEventDataMapper +import com.github.butvinmitmo.shared.dto.events.EventType +import com.github.butvinmitmo.shared.dto.events.FileEventData +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.time.Instant + +@Component +class KafkaFileEventPublisher( + private val kafkaTemplate: KafkaTemplate, + private val mapper: FileEventDataMapper, + @Value("\${kafka.topics.files-events}") private val topic: String, +) : FileEventPublisher { + private val log = LoggerFactory.getLogger(javaClass) + + override fun publishCompleted(fileUpload: FileUpload): Mono = publish(fileUpload, EventType.CREATED) + + override fun publishDeleted(fileUpload: FileUpload): Mono = publish(fileUpload, EventType.DELETED) + + private fun publish( + fileUpload: FileUpload, + eventType: EventType, + ): Mono = + Mono + .fromCallable { + val eventData = mapper.toEventData(fileUpload) + val record = + ProducerRecord(topic, null, eventData.uploadId.toString(), eventData).apply { + headers().add("eventType", eventType.name.toByteArray()) + headers().add("timestamp", Instant.now().toString().toByteArray()) + } + kafkaTemplate.send(record).get() + log.debug("Published {} event for file upload {}", eventType, fileUpload.id) + }.subscribeOn(Schedulers.boundedElastic()) + .doOnError { e -> + log.error("Failed to publish {} event for file upload {}: {}", eventType, fileUpload.id, e.message) + }.then() +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/messaging/mapper/FileEventDataMapper.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/messaging/mapper/FileEventDataMapper.kt new file mode 100644 index 0000000..4908062 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/messaging/mapper/FileEventDataMapper.kt @@ -0,0 +1,19 @@ +package com.github.butvinmitmo.filesservice.infrastructure.messaging.mapper + +import com.github.butvinmitmo.filesservice.domain.model.FileUpload +import com.github.butvinmitmo.shared.dto.events.FileEventData +import org.springframework.stereotype.Component + +@Component +class FileEventDataMapper { + fun toEventData(fileUpload: FileUpload): FileEventData = + FileEventData( + uploadId = fileUpload.id!!, + filePath = fileUpload.filePath, + originalFileName = fileUpload.originalFileName, + contentType = fileUpload.contentType, + fileSize = fileUpload.fileSize, + userId = fileUpload.userId, + completedAt = fileUpload.completedAt, + ) +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/R2dbcFileUploadRepository.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/R2dbcFileUploadRepository.kt new file mode 100644 index 0000000..da44c9a --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/R2dbcFileUploadRepository.kt @@ -0,0 +1,56 @@ +package com.github.butvinmitmo.filesservice.infrastructure.persistence + +import com.github.butvinmitmo.filesservice.application.interfaces.repository.FileUploadRepository +import com.github.butvinmitmo.filesservice.domain.model.FileUpload +import com.github.butvinmitmo.filesservice.domain.model.FileUploadStatus +import com.github.butvinmitmo.filesservice.infrastructure.persistence.mapper.FileUploadEntityMapper +import com.github.butvinmitmo.filesservice.infrastructure.persistence.repository.SpringDataFileUploadRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Instant +import java.util.UUID + +@Repository +class R2dbcFileUploadRepository( + private val springDataFileUploadRepository: SpringDataFileUploadRepository, + private val fileUploadEntityMapper: FileUploadEntityMapper, +) : FileUploadRepository { + override fun save(fileUpload: FileUpload): Mono { + val entity = fileUploadEntityMapper.toEntity(fileUpload) + return springDataFileUploadRepository + .save(entity) + .flatMap { savedEntity -> + // Re-fetch to get database-generated fields (id, created_at) + springDataFileUploadRepository + .findById(savedEntity.id!!) + .map { fileUploadEntityMapper.toDomain(it) } + } + } + + override fun findById(id: UUID): Mono = + springDataFileUploadRepository.findById(id).map { fileUploadEntityMapper.toDomain(it) } + + override fun findByIdAndUserId( + id: UUID, + userId: UUID, + ): Mono = + springDataFileUploadRepository + .findByIdAndUserId(id, userId) + .map { fileUploadEntityMapper.toDomain(it) } + + override fun updateStatus( + id: UUID, + status: FileUploadStatus, + fileSize: Long?, + completedAt: Instant?, + ): Mono = + springDataFileUploadRepository + .updateStatus(id, status.name, fileSize, completedAt) + .then() + + override fun findExpiredPending(now: Instant): Flux = + springDataFileUploadRepository.findExpiredPending(now).map { fileUploadEntityMapper.toDomain(it) } + + override fun deleteById(id: UUID): Mono = springDataFileUploadRepository.deleteById(id) +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/entity/FileUploadEntity.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/entity/FileUploadEntity.kt new file mode 100644 index 0000000..26ea28b --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/entity/FileUploadEntity.kt @@ -0,0 +1,31 @@ +package com.github.butvinmitmo.filesservice.infrastructure.persistence.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant +import java.util.UUID + +@Table("file_upload") +data class FileUploadEntity( + @Id + val id: UUID? = null, + @Column("user_id") + val userId: UUID, + @Column("file_path") + val filePath: String, + @Column("original_file_name") + val originalFileName: String, + @Column("content_type") + val contentType: String, + @Column("file_size") + val fileSize: Long?, + @Column("status") + val status: String, + @Column("created_at") + val createdAt: Instant? = null, + @Column("expires_at") + val expiresAt: Instant, + @Column("completed_at") + val completedAt: Instant?, +) diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/mapper/FileUploadEntityMapper.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/mapper/FileUploadEntityMapper.kt new file mode 100644 index 0000000..7b9753a --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/mapper/FileUploadEntityMapper.kt @@ -0,0 +1,37 @@ +package com.github.butvinmitmo.filesservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.filesservice.domain.model.FileUpload +import com.github.butvinmitmo.filesservice.domain.model.FileUploadStatus +import com.github.butvinmitmo.filesservice.infrastructure.persistence.entity.FileUploadEntity +import org.springframework.stereotype.Component + +@Component +class FileUploadEntityMapper { + fun toDomain(entity: FileUploadEntity): FileUpload = + FileUpload( + id = entity.id!!, + userId = entity.userId, + filePath = entity.filePath, + originalFileName = entity.originalFileName, + contentType = entity.contentType, + fileSize = entity.fileSize, + status = FileUploadStatus.valueOf(entity.status), + createdAt = entity.createdAt!!, + expiresAt = entity.expiresAt, + completedAt = entity.completedAt, + ) + + fun toEntity(fileUpload: FileUpload): FileUploadEntity = + FileUploadEntity( + id = fileUpload.id, + userId = fileUpload.userId, + filePath = fileUpload.filePath, + originalFileName = fileUpload.originalFileName, + contentType = fileUpload.contentType, + fileSize = fileUpload.fileSize, + status = fileUpload.status.name, + createdAt = fileUpload.createdAt, + expiresAt = fileUpload.expiresAt, + completedAt = fileUpload.completedAt, + ) +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/repository/SpringDataFileUploadRepository.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/repository/SpringDataFileUploadRepository.kt new file mode 100644 index 0000000..e135837 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/persistence/repository/SpringDataFileUploadRepository.kt @@ -0,0 +1,40 @@ +package com.github.butvinmitmo.filesservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.filesservice.infrastructure.persistence.entity.FileUploadEntity +import org.springframework.data.r2dbc.repository.Modifying +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Instant +import java.util.UUID + +interface SpringDataFileUploadRepository : R2dbcRepository { + fun findByIdAndUserId( + id: UUID, + userId: UUID, + ): Mono + + @Modifying + @Query( + """ + UPDATE file_upload + SET status = :status, file_size = :fileSize, completed_at = :completedAt + WHERE id = :id + """, + ) + fun updateStatus( + id: UUID, + status: String, + fileSize: Long?, + completedAt: Instant?, + ): Mono + + @Query( + """ + SELECT * FROM file_upload + WHERE status = 'PENDING' AND expires_at < :now + """, + ) + fun findExpiredPending(now: Instant): Flux +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/security/GatewayAuthenticationWebFilter.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/security/GatewayAuthenticationWebFilter.kt new file mode 100644 index 0000000..30fe547 --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/security/GatewayAuthenticationWebFilter.kt @@ -0,0 +1,38 @@ +package com.github.butvinmitmo.filesservice.infrastructure.security + +import com.github.butvinmitmo.shared.security.GatewayAuthenticationToken +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono +import java.util.UUID + +@Component +class GatewayAuthenticationWebFilter : WebFilter { + override fun filter( + exchange: ServerWebExchange, + chain: WebFilterChain, + ): Mono { + val userIdHeader = exchange.request.headers.getFirst("X-User-Id") + val roleHeader = exchange.request.headers.getFirst("X-User-Role") + + return if (userIdHeader != null && roleHeader != null) { + try { + val userId = UUID.fromString(userIdHeader) + val authentication = GatewayAuthenticationToken(userId, roleHeader) + val securityContext = SecurityContextImpl(authentication) + chain + .filter( + exchange, + ).contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext))) + } catch (e: IllegalArgumentException) { + chain.filter(exchange) + } + } else { + chain.filter(exchange) + } + } +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/security/SecurityContextCurrentUserProvider.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/security/SecurityContextCurrentUserProvider.kt new file mode 100644 index 0000000..1d103af --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/security/SecurityContextCurrentUserProvider.kt @@ -0,0 +1,16 @@ +package com.github.butvinmitmo.filesservice.infrastructure.security + +import com.github.butvinmitmo.filesservice.application.interfaces.provider.CurrentUserProvider +import com.github.butvinmitmo.shared.security.GatewayAuthenticationToken +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import java.util.UUID + +@Component +class SecurityContextCurrentUserProvider : CurrentUserProvider { + override fun getCurrentUserId(): Mono = + ReactiveSecurityContextHolder + .getContext() + .map { (it.authentication as GatewayAuthenticationToken).userId } +} diff --git a/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/storage/MinioFileStorage.kt b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/storage/MinioFileStorage.kt new file mode 100644 index 0000000..b78a0ea --- /dev/null +++ b/files-service/src/main/kotlin/com/github/butvinmitmo/filesservice/infrastructure/storage/MinioFileStorage.kt @@ -0,0 +1,104 @@ +package com.github.butvinmitmo.filesservice.infrastructure.storage + +import com.github.butvinmitmo.filesservice.application.interfaces.storage.FileStorage +import com.github.butvinmitmo.filesservice.config.MinioProperties +import io.minio.GetPresignedObjectUrlArgs +import io.minio.MinioClient +import io.minio.RemoveObjectArgs +import io.minio.StatObjectArgs +import io.minio.http.Method +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.util.concurrent.TimeUnit + +@Component +class MinioFileStorage( + private val minioClient: MinioClient, + private val presignedMinioClient: MinioClient, + private val minioProperties: MinioProperties, +) : FileStorage { + override fun generatePresignedUploadUrl( + path: String, + contentType: String, + expirationMinutes: Int, + ): Mono = + Mono + .fromCallable { + presignedMinioClient.getPresignedObjectUrl( + GetPresignedObjectUrlArgs + .builder() + .method(Method.PUT) + .bucket(minioProperties.bucket) + .`object`(path) + .expiry(expirationMinutes, TimeUnit.MINUTES) + .extraHeaders(mapOf("Content-Type" to contentType)) + .build(), + ) + }.subscribeOn(Schedulers.boundedElastic()) + + override fun generatePresignedDownloadUrl( + path: String, + expirationMinutes: Int, + ): Mono = + Mono + .fromCallable { + presignedMinioClient.getPresignedObjectUrl( + GetPresignedObjectUrlArgs + .builder() + .method(Method.GET) + .bucket(minioProperties.bucket) + .`object`(path) + .expiry(expirationMinutes, TimeUnit.MINUTES) + .build(), + ) + }.subscribeOn(Schedulers.boundedElastic()) + + override fun exists(path: String): Mono = + Mono + .fromCallable { + try { + minioClient.statObject( + StatObjectArgs + .builder() + .bucket(minioProperties.bucket) + .`object`(path) + .build(), + ) + true + } catch (e: io.minio.errors.ErrorResponseException) { + if (e.errorResponse().code() == "NoSuchKey") { + false + } else { + throw e + } + } + }.subscribeOn(Schedulers.boundedElastic()) + + override fun getObjectSize(path: String): Mono = + Mono + .fromCallable { + val stat = + minioClient.statObject( + StatObjectArgs + .builder() + .bucket(minioProperties.bucket) + .`object`(path) + .build(), + ) + stat.size() + }.subscribeOn(Schedulers.boundedElastic()) + + override fun delete(path: String): Mono = + Mono + .fromCallable { + minioClient.removeObject( + RemoveObjectArgs + .builder() + .bucket(minioProperties.bucket) + .`object`(path) + .build(), + ) + }.subscribeOn(Schedulers.boundedElastic()) + .then() +} diff --git a/files-service/src/main/resources/application.yml b/files-service/src/main/resources/application.yml new file mode 100644 index 0000000..f0f0f8c --- /dev/null +++ b/files-service/src/main/resources/application.yml @@ -0,0 +1,17 @@ +spring: + application: + name: files-service + config: + import: optional:configserver:${CONFIG_SERVER_URL:http://localhost:8888} + cloud: + config: + fail-fast: false + retry: + max-attempts: 6 + initial-interval: 1000 + multiplier: 1.1 + max-interval: 2000 + flyway: + enabled: true + locations: classpath:db/migration + table: flyway_schema_history_files diff --git a/files-service/src/main/resources/db/migration/V1__create_file_upload_table.sql b/files-service/src/main/resources/db/migration/V1__create_file_upload_table.sql new file mode 100644 index 0000000..e7dd970 --- /dev/null +++ b/files-service/src/main/resources/db/migration/V1__create_file_upload_table.sql @@ -0,0 +1,19 @@ +-- Ensure UUID extension exists (may already be created by other services) +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS file_upload ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL, + file_path VARCHAR(512) NOT NULL, + original_file_name VARCHAR(256) NOT NULL, + content_type VARCHAR(128) NOT NULL, + file_size BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_file_upload_user_id ON file_upload(user_id); +CREATE INDEX IF NOT EXISTS idx_file_upload_status ON file_upload(status); +CREATE INDEX IF NOT EXISTS idx_file_upload_expires_at ON file_upload(expires_at) WHERE status = 'PENDING'; diff --git a/files-service/src/test/kotlin/com/github/butvinmitmo/filesservice/integration/controller/FileUploadControllerIntegrationTest.kt b/files-service/src/test/kotlin/com/github/butvinmitmo/filesservice/integration/controller/FileUploadControllerIntegrationTest.kt new file mode 100644 index 0000000..58cfe85 --- /dev/null +++ b/files-service/src/test/kotlin/com/github/butvinmitmo/filesservice/integration/controller/FileUploadControllerIntegrationTest.kt @@ -0,0 +1,469 @@ +package com.github.butvinmitmo.filesservice.integration.controller + +import com.github.butvinmitmo.filesservice.api.dto.PresignedUploadRequest +import com.github.butvinmitmo.filesservice.api.dto.PresignedUploadResponse +import com.github.butvinmitmo.filesservice.application.interfaces.publisher.FileEventPublisher +import com.github.butvinmitmo.filesservice.infrastructure.persistence.entity.FileUploadEntity +import com.github.butvinmitmo.filesservice.infrastructure.persistence.repository.SpringDataFileUploadRepository +import io.minio.BucketExistsArgs +import io.minio.MakeBucketArgs +import io.minio.MinioClient +import io.minio.PutObjectArgs +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.reactive.server.WebTestClient +import org.testcontainers.containers.MinIOContainer +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import reactor.core.publisher.Mono +import java.io.ByteArrayInputStream +import java.time.Instant +import java.util.UUID + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Testcontainers +class FileUploadControllerIntegrationTest { + @Autowired + private lateinit var webTestClient: WebTestClient + + @Autowired + private lateinit var springDataFileUploadRepository: SpringDataFileUploadRepository + + @MockBean + private lateinit var fileEventPublisher: FileEventPublisher + + private val testUserId = UUID.randomUUID() + private val testBucket = "test-bucket" + + @BeforeEach + fun setup() { + ensureBucketExists() + whenever(fileEventPublisher.publishCompleted(any())).thenReturn(Mono.empty()) + whenever(fileEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) + } + + @AfterEach + fun cleanup() { + springDataFileUploadRepository.deleteAll().block() + } + + private fun ensureBucketExists() { + val minioClient = createMinioClient() + if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(testBucket).build())) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(testBucket).build()) + } + } + + private fun createMinioClient(): MinioClient = + MinioClient + .builder() + .endpoint(minio.s3URL) + .credentials(minio.userName, minio.password) + .build() + + @Test + fun `requestPresignedUpload should return presigned URL for valid content type`() { + val request = + PresignedUploadRequest( + fileName = "test-image.jpg", + contentType = "image/jpeg", + ) + + webTestClient + .post() + .uri("/api/v0.0.1/files/presigned-upload") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isOk + .expectBody(PresignedUploadResponse::class.java) + .consumeWith { response -> + val body = response.responseBody!! + assert(body.uploadId != null) { "uploadId should not be null" } + assert(body.uploadUrl.isNotEmpty()) { "uploadUrl should not be empty" } + assert(body.uploadUrl.startsWith("http")) { "uploadUrl should be a valid URL" } + assert(body.expiresAt.isAfter(Instant.now())) { "expiresAt should be in the future" } + } + } + + @Test + fun `requestPresignedUpload should reject invalid content type`() { + val request = + PresignedUploadRequest( + fileName = "document.pdf", + contentType = "application/pdf", + ) + + webTestClient + .post() + .uri("/api/v0.0.1/files/presigned-upload") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isBadRequest + } + + @Test + fun `requestPresignedUpload should return 401 without auth headers`() { + val request = + PresignedUploadRequest( + fileName = "test-image.jpg", + contentType = "image/jpeg", + ) + + webTestClient + .post() + .uri("/api/v0.0.1/files/presigned-upload") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `getUploadMetadata should return metadata for completed upload`() { + // Use a fixed file path that we can create before the entity + val content = "test image content".toByteArray() + val fileId = UUID.randomUUID() + val filePath = "$testUserId/$fileId/test-image.jpg" + + // Upload a file to MinIO first + val minioClient = createMinioClient() + minioClient.putObject( + PutObjectArgs + .builder() + .bucket(testBucket) + .`object`(filePath) + .stream(ByteArrayInputStream(content), content.size.toLong(), -1) + .contentType("image/jpeg") + .build(), + ) + + // Create a completed upload in the database with the file path + val entity = + FileUploadEntity( + id = null, + userId = testUserId, + filePath = filePath, + originalFileName = "test-image.jpg", + contentType = "image/jpeg", + fileSize = content.size.toLong(), + status = "COMPLETED", + createdAt = null, + expiresAt = Instant.now().plusSeconds(3600), + completedAt = Instant.now(), + ) + val savedEntity = springDataFileUploadRepository.save(entity).block()!! + val uploadId = savedEntity.id!! + + webTestClient + .get() + .uri("/api/v0.0.1/files/$uploadId") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.uploadId") + .isEqualTo(uploadId.toString()) + .jsonPath("$.originalFileName") + .isEqualTo("test-image.jpg") + .jsonPath("$.contentType") + .isEqualTo("image/jpeg") + .jsonPath("$.fileSize") + .isEqualTo(content.size) + } + + @Test + fun `getUploadMetadata should return error for pending upload`() { + val entity = + FileUploadEntity( + id = null, + userId = testUserId, + filePath = "temp-path", + originalFileName = "test-image.jpg", + contentType = "image/jpeg", + fileSize = null, + status = "PENDING", + createdAt = null, + expiresAt = Instant.now().plusSeconds(3600), + completedAt = null, + ) + val savedEntity = springDataFileUploadRepository.save(entity).block()!! + val uploadId = savedEntity.id!! + + webTestClient + .get() + .uri("/api/v0.0.1/files/$uploadId") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isBadRequest + } + + @Test + fun `getDownloadUrl should return presigned download URL for completed upload`() { + val content = "test image content".toByteArray() + val fileId = UUID.randomUUID() + val filePath = "$testUserId/$fileId/test-image.jpg" + + // Upload a file to MinIO first + val minioClient = createMinioClient() + minioClient.putObject( + PutObjectArgs + .builder() + .bucket(testBucket) + .`object`(filePath) + .stream(ByteArrayInputStream(content), content.size.toLong(), -1) + .contentType("image/jpeg") + .build(), + ) + + val entity = + FileUploadEntity( + id = null, + userId = testUserId, + filePath = filePath, + originalFileName = "test-image.jpg", + contentType = "image/jpeg", + fileSize = content.size.toLong(), + status = "COMPLETED", + createdAt = null, + expiresAt = Instant.now().plusSeconds(3600), + completedAt = Instant.now(), + ) + val savedEntity = springDataFileUploadRepository.save(entity).block()!! + val uploadId = savedEntity.id!! + + webTestClient + .get() + .uri("/api/v0.0.1/files/$uploadId/download-url") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.downloadUrl") + .isNotEmpty + } + + @Test + fun `deleteUpload should delete file and record`() { + val content = "test image content".toByteArray() + val fileId = UUID.randomUUID() + val filePath = "$testUserId/$fileId/test-image.jpg" + + // Upload a file to MinIO first + val minioClient = createMinioClient() + minioClient.putObject( + PutObjectArgs + .builder() + .bucket(testBucket) + .`object`(filePath) + .stream(ByteArrayInputStream(content), content.size.toLong(), -1) + .contentType("image/jpeg") + .build(), + ) + + val entity = + FileUploadEntity( + id = null, + userId = testUserId, + filePath = filePath, + originalFileName = "test-image.jpg", + contentType = "image/jpeg", + fileSize = content.size.toLong(), + status = "COMPLETED", + createdAt = null, + expiresAt = Instant.now().plusSeconds(3600), + completedAt = Instant.now(), + ) + val savedEntity = springDataFileUploadRepository.save(entity).block()!! + val uploadId = savedEntity.id!! + + webTestClient + .delete() + .uri("/api/v0.0.1/files/$uploadId") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isNoContent + + // Verify record is deleted + val deleted = springDataFileUploadRepository.findById(uploadId).block() + assert(deleted == null) { "Upload record should be deleted" } + } + + @Test + fun `deleteUpload should reject upload belonging to different user`() { + val anotherUserId = UUID.randomUUID() + + val entity = + FileUploadEntity( + id = null, + userId = anotherUserId, + filePath = "temp-path", + originalFileName = "test-image.jpg", + contentType = "image/jpeg", + fileSize = 1024L, + status = "COMPLETED", + createdAt = null, + expiresAt = Instant.now().plusSeconds(3600), + completedAt = Instant.now(), + ) + val savedEntity = springDataFileUploadRepository.save(entity).block()!! + val uploadId = savedEntity.id!! + + webTestClient + .delete() + .uri("/api/v0.0.1/files/$uploadId") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isBadRequest + + // Verify record is NOT deleted + val stillExists = springDataFileUploadRepository.findById(uploadId).block() + assert(stillExists != null) { "Upload record should still exist" } + } + + @Test + fun `internal verify endpoint should complete pending upload when file exists`() { + val content = "test image content".toByteArray() + val fileId = UUID.randomUUID() + val filePath = "$testUserId/$fileId/test-image.jpg" + + // Upload a file to MinIO first + val minioClient = createMinioClient() + minioClient.putObject( + PutObjectArgs + .builder() + .bucket(testBucket) + .`object`(filePath) + .stream(ByteArrayInputStream(content), content.size.toLong(), -1) + .contentType("image/jpeg") + .build(), + ) + + val entity = + FileUploadEntity( + id = null, + userId = testUserId, + filePath = filePath, + originalFileName = "test-image.jpg", + contentType = "image/jpeg", + fileSize = null, + status = "PENDING", + createdAt = null, + expiresAt = Instant.now().plusSeconds(3600), + completedAt = null, + ) + val savedEntity = springDataFileUploadRepository.save(entity).block()!! + val uploadId = savedEntity.id!! + + webTestClient + .post() + .uri("/internal/files/$uploadId/verify?userId=$testUserId") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.uploadId") + .isEqualTo(uploadId.toString()) + .jsonPath("$.fileSize") + .isEqualTo(content.size) + + // Verify status is now COMPLETED + val updated = springDataFileUploadRepository.findById(uploadId).block()!! + assert(updated.status == "COMPLETED") { "Upload status should be COMPLETED" } + assert(updated.fileSize == content.size.toLong()) { "File size should be set" } + } + + @Test + fun `internal verify endpoint should reject wrong user`() { + val wrongUserId = UUID.randomUUID() + + val entity = + FileUploadEntity( + id = null, + userId = testUserId, + filePath = "temp-path", + originalFileName = "test-image.jpg", + contentType = "image/jpeg", + fileSize = null, + status = "PENDING", + createdAt = null, + expiresAt = Instant.now().plusSeconds(3600), + completedAt = null, + ) + val savedEntity = springDataFileUploadRepository.save(entity).block()!! + val uploadId = savedEntity.id!! + + webTestClient + .post() + .uri("/internal/files/$uploadId/verify?userId=$wrongUserId") + .exchange() + .expectStatus() + .isBadRequest + } + + companion object { + @Container + @JvmStatic + val postgres: PostgreSQLContainer<*> = + PostgreSQLContainer("postgres:15-alpine") + .withDatabaseName("tarot_db_test") + .withUsername("test_user") + .withPassword("test_password") + + @Container + @JvmStatic + val minio: MinIOContainer = + MinIOContainer("minio/minio:latest") + .withUserName("minioadmin") + .withPassword("minioadmin") + + @JvmStatic + @DynamicPropertySource + fun configureProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.r2dbc.url") { + "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(5432)}/${postgres.databaseName}" + } + registry.add("spring.r2dbc.username") { postgres.username } + registry.add("spring.r2dbc.password") { postgres.password } + registry.add("spring.flyway.url") { postgres.jdbcUrl } + registry.add("spring.flyway.user") { postgres.username } + registry.add("spring.flyway.password") { postgres.password } + registry.add("spring.flyway.enabled") { "true" } + registry.add("minio.endpoint") { minio.s3URL } + registry.add("minio.external-endpoint") { minio.s3URL } + registry.add("minio.access-key") { minio.userName } + registry.add("minio.secret-key") { minio.password } + registry.add("minio.bucket") { "test-bucket" } + } + } +} diff --git a/files-service/src/test/kotlin/com/github/butvinmitmo/filesservice/unit/service/FileUploadServiceTest.kt b/files-service/src/test/kotlin/com/github/butvinmitmo/filesservice/unit/service/FileUploadServiceTest.kt new file mode 100644 index 0000000..34af2ff --- /dev/null +++ b/files-service/src/test/kotlin/com/github/butvinmitmo/filesservice/unit/service/FileUploadServiceTest.kt @@ -0,0 +1,332 @@ +package com.github.butvinmitmo.filesservice.unit.service + +import com.github.butvinmitmo.filesservice.application.interfaces.provider.CurrentUserProvider +import com.github.butvinmitmo.filesservice.application.interfaces.publisher.FileEventPublisher +import com.github.butvinmitmo.filesservice.application.interfaces.repository.FileUploadRepository +import com.github.butvinmitmo.filesservice.application.interfaces.storage.FileStorage +import com.github.butvinmitmo.filesservice.application.service.FileUploadService +import com.github.butvinmitmo.filesservice.config.UploadProperties +import com.github.butvinmitmo.filesservice.domain.model.FileUpload +import com.github.butvinmitmo.filesservice.domain.model.FileUploadStatus +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import reactor.core.publisher.Mono +import reactor.test.StepVerifier +import java.time.Instant +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class FileUploadServiceTest { + @Mock + private lateinit var fileUploadRepository: FileUploadRepository + + @Mock + private lateinit var fileStorage: FileStorage + + @Mock + private lateinit var currentUserProvider: CurrentUserProvider + + @Mock + private lateinit var fileEventPublisher: FileEventPublisher + + private lateinit var uploadProperties: UploadProperties + private lateinit var fileUploadService: FileUploadService + + private val testUserId = UUID.randomUUID() + private val testUploadId = UUID.randomUUID() + + @BeforeEach + fun setup() { + uploadProperties = + UploadProperties( + expirationMinutes = 60, + maxFileSize = 5242880L, + allowedContentTypes = listOf("image/jpeg", "image/png", "image/gif", "image/webp"), + cleanupIntervalMs = 300000L, + ) + + fileUploadService = + FileUploadService( + fileUploadRepository, + fileStorage, + currentUserProvider, + uploadProperties, + fileEventPublisher, + ) + } + + @Test + fun `requestUpload should create pending upload and return presigned URL`() { + val savedUpload = createTestFileUpload(testUploadId, testUserId, FileUploadStatus.PENDING) + val presignedUrl = "https://minio.example.com/presigned-upload-url" + + whenever(currentUserProvider.getCurrentUserId()).thenReturn(Mono.just(testUserId)) + whenever(fileUploadRepository.save(any())).thenReturn(Mono.just(savedUpload)) + whenever(fileStorage.generatePresignedUploadUrl(any(), eq("image/jpeg"), eq(60))) + .thenReturn(Mono.just(presignedUrl)) + + StepVerifier + .create(fileUploadService.requestUpload("test-image.jpg", "image/jpeg")) + .assertNext { result -> + assert(result.uploadId == testUploadId) + assert(result.uploadUrl == presignedUrl) + }.verifyComplete() + + verify(fileUploadRepository).save( + argThat { upload: FileUpload -> + upload.userId == testUserId && + upload.contentType == "image/jpeg" && + upload.status == FileUploadStatus.PENDING + }, + ) + } + + @Test + fun `requestUpload should reject invalid content type`() { + StepVerifier + .create(fileUploadService.requestUpload("document.pdf", "application/pdf")) + .expectErrorMatches { error -> + error is IllegalArgumentException && + error.message?.contains("Content type 'application/pdf' is not allowed") == true + }.verify() + + verify(fileUploadRepository, never()).save(any()) + verify(fileStorage, never()).generatePresignedUploadUrl(any(), any(), any()) + } + + @Test + fun `verifyAndCompleteUpload should complete upload when file exists and is valid`() { + val pendingUpload = createTestFileUpload(testUploadId, testUserId, FileUploadStatus.PENDING) + val fileSize = 1024L + + whenever(fileUploadRepository.findByIdAndUserId(testUploadId, testUserId)) + .thenReturn(Mono.just(pendingUpload)) + whenever(fileStorage.exists(pendingUpload.filePath)).thenReturn(Mono.just(true)) + whenever(fileStorage.getObjectSize(pendingUpload.filePath)).thenReturn(Mono.just(fileSize)) + whenever( + fileUploadRepository.updateStatus(eq(testUploadId), eq(FileUploadStatus.COMPLETED), eq(fileSize), any()), + ).thenReturn(Mono.empty()) + whenever(fileEventPublisher.publishCompleted(any())).thenReturn(Mono.empty()) + + StepVerifier + .create(fileUploadService.verifyAndCompleteUpload(testUploadId, testUserId)) + .assertNext { metadata -> + assert(metadata.uploadId == testUploadId) + assert(metadata.fileSize == fileSize) + assert(metadata.originalFileName == "test-file.jpg") + }.verifyComplete() + } + + @Test + fun `verifyAndCompleteUpload should reject upload belonging to different user`() { + val differentUserId = UUID.randomUUID() + + whenever(fileUploadRepository.findByIdAndUserId(testUploadId, differentUserId)) + .thenReturn(Mono.empty()) + + StepVerifier + .create(fileUploadService.verifyAndCompleteUpload(testUploadId, differentUserId)) + .expectErrorMatches { error -> + error is IllegalArgumentException && + error.message?.contains("Upload not found or does not belong to user") == true + }.verify() + } + + @Test + fun `verifyAndCompleteUpload should return existing metadata for already completed upload`() { + val completedUpload = + createTestFileUpload(testUploadId, testUserId, FileUploadStatus.COMPLETED) + .copy(fileSize = 2048L, completedAt = Instant.now()) + + whenever(fileUploadRepository.findByIdAndUserId(testUploadId, testUserId)) + .thenReturn(Mono.just(completedUpload)) + + StepVerifier + .create(fileUploadService.verifyAndCompleteUpload(testUploadId, testUserId)) + .assertNext { metadata -> + assert(metadata.uploadId == testUploadId) + assert(metadata.fileSize == 2048L) + }.verifyComplete() + + verify(fileStorage, never()).exists(any()) + } + + @Test + fun `verifyAndCompleteUpload should reject expired upload`() { + val expiredUpload = + createTestFileUpload(testUploadId, testUserId, FileUploadStatus.PENDING) + .copy(expiresAt = Instant.now().minusSeconds(3600)) + + whenever(fileUploadRepository.findByIdAndUserId(testUploadId, testUserId)) + .thenReturn(Mono.just(expiredUpload)) + + StepVerifier + .create(fileUploadService.verifyAndCompleteUpload(testUploadId, testUserId)) + .expectErrorMatches { error -> + error is IllegalStateException && + error.message?.contains("Upload has expired") == true + }.verify() + } + + @Test + fun `verifyAndCompleteUpload should reject when file not uploaded to storage`() { + val pendingUpload = createTestFileUpload(testUploadId, testUserId, FileUploadStatus.PENDING) + + whenever(fileUploadRepository.findByIdAndUserId(testUploadId, testUserId)) + .thenReturn(Mono.just(pendingUpload)) + whenever(fileStorage.exists(pendingUpload.filePath)).thenReturn(Mono.just(false)) + + StepVerifier + .create(fileUploadService.verifyAndCompleteUpload(testUploadId, testUserId)) + .expectErrorMatches { error -> + error is IllegalStateException && + error.message?.contains("File has not been uploaded to storage") == true + }.verify() + } + + @Test + fun `verifyAndCompleteUpload should reject file exceeding max size`() { + val pendingUpload = createTestFileUpload(testUploadId, testUserId, FileUploadStatus.PENDING) + val oversizedFileSize = uploadProperties.maxFileSize + 1 + + whenever(fileUploadRepository.findByIdAndUserId(testUploadId, testUserId)) + .thenReturn(Mono.just(pendingUpload)) + whenever(fileStorage.exists(pendingUpload.filePath)).thenReturn(Mono.just(true)) + whenever(fileStorage.getObjectSize(pendingUpload.filePath)).thenReturn(Mono.just(oversizedFileSize)) + whenever(fileStorage.delete(pendingUpload.filePath)).thenReturn(Mono.empty()) + + StepVerifier + .create(fileUploadService.verifyAndCompleteUpload(testUploadId, testUserId)) + .expectErrorMatches { error -> + error is IllegalArgumentException && + error.message?.contains("exceeds maximum allowed size") == true + }.verify() + + verify(fileStorage).delete(pendingUpload.filePath) + } + + @Test + fun `getUploadMetadata should return metadata for completed upload`() { + val completedUpload = + createTestFileUpload(testUploadId, testUserId, FileUploadStatus.COMPLETED) + .copy(fileSize = 1024L, completedAt = Instant.now()) + + whenever(fileUploadRepository.findById(testUploadId)).thenReturn(Mono.just(completedUpload)) + + StepVerifier + .create(fileUploadService.getUploadMetadata(testUploadId)) + .assertNext { metadata -> + assert(metadata.uploadId == testUploadId) + assert(metadata.originalFileName == "test-file.jpg") + assert(metadata.contentType == "image/jpeg") + }.verifyComplete() + } + + @Test + fun `getUploadMetadata should reject when upload not found`() { + whenever(fileUploadRepository.findById(testUploadId)).thenReturn(Mono.empty()) + + StepVerifier + .create(fileUploadService.getUploadMetadata(testUploadId)) + .expectErrorMatches { error -> + error is IllegalArgumentException && + error.message?.contains("Upload not found") == true + }.verify() + } + + @Test + fun `getUploadMetadata should reject when upload not completed`() { + val pendingUpload = createTestFileUpload(testUploadId, testUserId, FileUploadStatus.PENDING) + + whenever(fileUploadRepository.findById(testUploadId)).thenReturn(Mono.just(pendingUpload)) + + StepVerifier + .create(fileUploadService.getUploadMetadata(testUploadId)) + .expectErrorMatches { error -> + error is IllegalStateException && + error.message?.contains("Upload is not completed") == true + }.verify() + } + + @Test + fun `getDownloadUrl should generate presigned URL for completed upload`() { + val completedUpload = + createTestFileUpload(testUploadId, testUserId, FileUploadStatus.COMPLETED) + .copy(fileSize = 1024L, completedAt = Instant.now()) + val downloadUrl = "https://minio.example.com/presigned-download-url" + + whenever(fileUploadRepository.findById(testUploadId)).thenReturn(Mono.just(completedUpload)) + whenever(fileStorage.generatePresignedDownloadUrl(completedUpload.filePath, 60)) + .thenReturn(Mono.just(downloadUrl)) + + StepVerifier + .create(fileUploadService.getDownloadUrl(testUploadId)) + .assertNext { url -> + assert(url == downloadUrl) + }.verifyComplete() + } + + @Test + fun `deleteUpload should delete file from storage and database`() { + val upload = createTestFileUpload(testUploadId, testUserId, FileUploadStatus.COMPLETED) + + whenever(currentUserProvider.getCurrentUserId()).thenReturn(Mono.just(testUserId)) + whenever(fileUploadRepository.findByIdAndUserId(testUploadId, testUserId)) + .thenReturn(Mono.just(upload)) + whenever(fileStorage.delete(upload.filePath)).thenReturn(Mono.empty()) + whenever(fileEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) + whenever(fileUploadRepository.deleteById(testUploadId)).thenReturn(Mono.empty()) + + StepVerifier + .create(fileUploadService.deleteUpload(testUploadId)) + .verifyComplete() + + verify(fileStorage).delete(upload.filePath) + verify(fileEventPublisher).publishDeleted(upload) + verify(fileUploadRepository).deleteById(testUploadId) + } + + @Test + fun `deleteUpload should reject upload belonging to different user`() { + whenever(currentUserProvider.getCurrentUserId()).thenReturn(Mono.just(testUserId)) + whenever(fileUploadRepository.findByIdAndUserId(testUploadId, testUserId)) + .thenReturn(Mono.empty()) + + StepVerifier + .create(fileUploadService.deleteUpload(testUploadId)) + .expectErrorMatches { error -> + error is IllegalArgumentException && + error.message?.contains("Upload not found or does not belong to user") == true + }.verify() + + verify(fileStorage, never()).delete(any()) + verify(fileUploadRepository, never()).deleteById(any()) + } + + private fun createTestFileUpload( + id: UUID, + userId: UUID, + status: FileUploadStatus, + ): FileUpload = + FileUpload( + id = id, + userId = userId, + filePath = "$userId/$id/test-file.jpg", + originalFileName = "test-file.jpg", + contentType = "image/jpeg", + fileSize = null, + status = status, + createdAt = Instant.now(), + expiresAt = Instant.now().plusSeconds(3600), + completedAt = null, + ) +} diff --git a/files-service/src/test/resources/application-test.yml b/files-service/src/test/resources/application-test.yml new file mode 100644 index 0000000..64f607b --- /dev/null +++ b/files-service/src/test/resources/application-test.yml @@ -0,0 +1,42 @@ +spring: + application: + name: files-service + cloud: + config: + enabled: false + r2dbc: + url: r2dbc:postgresql://localhost:5432/test_db + username: test_user + password: test_password + pool: + initial-size: 5 + max-size: 20 + flyway: + enabled: true + locations: classpath:db/migration + table: flyway_schema_history_files + baseline-on-migrate: true + baseline-version: 0 + url: jdbc:postgresql://localhost:5432/test_db + user: test_user + password: test_password + +eureka: + client: + enabled: false + +minio: + endpoint: http://localhost:9000 + access-key: minioadmin + secret-key: minioadmin + bucket: test-bucket + +upload: + expiration-minutes: 60 + max-file-size: 5242880 + allowed-content-types: + - image/jpeg + - image/png + - image/gif + - image/webp + cleanup-interval-ms: 300000 diff --git a/gateway-service/src/main/kotlin/com/github/butvinmitmo/gatewayservice/filter/JwtAuthenticationFilter.kt b/gateway-service/src/main/kotlin/com/github/butvinmitmo/gatewayservice/filter/JwtAuthenticationFilter.kt index 1a726d5..26f0154 100644 --- a/gateway-service/src/main/kotlin/com/github/butvinmitmo/gatewayservice/filter/JwtAuthenticationFilter.kt +++ b/gateway-service/src/main/kotlin/com/github/butvinmitmo/gatewayservice/filter/JwtAuthenticationFilter.kt @@ -27,14 +27,12 @@ class JwtAuthenticationFilter( return chain.filter(exchange) } - // Extract and validate JWT - val authHeader = exchange.request.headers.getFirst("Authorization") - if (authHeader == null || !authHeader.startsWith("Bearer ")) { + // Extract JWT from Authorization header or query param (for WebSocket) + val token = extractToken(exchange) + if (token == null) { exchange.response.statusCode = HttpStatus.UNAUTHORIZED return exchange.response.setComplete() } - - val token = authHeader.substring(7) val claims = jwtUtil.validateAndExtract(token) ?: run { exchange.response.statusCode = HttpStatus.UNAUTHORIZED @@ -60,4 +58,15 @@ class JwtAuthenticationFilter( } override fun getOrder() = Ordered.HIGHEST_PRECEDENCE + + private fun extractToken(exchange: ServerWebExchange): String? { + // Try Authorization header first + val authHeader = exchange.request.headers.getFirst("Authorization") + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7) + } + + // Fall back to query param for WebSocket connections + return exchange.request.queryParams.getFirst("token") + } } diff --git a/highload-config b/highload-config index de5a95b..fae0180 160000 --- a/highload-config +++ b/highload-config @@ -1 +1 @@ -Subproject commit de5a95b92894396769f0ff5d95f56e6ed0861396 +Subproject commit fae01802df62bba0024a4258b0d04ae72d2474d6 diff --git a/notification-service/Dockerfile b/notification-service/Dockerfile new file mode 100644 index 0000000..76241b6 --- /dev/null +++ b/notification-service/Dockerfile @@ -0,0 +1,15 @@ +FROM gradle:8-jdk21 AS build +WORKDIR /app + +ENV GRADLE_USER_HOME=/home/gradle/.gradle + +COPY . . + +RUN --mount=type=cache,target=/home/gradle/.gradle,id=notification-service-gradle \ + gradle :notification-service:bootJar --no-daemon -x test + +FROM eclipse-temurin:21-jre +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /app/notification-service/build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/notification-service/build.gradle.kts b/notification-service/build.gradle.kts new file mode 100644 index 0000000..9d702b4 --- /dev/null +++ b/notification-service/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + kotlin("jvm") version "2.2.10" + kotlin("plugin.spring") version "2.2.10" + id("org.springframework.boot") version "3.5.6" + id("io.spring.dependency-management") version "1.1.7" + id("org.jlleitschuh.gradle.ktlint") version "12.1.2" +} + +group = "com.github.butvinmitmo" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2025.0.0" + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +dependencies { + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") + implementation(project(":shared-dto")) + implementation(project(":shared-clients")) + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") + runtimeOnly("org.postgresql:r2dbc-postgresql") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.springdoc:springdoc-openapi-starter-webflux-api:2.8.4") + + // Spring Security + implementation("org.springframework.boot:spring-boot-starter-security") + + // Kafka consumer + implementation("org.springframework.kafka:spring-kafka") + + // PostgreSQL JDBC driver for Flyway + implementation("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.projectreactor:reactor-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") + testImplementation("org.mockito:mockito-junit-jupiter") + testImplementation("org.testcontainers:testcontainers:2.0.2") + testImplementation("org.testcontainers:testcontainers-postgresql:2.0.2") + testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.2") + testImplementation("org.wiremock:wiremock-standalone:3.9.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property") + } +} + +tasks.withType { + useJUnitPlatform() +} + +ktlint { + version.set("1.5.0") +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/NotificationServiceApplication.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/NotificationServiceApplication.kt new file mode 100644 index 0000000..2899e0f --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/NotificationServiceApplication.kt @@ -0,0 +1,43 @@ +package com.github.butvinmitmo.notificationservice + +import io.swagger.v3.oas.annotations.OpenAPIDefinition +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.security.SecurityScheme +import io.swagger.v3.oas.annotations.servers.Server +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration +import org.springframework.boot.runApplication +import org.springframework.cloud.client.discovery.EnableDiscoveryClient +import org.springframework.cloud.openfeign.EnableFeignClients +import org.springframework.context.annotation.ComponentScan +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories + +@SpringBootApplication( + exclude = [ + JpaRepositoriesAutoConfiguration::class, + HibernateJpaAutoConfiguration::class, + ], +) +@EnableDiscoveryClient +@EnableFeignClients(basePackages = ["com.github.butvinmitmo.shared.client"]) +@ComponentScan(basePackages = ["com.github.butvinmitmo.notificationservice", "com.github.butvinmitmo.shared.client"]) +@EnableR2dbcRepositories( + basePackages = ["com.github.butvinmitmo.notificationservice.infrastructure.persistence.repository"], +) +@OpenAPIDefinition( + servers = [Server(url = "http://localhost:8080", description = "API Gateway")], + security = [SecurityRequirement(name = "bearerAuth")], +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", +) +class NotificationServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/api/controller/NotificationController.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/api/controller/NotificationController.kt new file mode 100644 index 0000000..2766a8b --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/api/controller/NotificationController.kt @@ -0,0 +1,132 @@ +package com.github.butvinmitmo.notificationservice.api.controller + +import com.github.butvinmitmo.notificationservice.api.mapper.NotificationDtoMapper +import com.github.butvinmitmo.notificationservice.application.interfaces.provider.CurrentUserProvider +import com.github.butvinmitmo.notificationservice.application.service.NotificationService +import com.github.butvinmitmo.shared.dto.NotificationDto +import com.github.butvinmitmo.shared.dto.UnreadCountResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.headers.Header +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import java.util.UUID + +@RestController +@RequestMapping("/api/v0.0.1/notifications") +@Tag(name = "Notifications", description = "User notification operations") +@Validated +class NotificationController( + private val notificationService: NotificationService, + private val currentUserProvider: CurrentUserProvider, + private val notificationDtoMapper: NotificationDtoMapper, +) { + @GetMapping + @Operation( + summary = "Get paginated list of notifications", + description = "Retrieves notifications for the current user. Can filter by read status.", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Notifications retrieved successfully", + headers = [ + Header( + name = "X-Total-Count", + description = "Total number of notifications", + schema = Schema(type = "integer"), + ), + ], + content = [Content(array = ArraySchema(schema = Schema(implementation = NotificationDto::class)))], + ), + ApiResponse(responseCode = "401", description = "Not authenticated"), + ], + ) + fun getNotifications( + @Parameter(description = "Filter by read status (optional)") + @RequestParam(required = false) + isRead: Boolean?, + @Parameter(description = "Page number (0-based)", example = "0") + @RequestParam(defaultValue = "0") + @Min(0) + page: Int, + @Parameter(description = "Page size (max 50)", example = "20") + @RequestParam(defaultValue = "20") + @Min(1) + @Max(50) + size: Int, + ): Mono>> = + currentUserProvider.getCurrentUserId().flatMap { userId -> + notificationService.getNotificationsForUser(userId, isRead, page, size).map { response -> + ResponseEntity + .ok() + .header("X-Total-Count", response.totalElements.toString()) + .body(response.content.map { notificationDtoMapper.toDto(it) }) + } + } + + @GetMapping("/unread-count") + @Operation( + summary = "Get unread notification count", + description = "Returns the number of unread notifications for the current user.", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Unread count retrieved successfully", + content = [Content(schema = Schema(implementation = UnreadCountResponse::class))], + ), + ApiResponse(responseCode = "401", description = "Not authenticated"), + ], + ) + fun getUnreadCount(): Mono> = + currentUserProvider.getCurrentUserId().flatMap { userId -> + notificationService.getUnreadCountForUser(userId).map { count -> + ResponseEntity.ok(UnreadCountResponse(count = count)) + } + } + + @PutMapping("/{id}/read") + @Operation( + summary = "Mark notification as read", + description = "Marks a specific notification as read for the current user.", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Notification marked as read", + content = [Content(schema = Schema(implementation = NotificationDto::class))], + ), + ApiResponse(responseCode = "404", description = "Notification not found"), + ApiResponse(responseCode = "401", description = "Not authenticated"), + ], + ) + fun markAsRead( + @Parameter(description = "Notification ID", required = true) + @PathVariable + id: UUID, + ): Mono> = + currentUserProvider.getCurrentUserId().flatMap { userId -> + notificationService.markAsRead(id, userId).map { notification -> + ResponseEntity.ok(notificationDtoMapper.toDto(notification)) + } + } +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/api/mapper/NotificationDtoMapper.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/api/mapper/NotificationDtoMapper.kt new file mode 100644 index 0000000..f28933e --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/api/mapper/NotificationDtoMapper.kt @@ -0,0 +1,20 @@ +package com.github.butvinmitmo.notificationservice.api.mapper + +import com.github.butvinmitmo.notificationservice.domain.model.Notification +import com.github.butvinmitmo.shared.dto.NotificationDto +import org.springframework.stereotype.Component + +@Component +class NotificationDtoMapper { + fun toDto(notification: Notification): NotificationDto = + NotificationDto( + id = notification.id!!, + recipientId = notification.recipientId, + spreadId = notification.spreadId, + interpretationAuthorId = notification.interpretationAuthorId, + title = notification.title, + message = notification.message, + isRead = notification.isRead, + createdAt = notification.createdAt!!, + ) +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/provider/CurrentUserProvider.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/provider/CurrentUserProvider.kt new file mode 100644 index 0000000..2ce9da6 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/provider/CurrentUserProvider.kt @@ -0,0 +1,8 @@ +package com.github.butvinmitmo.notificationservice.application.interfaces.provider + +import reactor.core.publisher.Mono +import java.util.UUID + +interface CurrentUserProvider { + fun getCurrentUserId(): Mono +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/provider/SpreadProvider.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/provider/SpreadProvider.kt new file mode 100644 index 0000000..fd90aeb --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/provider/SpreadProvider.kt @@ -0,0 +1,8 @@ +package com.github.butvinmitmo.notificationservice.application.interfaces.provider + +import reactor.core.publisher.Mono +import java.util.UUID + +interface SpreadProvider { + fun getSpreadOwnerId(spreadId: UUID): Mono +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/repository/NotificationRepository.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/repository/NotificationRepository.kt new file mode 100644 index 0000000..dfb9648 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/interfaces/repository/NotificationRepository.kt @@ -0,0 +1,33 @@ +package com.github.butvinmitmo.notificationservice.application.interfaces.repository + +import com.github.butvinmitmo.notificationservice.domain.model.Notification +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface NotificationRepository { + fun findById(id: UUID): Mono + + fun findByIdAndRecipientId( + id: UUID, + recipientId: UUID, + ): Mono + + fun findByRecipientIdPaginated( + recipientId: UUID, + isRead: Boolean?, + offset: Long, + limit: Int, + ): Flux + + fun countByRecipientId(recipientId: UUID): Mono + + fun countByRecipientIdAndIsRead( + recipientId: UUID, + isRead: Boolean, + ): Mono + + fun save(notification: Notification): Mono + + fun existsByInterpretationId(interpretationId: UUID): Mono +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/service/NotificationService.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/service/NotificationService.kt new file mode 100644 index 0000000..e343556 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/application/service/NotificationService.kt @@ -0,0 +1,98 @@ +package com.github.butvinmitmo.notificationservice.application.service + +import com.github.butvinmitmo.notificationservice.application.interfaces.repository.NotificationRepository +import com.github.butvinmitmo.notificationservice.domain.model.Notification +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.time.Instant +import java.util.UUID + +data class PageResult( + val content: List, + val totalElements: Long, +) + +@Service +class NotificationService( + private val notificationRepository: NotificationRepository, +) { + private val logger = LoggerFactory.getLogger(NotificationService::class.java) + + fun create( + recipientId: UUID, + interpretationId: UUID, + interpretationAuthorId: UUID, + spreadId: UUID, + title: String, + message: String, + ): Mono = + notificationRepository + .existsByInterpretationId(interpretationId) + .flatMap { exists -> + if (exists) { + logger.debug( + "Notification for interpretation {} already exists, skipping", + interpretationId, + ) + Mono.empty() + } else { + val notification = + Notification( + id = null, + recipientId = recipientId, + interpretationId = interpretationId, + interpretationAuthorId = interpretationAuthorId, + spreadId = spreadId, + title = title, + message = message, + isRead = false, + createdAt = Instant.now(), + ) + notificationRepository.save(notification) + } + } + + fun getNotificationsForUser( + recipientId: UUID, + isRead: Boolean?, + page: Int, + size: Int, + ): Mono> { + val offset = page.toLong() * size + val countMono = + if (isRead != null) { + notificationRepository.countByRecipientIdAndIsRead(recipientId, isRead) + } else { + notificationRepository.countByRecipientId(recipientId) + } + return countMono.flatMap { totalElements -> + notificationRepository + .findByRecipientIdPaginated(recipientId, isRead, offset, size) + .collectList() + .map { notifications -> + PageResult( + content = notifications, + totalElements = totalElements, + ) + } + } + } + + fun getUnreadCountForUser(recipientId: UUID): Mono = + notificationRepository.countByRecipientIdAndIsRead(recipientId, false) + + fun markAsRead( + notificationId: UUID, + recipientId: UUID, + ): Mono = + notificationRepository + .findByIdAndRecipientId(notificationId, recipientId) + .flatMap { notification -> + if (notification.isRead) { + Mono.just(notification) + } else { + notificationRepository.save(notification.copy(isRead = true)) + } + } +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/FeignConfiguration.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/FeignConfiguration.kt new file mode 100644 index 0000000..bc155dd --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/FeignConfiguration.kt @@ -0,0 +1,19 @@ +package com.github.butvinmitmo.notificationservice.config + +import org.springframework.boot.autoconfigure.http.HttpMessageConverters +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter + +/** + * Configuration for Feign clients in WebFlux environment. + * + * Spring WebFlux doesn't provide HttpMessageConverters autoconfiguration + * (unlike Spring MVC), but Feign clients need them for JSON serialization. + * This configuration manually provides the required bean. + */ +@Configuration +class FeignConfiguration { + @Bean + fun httpMessageConverters(): HttpMessageConverters = HttpMessageConverters(MappingJackson2HttpMessageConverter()) +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/KafkaConsumerConfig.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/KafkaConsumerConfig.kt new file mode 100644 index 0000000..21a8d98 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/KafkaConsumerConfig.kt @@ -0,0 +1,42 @@ +package com.github.butvinmitmo.notificationservice.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.butvinmitmo.shared.dto.events.InterpretationEventData +import org.apache.kafka.common.serialization.StringDeserializer +import org.springframework.boot.autoconfigure.kafka.KafkaProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.support.serializer.JsonDeserializer + +@Configuration +class KafkaConsumerConfig { + @Bean + fun interpretationConsumerFactory( + kafkaProperties: KafkaProperties, + objectMapper: ObjectMapper, + ): ConsumerFactory { + val props = kafkaProperties.buildConsumerProperties(null) + val jsonDeserializer = + JsonDeserializer(InterpretationEventData::class.java, objectMapper).apply { + setRemoveTypeHeaders(false) + addTrustedPackages("*") + setUseTypeMapperForKey(false) + } + return DefaultKafkaConsumerFactory( + props, + StringDeserializer(), + jsonDeserializer, + ) + } + + @Bean + fun interpretationKafkaListenerContainerFactory( + interpretationConsumerFactory: ConsumerFactory, + ): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { + consumerFactory = interpretationConsumerFactory + } +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/SecurityConfig.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/SecurityConfig.kt new file mode 100644 index 0000000..f992721 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/SecurityConfig.kt @@ -0,0 +1,35 @@ +package com.github.butvinmitmo.notificationservice.config + +import com.github.butvinmitmo.notificationservice.infrastructure.security.GatewayAuthenticationWebFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.SecurityWebFiltersOrder +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.web.server.SecurityWebFilterChain + +@Configuration +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity(useAuthorizationManager = true) +class SecurityConfig( + private val gatewayAuthenticationWebFilter: GatewayAuthenticationWebFilter, +) { + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = + http + .csrf { it.disable() } + .authorizeExchange { + it + // WebSocket path - auth handled by gateway headers + .pathMatchers("/ws/**") + .permitAll() + // Health and API docs + .pathMatchers("/actuator/**", "/api-docs/**") + .permitAll() + // All other requests require authentication + .anyExchange() + .authenticated() + }.addFilterAt(gatewayAuthenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .build() +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/WebSocketConfig.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/WebSocketConfig.kt new file mode 100644 index 0000000..1c5de8c --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/config/WebSocketConfig.kt @@ -0,0 +1,23 @@ +package com.github.butvinmitmo.notificationservice.config + +import com.github.butvinmitmo.notificationservice.infrastructure.websocket.NotificationWebSocketHandler +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.HandlerMapping +import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping +import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter + +@Configuration +class WebSocketConfig( + private val notificationWebSocketHandler: NotificationWebSocketHandler, +) { + @Bean + fun webSocketHandlerMapping(): HandlerMapping = + SimpleUrlHandlerMapping( + mapOf("/ws/notifications" to notificationWebSocketHandler), + -1, // Higher priority than RouterFunction handlers + ) + + @Bean + fun webSocketHandlerAdapter(): WebSocketHandlerAdapter = WebSocketHandlerAdapter() +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/domain/model/Notification.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/domain/model/Notification.kt new file mode 100644 index 0000000..da68108 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/domain/model/Notification.kt @@ -0,0 +1,16 @@ +package com.github.butvinmitmo.notificationservice.domain.model + +import java.time.Instant +import java.util.UUID + +data class Notification( + val id: UUID?, + val recipientId: UUID, + val interpretationId: UUID, + val interpretationAuthorId: UUID, + val spreadId: UUID, + val title: String, + val message: String, + val isRead: Boolean, + val createdAt: Instant?, +) diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/external/FeignSpreadProvider.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/external/FeignSpreadProvider.kt new file mode 100644 index 0000000..ae1aa0c --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/external/FeignSpreadProvider.kt @@ -0,0 +1,37 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.external + +import com.github.butvinmitmo.notificationservice.application.interfaces.provider.SpreadProvider +import com.github.butvinmitmo.shared.client.DivinationServiceInternalClient +import feign.FeignException +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.util.UUID + +@Component +class FeignSpreadProvider( + private val divinationServiceInternalClient: DivinationServiceInternalClient, +) : SpreadProvider { + private val logger = LoggerFactory.getLogger(FeignSpreadProvider::class.java) + + override fun getSpreadOwnerId(spreadId: UUID): Mono = + Mono + .fromCallable { + divinationServiceInternalClient.getSpreadOwner(spreadId).body + }.subscribeOn(Schedulers.boundedElastic()) + .flatMap { ownerId -> + if (ownerId != null) Mono.just(ownerId) else Mono.empty() + }.onErrorResume { e -> + when { + e is FeignException.NotFound -> { + logger.debug("Spread {} not found, skipping notification", spreadId) + Mono.empty() + } + else -> { + logger.error("Error fetching spread owner for spread {}: {}", spreadId, e.message) + Mono.error(e) + } + } + } +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/messaging/InterpretationEventConsumer.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/messaging/InterpretationEventConsumer.kt new file mode 100644 index 0000000..f2ef85c --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/messaging/InterpretationEventConsumer.kt @@ -0,0 +1,96 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.messaging + +import com.github.butvinmitmo.notificationservice.application.interfaces.provider.SpreadProvider +import com.github.butvinmitmo.notificationservice.application.service.NotificationService +import com.github.butvinmitmo.notificationservice.infrastructure.websocket.WebSocketSessionManager +import com.github.butvinmitmo.shared.dto.events.EventType +import com.github.butvinmitmo.shared.dto.events.InterpretationEventData +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.messaging.handler.annotation.Header +import org.springframework.messaging.handler.annotation.Payload +import org.springframework.stereotype.Component + +@Component +class InterpretationEventConsumer( + private val spreadProvider: SpreadProvider, + private val notificationService: NotificationService, + private val webSocketSessionManager: WebSocketSessionManager, +) { + private val logger = LoggerFactory.getLogger(InterpretationEventConsumer::class.java) + + @KafkaListener( + topics = ["\${kafka.topics.interpretations-events:interpretations-events}"], + containerFactory = "interpretationKafkaListenerContainerFactory", + ) + fun onInterpretationEvent( + @Payload event: InterpretationEventData, + @Header("eventType") eventTypeBytes: ByteArray, + ) { + val eventType = + try { + EventType.valueOf(String(eventTypeBytes)) + } catch (e: IllegalArgumentException) { + logger.warn("Unknown event type: {}, skipping", String(eventTypeBytes)) + return + } + + // Only process CREATED events for notifications + if (eventType != EventType.CREATED) { + logger.debug("Ignoring {} event for interpretation {}", eventType, event.id) + return + } + + logger.info( + "Processing CREATED event for interpretation {} on spread {}", + event.id, + event.spreadId, + ) + + spreadProvider + .getSpreadOwnerId(event.spreadId) + .flatMap { spreadOwnerId -> + // Don't notify if the interpretation author is the spread owner + if (event.authorId == spreadOwnerId) { + logger.debug( + "Interpretation author {} is spread owner, skipping notification", + event.authorId, + ) + return@flatMap reactor.core.publisher.Mono + .empty() + } + + logger.info( + "Creating notification for spread owner {} about interpretation by {}", + spreadOwnerId, + event.authorId, + ) + + notificationService.create( + recipientId = spreadOwnerId, + interpretationId = event.id, + interpretationAuthorId = event.authorId, + spreadId = event.spreadId, + title = "New interpretation on your spread", + message = "Someone added an interpretation to your spread", + ) + }.flatMap { notification -> + if (notification != null) { + logger.info("Created notification {} for interpretation {}", notification.id, event.id) + // Push real-time notification via WebSocket + webSocketSessionManager + .sendToUser(notification.recipientId, notification) + .thenReturn(notification) + } else { + reactor.core.publisher.Mono + .empty() + } + }.doOnError { e -> + logger.error( + "Failed to process interpretation event {}: {}", + event.id, + e.message, + ) + }.subscribe() + } +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/R2dbcNotificationRepository.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/R2dbcNotificationRepository.kt new file mode 100644 index 0000000..740c52d --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/R2dbcNotificationRepository.kt @@ -0,0 +1,66 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.persistence + +import com.github.butvinmitmo.notificationservice.application.interfaces.repository.NotificationRepository +import com.github.butvinmitmo.notificationservice.domain.model.Notification +import com.github.butvinmitmo.notificationservice.infrastructure.persistence.mapper.NotificationEntityMapper +import com.github.butvinmitmo.notificationservice.infrastructure.persistence.repository.SpringDataNotificationRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +class R2dbcNotificationRepository( + private val springDataNotificationRepository: SpringDataNotificationRepository, + private val notificationEntityMapper: NotificationEntityMapper, +) : NotificationRepository { + override fun findById(id: UUID): Mono = + springDataNotificationRepository.findById(id).map { notificationEntityMapper.toDomain(it) } + + override fun findByIdAndRecipientId( + id: UUID, + recipientId: UUID, + ): Mono = + springDataNotificationRepository + .findByIdAndRecipientId(id, recipientId) + .map { notificationEntityMapper.toDomain(it) } + + override fun findByRecipientIdPaginated( + recipientId: UUID, + isRead: Boolean?, + offset: Long, + limit: Int, + ): Flux = + if (isRead != null) { + springDataNotificationRepository + .findByRecipientIdAndIsReadPaginated(recipientId, isRead, offset, limit) + .map { notificationEntityMapper.toDomain(it) } + } else { + springDataNotificationRepository + .findByRecipientIdPaginated(recipientId, offset, limit) + .map { notificationEntityMapper.toDomain(it) } + } + + override fun countByRecipientId(recipientId: UUID): Mono = + springDataNotificationRepository.countByRecipientId(recipientId) + + override fun countByRecipientIdAndIsRead( + recipientId: UUID, + isRead: Boolean, + ): Mono = springDataNotificationRepository.countByRecipientIdAndIsRead(recipientId, isRead) + + override fun save(notification: Notification): Mono { + val entity = notificationEntityMapper.toEntity(notification) + return springDataNotificationRepository + .save(entity) + .flatMap { savedEntity -> + // Re-fetch to get database-generated fields (id, created_at) + springDataNotificationRepository + .findById(savedEntity.id!!) + .map { notificationEntityMapper.toDomain(it) } + } + } + + override fun existsByInterpretationId(interpretationId: UUID): Mono = + springDataNotificationRepository.existsByInterpretationId(interpretationId) +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/entity/NotificationEntity.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/entity/NotificationEntity.kt new file mode 100644 index 0000000..c062436 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/entity/NotificationEntity.kt @@ -0,0 +1,29 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.persistence.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant +import java.util.UUID + +@Table("notification") +data class NotificationEntity( + @Id + val id: UUID? = null, + @Column("recipient_id") + val recipientId: UUID, + @Column("interpretation_id") + val interpretationId: UUID, + @Column("interpretation_author_id") + val interpretationAuthorId: UUID, + @Column("spread_id") + val spreadId: UUID, + @Column("title") + val title: String, + @Column("message") + val message: String, + @Column("is_read") + val isRead: Boolean, + @Column("created_at") + val createdAt: Instant? = null, +) diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/mapper/NotificationEntityMapper.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/mapper/NotificationEntityMapper.kt new file mode 100644 index 0000000..42f8a16 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/mapper/NotificationEntityMapper.kt @@ -0,0 +1,34 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.notificationservice.domain.model.Notification +import com.github.butvinmitmo.notificationservice.infrastructure.persistence.entity.NotificationEntity +import org.springframework.stereotype.Component + +@Component +class NotificationEntityMapper { + fun toDomain(entity: NotificationEntity): Notification = + Notification( + id = entity.id!!, + recipientId = entity.recipientId, + interpretationId = entity.interpretationId, + interpretationAuthorId = entity.interpretationAuthorId, + spreadId = entity.spreadId, + title = entity.title, + message = entity.message, + isRead = entity.isRead, + createdAt = entity.createdAt!!, + ) + + fun toEntity(notification: Notification): NotificationEntity = + NotificationEntity( + id = notification.id, + recipientId = notification.recipientId, + interpretationId = notification.interpretationId, + interpretationAuthorId = notification.interpretationAuthorId, + spreadId = notification.spreadId, + title = notification.title, + message = notification.message, + isRead = notification.isRead, + createdAt = notification.createdAt, + ) +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/repository/SpringDataNotificationRepository.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/repository/SpringDataNotificationRepository.kt new file mode 100644 index 0000000..892849c --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/persistence/repository/SpringDataNotificationRepository.kt @@ -0,0 +1,53 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.notificationservice.infrastructure.persistence.entity.NotificationEntity +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface SpringDataNotificationRepository : R2dbcRepository { + fun findByIdAndRecipientId( + id: UUID, + recipientId: UUID, + ): Mono + + @Query( + """ + SELECT * FROM notification + WHERE recipient_id = :recipientId + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset + """, + ) + fun findByRecipientIdPaginated( + recipientId: UUID, + offset: Long, + limit: Int, + ): Flux + + @Query( + """ + SELECT * FROM notification + WHERE recipient_id = :recipientId AND is_read = :isRead + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset + """, + ) + fun findByRecipientIdAndIsReadPaginated( + recipientId: UUID, + isRead: Boolean, + offset: Long, + limit: Int, + ): Flux + + fun countByRecipientId(recipientId: UUID): Mono + + fun countByRecipientIdAndIsRead( + recipientId: UUID, + isRead: Boolean, + ): Mono + + fun existsByInterpretationId(interpretationId: UUID): Mono +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/security/GatewayAuthenticationWebFilter.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/security/GatewayAuthenticationWebFilter.kt new file mode 100644 index 0000000..16e9317 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/security/GatewayAuthenticationWebFilter.kt @@ -0,0 +1,38 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.security + +import com.github.butvinmitmo.shared.security.GatewayAuthenticationToken +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono +import java.util.UUID + +@Component +class GatewayAuthenticationWebFilter : WebFilter { + override fun filter( + exchange: ServerWebExchange, + chain: WebFilterChain, + ): Mono { + val userIdHeader = exchange.request.headers.getFirst("X-User-Id") + val roleHeader = exchange.request.headers.getFirst("X-User-Role") + + return if (userIdHeader != null && roleHeader != null) { + try { + val userId = UUID.fromString(userIdHeader) + val authentication = GatewayAuthenticationToken(userId, roleHeader) + val securityContext = SecurityContextImpl(authentication) + chain + .filter( + exchange, + ).contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext))) + } catch (e: IllegalArgumentException) { + chain.filter(exchange) + } + } else { + chain.filter(exchange) + } + } +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/security/SecurityContextCurrentUserProvider.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/security/SecurityContextCurrentUserProvider.kt new file mode 100644 index 0000000..308e516 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/security/SecurityContextCurrentUserProvider.kt @@ -0,0 +1,16 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.security + +import com.github.butvinmitmo.notificationservice.application.interfaces.provider.CurrentUserProvider +import com.github.butvinmitmo.shared.security.GatewayAuthenticationToken +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import java.util.UUID + +@Component +class SecurityContextCurrentUserProvider : CurrentUserProvider { + override fun getCurrentUserId(): Mono = + ReactiveSecurityContextHolder + .getContext() + .map { (it.authentication as GatewayAuthenticationToken).userId } +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/websocket/NotificationWebSocketHandler.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/websocket/NotificationWebSocketHandler.kt new file mode 100644 index 0000000..d9ad5d3 --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/websocket/NotificationWebSocketHandler.kt @@ -0,0 +1,50 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.websocket + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.reactive.socket.WebSocketHandler +import org.springframework.web.reactive.socket.WebSocketSession +import reactor.core.publisher.Mono +import java.util.UUID + +@Component +class NotificationWebSocketHandler( + private val sessionManager: WebSocketSessionManager, +) : WebSocketHandler { + private val logger = LoggerFactory.getLogger(NotificationWebSocketHandler::class.java) + + override fun handle(session: WebSocketSession): Mono { + val userId = extractUserId(session) + if (userId == null) { + logger.warn("WebSocket connection rejected: no X-User-Id header in session {}", session.id) + return session.close() + } + + sessionManager.register(userId, session) + + // Keep connection alive by receiving and ignoring incoming messages + // Session cleanup happens when the receive stream completes (disconnect) + return session + .receive() + .doOnNext { message -> + // Ignore incoming messages - this is a push-only channel + logger.debug("Received message from user {}, ignoring", userId) + }.doFinally { + sessionManager.unregister(userId, session) + logger.info("WebSocket connection closed for user {}", userId) + }.then() + } + + private fun extractUserId(session: WebSocketSession): UUID? { + // Gateway adds X-User-Id header after JWT validation + val userIdHeader = session.handshakeInfo.headers.getFirst("X-User-Id") + return userIdHeader?.let { + try { + UUID.fromString(it) + } catch (e: IllegalArgumentException) { + logger.warn("Invalid X-User-Id header: {}", it) + null + } + } + } +} diff --git a/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/websocket/WebSocketSessionManager.kt b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/websocket/WebSocketSessionManager.kt new file mode 100644 index 0000000..04fe60b --- /dev/null +++ b/notification-service/src/main/kotlin/com/github/butvinmitmo/notificationservice/infrastructure/websocket/WebSocketSessionManager.kt @@ -0,0 +1,87 @@ +package com.github.butvinmitmo.notificationservice.infrastructure.websocket + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.butvinmitmo.notificationservice.domain.model.Notification +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.reactive.socket.WebSocketSession +import reactor.core.publisher.Mono +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +@Component +class WebSocketSessionManager( + private val objectMapper: ObjectMapper, +) { + private val logger = LoggerFactory.getLogger(WebSocketSessionManager::class.java) + private val sessions = ConcurrentHashMap>() + + fun register( + userId: UUID, + session: WebSocketSession, + ) { + sessions.computeIfAbsent(userId) { CopyOnWriteArrayList() }.add(session) + logger.info("Registered WebSocket session {} for user {}", session.id, userId) + } + + fun unregister( + userId: UUID, + session: WebSocketSession, + ) { + sessions[userId]?.let { userSessions -> + userSessions.remove(session) + if (userSessions.isEmpty()) { + sessions.remove(userId) + } + logger.info("Unregistered WebSocket session {} for user {}", session.id, userId) + } + } + + fun sendToUser( + userId: UUID, + notification: Notification, + ): Mono { + val userSessions = sessions[userId] ?: return Mono.empty() + + if (userSessions.isEmpty()) { + return Mono.empty() + } + + val json = objectMapper.writeValueAsString(NotificationMessage.from(notification)) + + return Mono + .fromCallable { + userSessions.forEach { session -> + try { + if (session.isOpen) { + session.send(Mono.just(session.textMessage(json))).subscribe() + } + } catch (e: Exception) { + logger.warn("Failed to send to session {}: {}", session.id, e.message) + } + } + }.then() + } + + data class NotificationMessage( + val id: UUID, + val spreadId: UUID, + val interpretationAuthorId: UUID, + val title: String, + val message: String, + val createdAt: String, + ) { + companion object { + fun from(notification: Notification): NotificationMessage = + NotificationMessage( + id = notification.id!!, + spreadId = notification.spreadId, + interpretationAuthorId = notification.interpretationAuthorId, + title = notification.title, + message = notification.message, + createdAt = notification.createdAt.toString(), + ) + } + } +} diff --git a/notification-service/src/main/resources/application.yml b/notification-service/src/main/resources/application.yml new file mode 100644 index 0000000..5e0efab --- /dev/null +++ b/notification-service/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: notification-service + config: + import: optional:configserver:${CONFIG_SERVER_URL:http://localhost:8888} + cloud: + config: + fail-fast: false + retry: + max-attempts: 6 + initial-interval: 1000 + multiplier: 1.1 + max-interval: 2000 diff --git a/notification-service/src/main/resources/db/migration/V1__create_notification_table.sql b/notification-service/src/main/resources/db/migration/V1__create_notification_table.sql new file mode 100644 index 0000000..d993c8f --- /dev/null +++ b/notification-service/src/main/resources/db/migration/V1__create_notification_table.sql @@ -0,0 +1,16 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS notification ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + recipient_id UUID NOT NULL, + interpretation_id UUID NOT NULL UNIQUE, + interpretation_author_id UUID NOT NULL, + spread_id UUID NOT NULL, + title VARCHAR(256) NOT NULL, + message TEXT NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_notification_recipient_read_created + ON notification(recipient_id, is_read, created_at DESC); diff --git a/notification-service/src/test/kotlin/com/github/butvinmitmo/notificationservice/integration/BaseIntegrationTest.kt b/notification-service/src/test/kotlin/com/github/butvinmitmo/notificationservice/integration/BaseIntegrationTest.kt new file mode 100644 index 0000000..cf40e12 --- /dev/null +++ b/notification-service/src/test/kotlin/com/github/butvinmitmo/notificationservice/integration/BaseIntegrationTest.kt @@ -0,0 +1,62 @@ +package com.github.butvinmitmo.notificationservice.integration + +import com.github.butvinmitmo.notificationservice.application.interfaces.provider.SpreadProvider +import com.github.butvinmitmo.notificationservice.infrastructure.persistence.repository.SpringDataNotificationRepository +import com.github.butvinmitmo.notificationservice.infrastructure.websocket.WebSocketSessionManager +import org.junit.jupiter.api.AfterEach +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.postgresql.PostgreSQLContainer + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +abstract class BaseIntegrationTest { + @Autowired + protected lateinit var springDataNotificationRepository: SpringDataNotificationRepository + + @MockBean + protected lateinit var spreadProvider: SpreadProvider + + @MockBean + protected lateinit var webSocketSessionManager: WebSocketSessionManager + + @AfterEach + fun cleanupDatabase() { + springDataNotificationRepository.deleteAll().block() + } + + companion object { + @JvmStatic + val postgres: PostgreSQLContainer = + PostgreSQLContainer("postgres:15-alpine") + .withDatabaseName("tarot_db_test") + .withUsername("test_user") + .withPassword("test_password") + .apply { + start() + } + + @JvmStatic + @DynamicPropertySource + fun configureProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.r2dbc.url") { + "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(5432)}/${postgres.databaseName}" + } + registry.add("spring.r2dbc.username") { postgres.username } + registry.add("spring.r2dbc.password") { postgres.password } + registry.add("spring.flyway.url") { postgres.jdbcUrl } + registry.add("spring.flyway.user") { postgres.username } + registry.add("spring.flyway.password") { postgres.password } + registry.add("spring.flyway.enabled") { "true" } + // Disable Kafka for tests + registry.add("spring.kafka.bootstrap-servers") { "localhost:9092" } + registry.add("spring.kafka.listener.auto-startup") { "false" } + } + } +} diff --git a/notification-service/src/test/kotlin/com/github/butvinmitmo/notificationservice/integration/controller/NotificationControllerIntegrationTest.kt b/notification-service/src/test/kotlin/com/github/butvinmitmo/notificationservice/integration/controller/NotificationControllerIntegrationTest.kt new file mode 100644 index 0000000..c8d03c0 --- /dev/null +++ b/notification-service/src/test/kotlin/com/github/butvinmitmo/notificationservice/integration/controller/NotificationControllerIntegrationTest.kt @@ -0,0 +1,185 @@ +package com.github.butvinmitmo.notificationservice.integration.controller + +import com.github.butvinmitmo.notificationservice.application.interfaces.provider.SpreadProvider +import com.github.butvinmitmo.notificationservice.infrastructure.persistence.entity.NotificationEntity +import com.github.butvinmitmo.notificationservice.infrastructure.persistence.repository.SpringDataNotificationRepository +import com.github.butvinmitmo.notificationservice.infrastructure.websocket.WebSocketSessionManager +import com.github.butvinmitmo.shared.dto.NotificationDto +import com.github.butvinmitmo.shared.dto.UnreadCountResponse +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.reactive.server.WebTestClient +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.postgresql.PostgreSQLContainer +import java.time.Instant +import java.util.UUID + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Testcontainers +class NotificationControllerIntegrationTest { + @Autowired + private lateinit var webTestClient: WebTestClient + + @Autowired + private lateinit var springDataNotificationRepository: SpringDataNotificationRepository + + @MockBean + private lateinit var spreadProvider: SpreadProvider + + @MockBean + private lateinit var webSocketSessionManager: WebSocketSessionManager + + private val testUserId = UUID.randomUUID() + private val otherUserId = UUID.randomUUID() + + @BeforeEach + fun setup() { + // Create test notifications + val notifications = + listOf( + createNotificationEntity(testUserId, false), + createNotificationEntity(testUserId, false), + createNotificationEntity(testUserId, true), + createNotificationEntity(otherUserId, false), + ) + notifications.forEach { springDataNotificationRepository.save(it).block() } + } + + @AfterEach + fun cleanup() { + springDataNotificationRepository.deleteAll().block() + } + + private fun createNotificationEntity( + recipientId: UUID, + isRead: Boolean, + ): NotificationEntity = + NotificationEntity( + id = null, + recipientId = recipientId, + interpretationId = UUID.randomUUID(), + interpretationAuthorId = UUID.randomUUID(), + spreadId = UUID.randomUUID(), + title = "Test notification", + message = "Test message", + isRead = isRead, + createdAt = Instant.now(), + ) + + @Test + fun `getNotifications should return paginated notifications for user`() { + webTestClient + .get() + .uri("/api/v0.0.1/notifications?page=0&size=10") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isOk + .expectHeader() + .valueEquals("X-Total-Count", "3") + .expectBodyList(NotificationDto::class.java) + .hasSize(3) + } + + @Test + fun `getNotifications should filter by isRead status`() { + webTestClient + .get() + .uri("/api/v0.0.1/notifications?isRead=false&page=0&size=10") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isOk + .expectHeader() + .valueEquals("X-Total-Count", "2") + .expectBodyList(NotificationDto::class.java) + .hasSize(2) + } + + @Test + fun `getUnreadCount should return unread count for user`() { + webTestClient + .get() + .uri("/api/v0.0.1/notifications/unread-count") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isOk + .expectBody(UnreadCountResponse::class.java) + .consumeWith { response -> + assert(response.responseBody?.count == 2L) + } + } + + @Test + fun `markAsRead should mark notification as read`() { + // Get an unread notification ID for this user + val notification = + springDataNotificationRepository + .findByRecipientIdAndIsReadPaginated(testUserId, false, 0, 10) + .blockFirst()!! + + webTestClient + .put() + .uri("/api/v0.0.1/notifications/${notification.id}/read") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "USER") + .exchange() + .expectStatus() + .isOk + .expectBody(NotificationDto::class.java) + .consumeWith { response -> + assert(response.responseBody?.isRead == true) + } + } + + @Test + fun `getNotifications should return 401 without auth headers`() { + webTestClient + .get() + .uri("/api/v0.0.1/notifications") + .exchange() + .expectStatus() + .isUnauthorized + } + + companion object { + @JvmStatic + val postgres: PostgreSQLContainer = + PostgreSQLContainer("postgres:15-alpine") + .withDatabaseName("tarot_db_test") + .withUsername("test_user") + .withPassword("test_password") + .apply { + start() + } + + @JvmStatic + @DynamicPropertySource + fun configureProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.r2dbc.url") { + "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(5432)}/${postgres.databaseName}" + } + registry.add("spring.r2dbc.username") { postgres.username } + registry.add("spring.r2dbc.password") { postgres.password } + registry.add("spring.flyway.url") { postgres.jdbcUrl } + registry.add("spring.flyway.user") { postgres.username } + registry.add("spring.flyway.password") { postgres.password } + registry.add("spring.flyway.enabled") { "true" } + // Disable Kafka for tests + registry.add("spring.kafka.bootstrap-servers") { "localhost:9092" } + registry.add("spring.kafka.listener.auto-startup") { "false" } + } + } +} diff --git a/notification-service/src/test/resources/application-test.yml b/notification-service/src/test/resources/application-test.yml new file mode 100644 index 0000000..ba7b037 --- /dev/null +++ b/notification-service/src/test/resources/application-test.yml @@ -0,0 +1,35 @@ +spring: + application: + name: notification-service + cloud: + config: + enabled: false + r2dbc: + url: r2dbc:postgresql://localhost:5432/test_db + username: test_user + password: test_password + pool: + initial-size: 5 + max-size: 20 + flyway: + enabled: true + locations: classpath:db/migration + table: flyway_schema_history_notification + baseline-on-migrate: true + baseline-version: 0 + url: jdbc:postgresql://localhost:5432/test_db + user: test_user + password: test_password + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: notification-service-test + auto-offset-reset: earliest + +eureka: + client: + enabled: false + +kafka: + topics: + interpretations-events: interpretations-events diff --git a/settings.gradle.kts b/settings.gradle.kts index 3155bd3..a39f1c8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,4 +8,6 @@ include("divination-service") include("config-server") include("eureka-server") include("gateway-service") +include("notification-service") +include("files-service") include("e2e-tests") diff --git a/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/DivinationServiceInternalClient.kt b/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/DivinationServiceInternalClient.kt index 428e137..8f52d42 100644 --- a/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/DivinationServiceInternalClient.kt +++ b/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/DivinationServiceInternalClient.kt @@ -2,13 +2,13 @@ package com.github.butvinmitmo.shared.client import org.springframework.cloud.openfeign.FeignClient 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.PathVariable import java.util.UUID /** - * Internal Feign client for calling divination-service cleanup endpoints. - * Used by user-service to delete user data before deleting the user. + * Internal Feign client for calling divination-service internal endpoints. + * Used by notification-service to get spread owner for notification targeting. * This client bypasses the gateway and calls divination-service directly via Eureka. */ @FeignClient( @@ -19,14 +19,14 @@ import java.util.UUID ) interface DivinationServiceInternalClient { /** - * Deletes all data associated with a user (spreads and interpretations). - * Should be called before deleting a user to ensure cascade cleanup. + * Gets the author (owner) ID of a spread. + * Used by notification-service to determine who should receive notifications. * - * @param userId The ID of the user whose data should be deleted - * @return 204 No Content on success + * @param spreadId The ID of the spread + * @return 200 OK with author UUID, or 404 if spread not found */ - @DeleteMapping("/internal/users/{userId}/data") - fun deleteUserData( - @PathVariable userId: UUID, - ): ResponseEntity + @GetMapping("/internal/spreads/{spreadId}/owner") + fun getSpreadOwner( + @PathVariable spreadId: UUID, + ): ResponseEntity } diff --git a/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FeignFallbackFactory.kt b/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FeignFallbackFactory.kt index f6cc38e..797309d 100644 --- a/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FeignFallbackFactory.kt +++ b/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FeignFallbackFactory.kt @@ -60,3 +60,11 @@ class DivinationServiceInternalFallbackFactory( override fun create(cause: Throwable): DivinationServiceInternalClient = factory.create("divination-service", DivinationServiceInternalClient::class.java, cause) } + +@Component +class FilesServiceInternalFallbackFactory( + private val factory: FeignFallbackFactory, +) : FallbackFactory { + override fun create(cause: Throwable): FilesServiceInternalClient = + factory.create("files-service", FilesServiceInternalClient::class.java, cause) +} diff --git a/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FilesServiceClient.kt b/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FilesServiceClient.kt new file mode 100644 index 0000000..51f8ea3 --- /dev/null +++ b/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FilesServiceClient.kt @@ -0,0 +1,41 @@ +package com.github.butvinmitmo.shared.client + +import com.github.butvinmitmo.shared.dto.DownloadUrlResponse +import com.github.butvinmitmo.shared.dto.FileMetadataResponse +import com.github.butvinmitmo.shared.dto.PresignedUploadRequest +import com.github.butvinmitmo.shared.dto.PresignedUploadResponse +import org.springframework.cloud.openfeign.FeignClient +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.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import java.util.UUID + +/** + * Feign client for files-service public API endpoints. + * Used for E2E tests and external clients. + */ +@FeignClient(name = "files-service-public", url = "\${services.files-service.url:}") +interface FilesServiceClient { + @PostMapping("/api/v0.0.1/files/presigned-upload") + fun requestPresignedUpload( + @RequestBody request: PresignedUploadRequest, + ): ResponseEntity + + @GetMapping("/api/v0.0.1/files/{uploadId}") + fun getUploadMetadata( + @PathVariable uploadId: UUID, + ): ResponseEntity + + @GetMapping("/api/v0.0.1/files/{uploadId}/download-url") + fun getDownloadUrl( + @PathVariable uploadId: UUID, + ): ResponseEntity + + @DeleteMapping("/api/v0.0.1/files/{uploadId}") + fun deleteUpload( + @PathVariable uploadId: UUID, + ): ResponseEntity +} diff --git a/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FilesServiceInternalClient.kt b/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FilesServiceInternalClient.kt new file mode 100644 index 0000000..450714a --- /dev/null +++ b/shared-clients/src/main/kotlin/com/github/butvinmitmo/shared/client/FilesServiceInternalClient.kt @@ -0,0 +1,61 @@ +package com.github.butvinmitmo.shared.client + +import com.github.butvinmitmo.shared.dto.FileUploadMetadataDto +import org.springframework.cloud.openfeign.FeignClient +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.RequestParam +import java.util.UUID + +/** + * Internal Feign client for calling files-service internal endpoints. + * Used by divination-service to verify file uploads and get download URLs. + * This client bypasses the gateway and calls files-service directly via Eureka. + */ +@FeignClient( + name = "files-service", + contextId = "filesServiceInternalClient", + url = "\${services.files-service.url:}", + fallbackFactory = FilesServiceInternalFallbackFactory::class, +) +interface FilesServiceInternalClient { + /** + * Verifies that a file upload exists and marks it as completed. + * Called by divination-service when creating an interpretation with an attachment. + * + * @param uploadId The ID of the upload to verify + * @param userId The ID of the user who owns the upload (for ownership validation) + * @return File metadata if upload exists, is valid, and belongs to the user + */ + @PostMapping("/internal/files/{uploadId}/verify") + fun verifyAndCompleteUpload( + @PathVariable uploadId: UUID, + @RequestParam userId: UUID, + ): ResponseEntity + + /** + * Gets metadata for a completed file upload. + * Called by divination-service to retrieve file information. + * + * @param uploadId The ID of the upload + * @return File metadata if upload exists and is completed + */ + @GetMapping("/internal/files/{uploadId}/metadata") + fun getUploadMetadata( + @PathVariable uploadId: UUID, + ): ResponseEntity + + /** + * Gets a presigned download URL for a file. + * Called by divination-service to generate download URLs for attachments. + * + * @param uploadId The ID of the upload + * @return Map containing "downloadUrl" key with presigned URL + */ + @GetMapping("/internal/files/{uploadId}/download-url") + fun getDownloadUrl( + @PathVariable uploadId: UUID, + ): ResponseEntity> +} diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/FileUploadDtos.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/FileUploadDtos.kt new file mode 100644 index 0000000..765d516 --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/FileUploadDtos.kt @@ -0,0 +1,42 @@ +package com.github.butvinmitmo.shared.dto + +import jakarta.validation.constraints.NotBlank +import java.time.Instant +import java.util.UUID + +/** + * Request to generate a presigned URL for file upload. + */ +data class PresignedUploadRequest( + @field:NotBlank(message = "File name is required") + val fileName: String, + @field:NotBlank(message = "Content type is required") + val contentType: String, +) + +/** + * Response containing presigned upload URL and metadata. + */ +data class PresignedUploadResponse( + val uploadId: UUID, + val uploadUrl: String, + val expiresAt: Instant, +) + +/** + * Response containing presigned download URL. + */ +data class DownloadUrlResponse( + val downloadUrl: String, +) + +/** + * Response containing file upload metadata. + */ +data class FileMetadataResponse( + val uploadId: UUID, + val originalFileName: String, + val contentType: String, + val fileSize: Long, + val completedAt: Instant, +) diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/FileUploadMetadataDto.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/FileUploadMetadataDto.kt new file mode 100644 index 0000000..af8b761 --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/FileUploadMetadataDto.kt @@ -0,0 +1,17 @@ +package com.github.butvinmitmo.shared.dto + +import java.time.Instant +import java.util.UUID + +/** + * File upload metadata returned by files-service internal API. + * Used by divination-service via Feign client to get file information. + */ +data class FileUploadMetadataDto( + val uploadId: UUID, + val filePath: String, + val originalFileName: String, + val contentType: String, + val fileSize: Long, + val completedAt: Instant, +) diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/InterpretationAttachmentDto.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/InterpretationAttachmentDto.kt new file mode 100644 index 0000000..96e0e85 --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/InterpretationAttachmentDto.kt @@ -0,0 +1,15 @@ +package com.github.butvinmitmo.shared.dto + +import java.util.UUID + +/** + * DTO for interpretation attachment information returned in API responses. + * Contains file metadata and a presigned download URL. + */ +data class InterpretationAttachmentDto( + val id: UUID, + val originalFileName: String, + val contentType: String, + val fileSize: Long, + val downloadUrl: String, +) diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/InterpretationDto.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/InterpretationDto.kt index 4574985..6e184be 100644 --- a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/InterpretationDto.kt +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/InterpretationDto.kt @@ -11,12 +11,14 @@ data class InterpretationDto( val createdAt: Instant, val author: UserDto, val spreadId: UUID, + val attachment: InterpretationAttachmentDto? = null, ) data class CreateInterpretationRequest( @field:NotBlank(message = "Interpretation text is required") @field:Size(min = 1, max = 50000, message = "Interpretation text must be between 1 and 50000 characters") val text: String, + val uploadId: UUID? = null, ) data class CreateInterpretationResponse( diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/NotificationDto.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/NotificationDto.kt new file mode 100644 index 0000000..9712a1f --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/NotificationDto.kt @@ -0,0 +1,19 @@ +package com.github.butvinmitmo.shared.dto + +import java.time.Instant +import java.util.UUID + +data class NotificationDto( + val id: UUID, + val recipientId: UUID, + val spreadId: UUID, + val interpretationAuthorId: UUID, + val title: String, + val message: String, + val isRead: Boolean, + val createdAt: Instant, +) + +data class UnreadCountResponse( + val count: Long, +) diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/EventType.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/EventType.kt new file mode 100644 index 0000000..8616f16 --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/EventType.kt @@ -0,0 +1,7 @@ +package com.github.butvinmitmo.shared.dto.events + +enum class EventType { + CREATED, + UPDATED, + DELETED, +} diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/FileEventData.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/FileEventData.kt new file mode 100644 index 0000000..1ec4a21 --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/FileEventData.kt @@ -0,0 +1,14 @@ +package com.github.butvinmitmo.shared.dto.events + +import java.time.Instant +import java.util.UUID + +data class FileEventData( + val uploadId: UUID, + val filePath: String, + val originalFileName: String, + val contentType: String, + val fileSize: Long?, + val userId: UUID, + val completedAt: Instant?, +) diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/InterpretationEventData.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/InterpretationEventData.kt new file mode 100644 index 0000000..58130f6 --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/InterpretationEventData.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.shared.dto.events + +import java.time.Instant +import java.util.UUID + +data class InterpretationEventData( + val id: UUID, + val text: String, + val authorId: UUID, + val spreadId: UUID, + val createdAt: Instant, +) diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/SpreadEventData.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/SpreadEventData.kt new file mode 100644 index 0000000..551113a --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/SpreadEventData.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.shared.dto.events + +import java.time.Instant +import java.util.UUID + +data class SpreadEventData( + val id: UUID, + val question: String?, + val layoutTypeId: UUID, + val authorId: UUID, + val createdAt: Instant, +) diff --git a/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/UserEventData.kt b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/UserEventData.kt new file mode 100644 index 0000000..592d2af --- /dev/null +++ b/shared-dto/src/main/kotlin/com/github/butvinmitmo/shared/dto/events/UserEventData.kt @@ -0,0 +1,11 @@ +package com.github.butvinmitmo.shared.dto.events + +import java.time.Instant +import java.util.UUID + +data class UserEventData( + val id: UUID, + val username: String, + val role: String, + val createdAt: Instant, +) diff --git a/tarot-service/build.gradle.kts b/tarot-service/build.gradle.kts index f00f86c..94c4dd5 100644 --- a/tarot-service/build.gradle.kts +++ b/tarot-service/build.gradle.kts @@ -1,7 +1,6 @@ plugins { kotlin("jvm") version "2.2.10" kotlin("plugin.spring") version "2.2.10" - kotlin("plugin.jpa") version "2.2.10" id("org.springframework.boot") version "3.5.6" id("io.spring.dependency-management") version "1.1.7" id("org.jlleitschuh.gradle.ktlint") version "12.1.2" @@ -35,13 +34,14 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") + runtimeOnly("org.postgresql:r2dbc-postgresql") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-database-postgresql") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.springdoc:springdoc-openapi-starter-webflux-api:2.8.4") - runtimeOnly("org.postgresql:postgresql") + implementation("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/TarotServiceApplication.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/TarotServiceApplication.kt index 7b4b861..0125b67 100644 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/TarotServiceApplication.kt +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/TarotServiceApplication.kt @@ -6,11 +6,20 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.security.SecurityScheme import io.swagger.v3.oas.annotations.servers.Server import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration import org.springframework.boot.runApplication import org.springframework.cloud.client.discovery.EnableDiscoveryClient +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories -@SpringBootApplication +@SpringBootApplication( + exclude = [ + JpaRepositoriesAutoConfiguration::class, + HibernateJpaAutoConfiguration::class, + ], +) @EnableDiscoveryClient +@EnableR2dbcRepositories @OpenAPIDefinition( servers = [Server(url = "http://localhost:8080", description = "API Gateway")], security = [SecurityRequirement(name = "bearerAuth")], diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/controller/CardController.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/controller/CardController.kt similarity index 87% rename from tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/controller/CardController.kt rename to tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/controller/CardController.kt index 29f1bde..cd62054 100644 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/controller/CardController.kt +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/controller/CardController.kt @@ -1,7 +1,8 @@ -package com.github.butvinmitmo.tarotservice.controller +package com.github.butvinmitmo.tarotservice.api.controller import com.github.butvinmitmo.shared.dto.CardDto -import com.github.butvinmitmo.tarotservice.service.TarotService +import com.github.butvinmitmo.tarotservice.api.mapper.CardDtoMapper +import com.github.butvinmitmo.tarotservice.application.service.TarotService import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.headers.Header @@ -29,6 +30,7 @@ import java.util.UUID @Validated class CardController( private val tarotService: TarotService, + private val cardDtoMapper: CardDtoMapper, ) { @GetMapping @Operation( @@ -79,11 +81,12 @@ class CardController( ): Mono>> = tarotService .getCards(page, size) - .map { response -> + .map { result -> + val totalPages = if (result.totalElements == 0L) 0 else ((result.totalElements - 1) / size + 1).toInt() ResponseEntity .ok() - .header("X-Total-Count", response.totalElements.toString()) - .body(response.content) + .header("X-Total-Count", result.totalElements.toString()) + .body(result.content.map { cardDtoMapper.toDto(it) }) } @GetMapping("/random") @@ -135,6 +138,8 @@ class CardController( count: Int, ): Mono>> = tarotService - .getRandomCardDtos(count) - .map { cards -> ResponseEntity.ok(cards) } + .getRandomCards(count) + .map { cards -> + ResponseEntity.ok(cards.map { cardDtoMapper.toDto(it) }) + } } diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/controller/LayoutTypeController.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/controller/LayoutTypeController.kt similarity index 88% rename from tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/controller/LayoutTypeController.kt rename to tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/controller/LayoutTypeController.kt index 5ada87e..f2af82c 100644 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/controller/LayoutTypeController.kt +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/controller/LayoutTypeController.kt @@ -1,7 +1,8 @@ -package com.github.butvinmitmo.tarotservice.controller +package com.github.butvinmitmo.tarotservice.api.controller import com.github.butvinmitmo.shared.dto.LayoutTypeDto -import com.github.butvinmitmo.tarotservice.service.TarotService +import com.github.butvinmitmo.tarotservice.api.mapper.LayoutTypeDtoMapper +import com.github.butvinmitmo.tarotservice.application.service.TarotService import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.headers.Header @@ -30,6 +31,7 @@ import java.util.UUID @Validated class LayoutTypeController( private val tarotService: TarotService, + private val layoutTypeDtoMapper: LayoutTypeDtoMapper, ) { @GetMapping @Operation( @@ -80,11 +82,11 @@ class LayoutTypeController( ): Mono>> = tarotService .getLayoutTypes(page, size) - .map { response -> + .map { result -> ResponseEntity .ok() - .header("X-Total-Count", response.totalElements.toString()) - .body(response.content) + .header("X-Total-Count", result.totalElements.toString()) + .body(result.content.map { layoutTypeDtoMapper.toDto(it) }) } @GetMapping("/{id}") @@ -130,6 +132,8 @@ class LayoutTypeController( @PathVariable id: UUID, ): Mono> = tarotService - .getLayoutTypeDtoById(id) - .map { layoutType -> ResponseEntity.ok(layoutType) } + .getLayoutTypeById(id) + .map { layoutType -> + ResponseEntity.ok(layoutTypeDtoMapper.toDto(layoutType)) + } } diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/ArcanaTypeMapper.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/ArcanaTypeDtoMapper.kt similarity index 64% rename from tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/ArcanaTypeMapper.kt rename to tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/ArcanaTypeDtoMapper.kt index 51926da..7825685 100644 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/ArcanaTypeMapper.kt +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/ArcanaTypeDtoMapper.kt @@ -1,11 +1,11 @@ -package com.github.butvinmitmo.tarotservice.mapper +package com.github.butvinmitmo.tarotservice.api.mapper import com.github.butvinmitmo.shared.dto.ArcanaTypeDto -import com.github.butvinmitmo.tarotservice.entity.ArcanaType +import com.github.butvinmitmo.tarotservice.domain.model.ArcanaType import org.springframework.stereotype.Component @Component -class ArcanaTypeMapper { +class ArcanaTypeDtoMapper { fun toDto(arcanaType: ArcanaType): ArcanaTypeDto = ArcanaTypeDto( id = arcanaType.id, diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/CardDtoMapper.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/CardDtoMapper.kt new file mode 100644 index 0000000..26665a6 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/CardDtoMapper.kt @@ -0,0 +1,17 @@ +package com.github.butvinmitmo.tarotservice.api.mapper + +import com.github.butvinmitmo.shared.dto.CardDto +import com.github.butvinmitmo.tarotservice.domain.model.Card +import org.springframework.stereotype.Component + +@Component +class CardDtoMapper( + private val arcanaTypeDtoMapper: ArcanaTypeDtoMapper, +) { + fun toDto(card: Card): CardDto = + CardDto( + id = card.id, + name = card.name, + arcanaType = arcanaTypeDtoMapper.toDto(card.arcanaType), + ) +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/LayoutTypeMapper.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/LayoutTypeDtoMapper.kt similarity index 68% rename from tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/LayoutTypeMapper.kt rename to tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/LayoutTypeDtoMapper.kt index 168d5a9..55801a1 100644 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/LayoutTypeMapper.kt +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/api/mapper/LayoutTypeDtoMapper.kt @@ -1,11 +1,11 @@ -package com.github.butvinmitmo.tarotservice.mapper +package com.github.butvinmitmo.tarotservice.api.mapper import com.github.butvinmitmo.shared.dto.LayoutTypeDto -import com.github.butvinmitmo.tarotservice.entity.LayoutType +import com.github.butvinmitmo.tarotservice.domain.model.LayoutType import org.springframework.stereotype.Component @Component -class LayoutTypeMapper { +class LayoutTypeDtoMapper { fun toDto(layoutType: LayoutType): LayoutTypeDto = LayoutTypeDto( id = layoutType.id, diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/interfaces/repository/CardRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/interfaces/repository/CardRepository.kt new file mode 100644 index 0000000..7991847 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/interfaces/repository/CardRepository.kt @@ -0,0 +1,16 @@ +package com.github.butvinmitmo.tarotservice.application.interfaces.repository + +import com.github.butvinmitmo.tarotservice.domain.model.Card +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +interface CardRepository { + fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux + + fun findRandomCards(limit: Int): Flux + + fun count(): Mono +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/interfaces/repository/LayoutTypeRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/interfaces/repository/LayoutTypeRepository.kt new file mode 100644 index 0000000..255a68f --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/interfaces/repository/LayoutTypeRepository.kt @@ -0,0 +1,17 @@ +package com.github.butvinmitmo.tarotservice.application.interfaces.repository + +import com.github.butvinmitmo.tarotservice.domain.model.LayoutType +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface LayoutTypeRepository { + fun findById(id: UUID): Mono + + fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux + + fun count(): Mono +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/service/TarotService.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/service/TarotService.kt new file mode 100644 index 0000000..b3519ab --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/application/service/TarotService.kt @@ -0,0 +1,62 @@ +package com.github.butvinmitmo.tarotservice.application.service + +import com.github.butvinmitmo.tarotservice.application.interfaces.repository.CardRepository +import com.github.butvinmitmo.tarotservice.application.interfaces.repository.LayoutTypeRepository +import com.github.butvinmitmo.tarotservice.domain.model.Card +import com.github.butvinmitmo.tarotservice.domain.model.LayoutType +import com.github.butvinmitmo.tarotservice.exception.NotFoundException +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.util.UUID + +data class PageResult( + val content: List, + val totalElements: Long, +) + +@Service +class TarotService( + private val cardRepository: CardRepository, + private val layoutTypeRepository: LayoutTypeRepository, +) { + fun getCards( + page: Int, + size: Int, + ): Mono> { + val offset = page.toLong() * size + return Mono + .zip( + cardRepository.findAllPaginated(offset, size).collectList(), + cardRepository.count(), + ).map { tuple -> + PageResult( + content = tuple.t1, + totalElements = tuple.t2, + ) + } + } + + fun getLayoutTypes( + page: Int, + size: Int, + ): Mono> { + val offset = page.toLong() * size + return Mono + .zip( + layoutTypeRepository.findAllPaginated(offset, size).collectList(), + layoutTypeRepository.count(), + ).map { tuple -> + PageResult( + content = tuple.t1, + totalElements = tuple.t2, + ) + } + } + + fun getLayoutTypeById(id: UUID): Mono = + layoutTypeRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("Layout type not found"))) + + fun getRandomCards(count: Int): Mono> = cardRepository.findRandomCards(count).collectList() +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/ArcanaType.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/ArcanaType.kt new file mode 100644 index 0000000..81dc55d --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/ArcanaType.kt @@ -0,0 +1,8 @@ +package com.github.butvinmitmo.tarotservice.domain.model + +import java.util.UUID + +data class ArcanaType( + val id: UUID, + val name: String, +) diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/Card.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/Card.kt new file mode 100644 index 0000000..b08532d --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/Card.kt @@ -0,0 +1,9 @@ +package com.github.butvinmitmo.tarotservice.domain.model + +import java.util.UUID + +data class Card( + val id: UUID, + val name: String, + val arcanaType: ArcanaType, +) diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/LayoutType.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/LayoutType.kt new file mode 100644 index 0000000..11a90fd --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/domain/model/LayoutType.kt @@ -0,0 +1,9 @@ +package com.github.butvinmitmo.tarotservice.domain.model + +import java.util.UUID + +data class LayoutType( + val id: UUID, + val name: String, + val cardsCount: Int, +) diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/ArcanaType.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/ArcanaType.kt deleted file mode 100644 index 3b3198b..0000000 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/ArcanaType.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.butvinmitmo.tarotservice.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Table -import org.hibernate.annotations.Generated -import java.util.UUID - -@Entity -@Table(name = "arcana_type") -class ArcanaType( - @Column(nullable = false, length = 16) - val name: String, -) { - @Id - @Generated - @Column(columnDefinition = "uuid", insertable = false, nullable = false) - lateinit var id: UUID -} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/Card.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/Card.kt deleted file mode 100644 index cea7a1b..0000000 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/Card.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.butvinmitmo.tarotservice.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.FetchType -import jakarta.persistence.Id -import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToOne -import jakarta.persistence.Table -import org.hibernate.annotations.Generated -import java.util.UUID - -@Entity -@Table(name = "card") -class Card( - @Column(nullable = false, length = 128) - val name: String, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "arcana_type_id", nullable = false) - val arcanaType: ArcanaType, -) { - @Id - @Generated - @Column(columnDefinition = "uuid", insertable = false, nullable = false) - lateinit var id: UUID -} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/LayoutType.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/LayoutType.kt deleted file mode 100644 index ca2815f..0000000 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/entity/LayoutType.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.butvinmitmo.tarotservice.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Table -import org.hibernate.annotations.Generated -import java.util.UUID - -@Entity -@Table(name = "layout_type") -class LayoutType( - @Column(nullable = false, length = 32) - val name: String, - @Column(name = "cards_count", nullable = false) - val cardsCount: Int, -) { - @Id - @Generated - @Column(columnDefinition = "uuid", insertable = false, nullable = false) - lateinit var id: UUID -} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/R2dbcCardRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/R2dbcCardRepository.kt new file mode 100644 index 0000000..f29c339 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/R2dbcCardRepository.kt @@ -0,0 +1,55 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence + +import com.github.butvinmitmo.tarotservice.application.interfaces.repository.CardRepository +import com.github.butvinmitmo.tarotservice.domain.model.ArcanaType +import com.github.butvinmitmo.tarotservice.domain.model.Card +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.mapper.ArcanaTypeEntityMapper +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.mapper.CardEntityMapper +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.repository.SpringDataArcanaTypeRepository +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.repository.SpringDataCardRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +class R2dbcCardRepository( + private val springDataCardRepository: SpringDataCardRepository, + private val springDataArcanaTypeRepository: SpringDataArcanaTypeRepository, + private val cardEntityMapper: CardEntityMapper, + private val arcanaTypeEntityMapper: ArcanaTypeEntityMapper, +) : CardRepository { + override fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux = + getArcanaTypeMap() + .flatMapMany { arcanaTypeMap -> + springDataCardRepository + .findAllPaginated(offset, limit) + .map { cardEntity -> + val arcanaType = arcanaTypeMap[cardEntity.arcanaTypeId]!! + cardEntityMapper.toDomain(cardEntity, arcanaType) + } + } + + override fun findRandomCards(limit: Int): Flux = + getArcanaTypeMap() + .flatMapMany { arcanaTypeMap -> + springDataCardRepository + .findRandomCards(limit) + .map { cardEntity -> + val arcanaType = arcanaTypeMap[cardEntity.arcanaTypeId]!! + cardEntityMapper.toDomain(cardEntity, arcanaType) + } + } + + override fun count(): Mono = springDataCardRepository.count() + + private fun getArcanaTypeMap(): Mono> = + springDataArcanaTypeRepository + .findAll() + .map { arcanaTypeEntityMapper.toDomain(it) } + .collectList() + .map { types -> types.associateBy { it.id } } +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/R2dbcLayoutTypeRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/R2dbcLayoutTypeRepository.kt new file mode 100644 index 0000000..25c7a83 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/R2dbcLayoutTypeRepository.kt @@ -0,0 +1,31 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence + +import com.github.butvinmitmo.tarotservice.application.interfaces.repository.LayoutTypeRepository +import com.github.butvinmitmo.tarotservice.domain.model.LayoutType +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.mapper.LayoutTypeEntityMapper +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.repository.SpringDataLayoutTypeRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +class R2dbcLayoutTypeRepository( + private val springDataLayoutTypeRepository: SpringDataLayoutTypeRepository, + private val layoutTypeEntityMapper: LayoutTypeEntityMapper, +) : LayoutTypeRepository { + override fun findById(id: UUID): Mono = + springDataLayoutTypeRepository + .findById(id) + .map { layoutTypeEntityMapper.toDomain(it) } + + override fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux = + springDataLayoutTypeRepository + .findAllPaginated(offset, limit) + .map { layoutTypeEntityMapper.toDomain(it) } + + override fun count(): Mono = springDataLayoutTypeRepository.count() +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/ArcanaTypeEntity.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/ArcanaTypeEntity.kt new file mode 100644 index 0000000..4b29cd2 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/ArcanaTypeEntity.kt @@ -0,0 +1,14 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.util.UUID + +@Table("arcana_type") +data class ArcanaTypeEntity( + @Id + val id: UUID? = null, + @Column("name") + val name: String, +) diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/CardEntity.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/CardEntity.kt new file mode 100644 index 0000000..8b3ed50 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/CardEntity.kt @@ -0,0 +1,16 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.util.UUID + +@Table("card") +data class CardEntity( + @Id + val id: UUID? = null, + @Column("name") + val name: String, + @Column("arcana_type_id") + val arcanaTypeId: UUID, +) diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/LayoutTypeEntity.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/LayoutTypeEntity.kt new file mode 100644 index 0000000..7f5d9d5 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/entity/LayoutTypeEntity.kt @@ -0,0 +1,16 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.util.UUID + +@Table("layout_type") +data class LayoutTypeEntity( + @Id + val id: UUID? = null, + @Column("name") + val name: String, + @Column("cards_count") + val cardsCount: Int, +) diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/ArcanaTypeEntityMapper.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/ArcanaTypeEntityMapper.kt new file mode 100644 index 0000000..c56f371 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/ArcanaTypeEntityMapper.kt @@ -0,0 +1,14 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.tarotservice.domain.model.ArcanaType +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity.ArcanaTypeEntity +import org.springframework.stereotype.Component + +@Component +class ArcanaTypeEntityMapper { + fun toDomain(entity: ArcanaTypeEntity): ArcanaType = + ArcanaType( + id = entity.id!!, + name = entity.name, + ) +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/CardEntityMapper.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/CardEntityMapper.kt new file mode 100644 index 0000000..de6b9ba --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/CardEntityMapper.kt @@ -0,0 +1,19 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.tarotservice.domain.model.ArcanaType +import com.github.butvinmitmo.tarotservice.domain.model.Card +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity.CardEntity +import org.springframework.stereotype.Component + +@Component +class CardEntityMapper { + fun toDomain( + entity: CardEntity, + arcanaType: ArcanaType, + ): Card = + Card( + id = entity.id!!, + name = entity.name, + arcanaType = arcanaType, + ) +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/LayoutTypeEntityMapper.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/LayoutTypeEntityMapper.kt new file mode 100644 index 0000000..a7492c9 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/mapper/LayoutTypeEntityMapper.kt @@ -0,0 +1,15 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.tarotservice.domain.model.LayoutType +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity.LayoutTypeEntity +import org.springframework.stereotype.Component + +@Component +class LayoutTypeEntityMapper { + fun toDomain(entity: LayoutTypeEntity): LayoutType = + LayoutType( + id = entity.id!!, + name = entity.name, + cardsCount = entity.cardsCount, + ) +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataArcanaTypeRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataArcanaTypeRepository.kt new file mode 100644 index 0000000..4bf6150 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataArcanaTypeRepository.kt @@ -0,0 +1,9 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity.ArcanaTypeEntity +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +interface SpringDataArcanaTypeRepository : R2dbcRepository diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataCardRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataCardRepository.kt new file mode 100644 index 0000000..c432fb4 --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataCardRepository.kt @@ -0,0 +1,24 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity.CardEntity +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +interface SpringDataCardRepository : R2dbcRepository { + @Query("SELECT * FROM card ORDER BY RANDOM() LIMIT :limit") + fun findRandomCards(limit: Int): Flux + + @Query("SELECT * FROM card ORDER BY id LIMIT :limit OFFSET :offset") + fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux + + @Query("SELECT COUNT(*) FROM card") + override fun count(): Mono +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataLayoutTypeRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataLayoutTypeRepository.kt new file mode 100644 index 0000000..68f052c --- /dev/null +++ b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/infrastructure/persistence/repository/SpringDataLayoutTypeRepository.kt @@ -0,0 +1,21 @@ +package com.github.butvinmitmo.tarotservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.tarotservice.infrastructure.persistence.entity.LayoutTypeEntity +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +interface SpringDataLayoutTypeRepository : R2dbcRepository { + @Query("SELECT * FROM layout_type ORDER BY id LIMIT :limit OFFSET :offset") + fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux + + @Query("SELECT COUNT(*) FROM layout_type") + override fun count(): Mono +} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/CardMapper.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/CardMapper.kt deleted file mode 100644 index 6cbb420..0000000 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/mapper/CardMapper.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.butvinmitmo.tarotservice.mapper - -import com.github.butvinmitmo.shared.dto.CardDto -import com.github.butvinmitmo.tarotservice.entity.Card -import org.springframework.stereotype.Component - -@Component -class CardMapper( - private val arcanaTypeMapper: ArcanaTypeMapper, -) { - fun toDto(card: Card): CardDto = - CardDto( - id = card.id, - name = card.name, - arcanaType = arcanaTypeMapper.toDto(card.arcanaType), - ) -} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/repository/CardRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/repository/CardRepository.kt deleted file mode 100644 index 1942b06..0000000 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/repository/CardRepository.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.butvinmitmo.tarotservice.repository - -import com.github.butvinmitmo.tarotservice.entity.Card -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param -import org.springframework.stereotype.Repository -import java.util.UUID - -@Repository -interface CardRepository : JpaRepository { - @Query(value = "SELECT * FROM card ORDER BY RANDOM() LIMIT :limit", nativeQuery = true) - fun findRandomCards( - @Param("limit") limit: Int, - ): List -} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/repository/LayoutTypeRepository.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/repository/LayoutTypeRepository.kt deleted file mode 100644 index 0760007..0000000 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/repository/LayoutTypeRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.butvinmitmo.tarotservice.repository - -import com.github.butvinmitmo.tarotservice.entity.LayoutType -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.stereotype.Repository -import java.util.UUID - -@Repository -interface LayoutTypeRepository : JpaRepository { - fun findByName(name: String): LayoutType? -} diff --git a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/service/TarotService.kt b/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/service/TarotService.kt deleted file mode 100644 index 04cf8f0..0000000 --- a/tarot-service/src/main/kotlin/com/github/butvinmitmo/tarotservice/service/TarotService.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.github.butvinmitmo.tarotservice.service - -import com.github.butvinmitmo.shared.dto.CardDto -import com.github.butvinmitmo.shared.dto.LayoutTypeDto -import com.github.butvinmitmo.shared.dto.PageResponse -import com.github.butvinmitmo.tarotservice.entity.Card -import com.github.butvinmitmo.tarotservice.entity.LayoutType -import com.github.butvinmitmo.tarotservice.exception.NotFoundException -import com.github.butvinmitmo.tarotservice.mapper.CardMapper -import com.github.butvinmitmo.tarotservice.mapper.LayoutTypeMapper -import com.github.butvinmitmo.tarotservice.repository.CardRepository -import com.github.butvinmitmo.tarotservice.repository.LayoutTypeRepository -import org.springframework.data.domain.PageRequest -import org.springframework.stereotype.Service -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers -import java.util.UUID - -@Service -class TarotService( - private val cardRepository: CardRepository, - private val layoutTypeRepository: LayoutTypeRepository, - private val cardMapper: CardMapper, - private val layoutTypeMapper: LayoutTypeMapper, -) { - fun getCards( - page: Int, - size: Int, - ): Mono> = - Mono - .fromCallable { - val pageable = PageRequest.of(page, size) - val cardsPage = cardRepository.findAll(pageable) - PageResponse( - content = cardsPage.content.map { cardMapper.toDto(it) }, - page = cardsPage.number, - size = cardsPage.size, - totalElements = cardsPage.totalElements, - totalPages = cardsPage.totalPages, - isFirst = cardsPage.isFirst, - isLast = cardsPage.isLast, - ) - }.subscribeOn(Schedulers.boundedElastic()) - - fun getLayoutTypes( - page: Int, - size: Int, - ): Mono> = - Mono - .fromCallable { - val pageable = PageRequest.of(page, size) - val layoutTypesPage = layoutTypeRepository.findAll(pageable) - PageResponse( - content = layoutTypesPage.content.map { layoutTypeMapper.toDto(it) }, - page = layoutTypesPage.number, - size = layoutTypesPage.size, - totalElements = layoutTypesPage.totalElements, - totalPages = layoutTypesPage.totalPages, - isFirst = layoutTypesPage.isFirst, - isLast = layoutTypesPage.isLast, - ) - }.subscribeOn(Schedulers.boundedElastic()) - - fun getLayoutTypeById(id: UUID): Mono = - Mono - .fromCallable { - layoutTypeRepository - .findById(id) - .orElseThrow { NotFoundException("Layout type not found") } - }.subscribeOn(Schedulers.boundedElastic()) - - fun getLayoutTypeDtoById(id: UUID): Mono = - getLayoutTypeById(id) - .map { layoutTypeMapper.toDto(it) } - - fun getRandomCards(count: Int): Mono> = - Mono - .fromCallable { - cardRepository.findRandomCards(count) - }.subscribeOn(Schedulers.boundedElastic()) - - fun getRandomCardDtos(count: Int): Mono> = - getRandomCards(count) - .map { cards -> cards.map { cardMapper.toDto(it) } } -} diff --git a/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/BaseIntegrationTest.kt b/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/BaseIntegrationTest.kt index a539fd0..54bebb2 100644 --- a/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/BaseIntegrationTest.kt +++ b/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/BaseIntegrationTest.kt @@ -25,10 +25,15 @@ abstract class BaseIntegrationTest { @JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url") { postgres.jdbcUrl } - registry.add("spring.datasource.username") { postgres.username } - registry.add("spring.datasource.password") { postgres.password } - registry.add("spring.jpa.hibernate.ddl-auto") { "validate" } + registry.add("spring.r2dbc.url") { + "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(5432)}/${postgres.databaseName}" + } + registry.add("spring.r2dbc.username") { postgres.username } + registry.add("spring.r2dbc.password") { postgres.password } + registry.add("spring.flyway.url") { postgres.jdbcUrl } + registry.add("spring.flyway.user") { postgres.username } + registry.add("spring.flyway.password") { postgres.password } + registry.add("spring.flyway.enabled") { "true" } } } } diff --git a/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/controller/BaseControllerIntegrationTest.kt b/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/controller/BaseControllerIntegrationTest.kt index aa12253..c81b4ae 100644 --- a/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/controller/BaseControllerIntegrationTest.kt +++ b/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/controller/BaseControllerIntegrationTest.kt @@ -32,10 +32,15 @@ abstract class BaseControllerIntegrationTest { @JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url") { postgres.jdbcUrl } - registry.add("spring.datasource.username") { postgres.username } - registry.add("spring.datasource.password") { postgres.password } - registry.add("spring.jpa.hibernate.ddl-auto") { "validate" } + registry.add("spring.r2dbc.url") { + "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(5432)}/${postgres.databaseName}" + } + registry.add("spring.r2dbc.username") { postgres.username } + registry.add("spring.r2dbc.password") { postgres.password } + registry.add("spring.flyway.url") { postgres.jdbcUrl } + registry.add("spring.flyway.user") { postgres.username } + registry.add("spring.flyway.password") { postgres.password } + registry.add("spring.flyway.enabled") { "true" } } } } diff --git a/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/service/TarotServiceIntegrationTest.kt b/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/service/TarotServiceIntegrationTest.kt index 5399540..3a51591 100644 --- a/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/service/TarotServiceIntegrationTest.kt +++ b/tarot-service/src/test/kotlin/com/github/butvinmitmo/tarotservice/integration/service/TarotServiceIntegrationTest.kt @@ -1,8 +1,8 @@ package com.github.butvinmitmo.tarotservice.integration.service +import com.github.butvinmitmo.tarotservice.application.service.TarotService import com.github.butvinmitmo.tarotservice.exception.NotFoundException import com.github.butvinmitmo.tarotservice.integration.BaseIntegrationTest -import com.github.butvinmitmo.tarotservice.service.TarotService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue @@ -63,14 +63,16 @@ class TarotServiceIntegrationTest : BaseIntegrationTest() { } @Test - fun `getRandomCardDtos should return requested number of cards as DTOs`() { - val cardDtos = tarotService.getRandomCardDtos(5).block() + fun `getRandomCards should return cards with arcana type`() { + val cards = tarotService.getRandomCards(5).block() - assertEquals(5, cardDtos!!.size) - cardDtos.forEach { card -> + assertEquals(5, cards!!.size) + cards.forEach { card -> assertNotNull(card.id) assertNotNull(card.name) assertNotNull(card.arcanaType) + assertNotNull(card.arcanaType.id) + assertNotNull(card.arcanaType.name) } } } diff --git a/tarot-service/src/test/resources/application-test.yml b/tarot-service/src/test/resources/application-test.yml index 0172788..35d3776 100644 --- a/tarot-service/src/test/resources/application-test.yml +++ b/tarot-service/src/test/resources/application-test.yml @@ -4,24 +4,22 @@ spring: cloud: config: enabled: false - datasource: - url: jdbc:postgresql://localhost:5432/test_db + r2dbc: + url: r2dbc:postgresql://localhost:5432/test_db username: test_user password: test_password - driver-class-name: org.postgresql.Driver - jpa: - hibernate: - ddl-auto: validate - open-in-view: false - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect + pool: + initial-size: 5 + max-size: 20 flyway: enabled: true locations: classpath:db/migration table: flyway_schema_history_tarot baseline-on-migrate: true baseline-version: 0 + url: jdbc:postgresql://localhost:5432/test_db + user: test_user + password: test_password eureka: client: diff --git a/user-service/build.gradle.kts b/user-service/build.gradle.kts index 04acd23..4decf2e 100644 --- a/user-service/build.gradle.kts +++ b/user-service/build.gradle.kts @@ -1,7 +1,6 @@ plugins { kotlin("jvm") version "2.2.10" kotlin("plugin.spring") version "2.2.10" - kotlin("plugin.jpa") version "2.2.10" id("org.springframework.boot") version "3.5.6" id("io.spring.dependency-management") version "1.1.7" id("org.jlleitschuh.gradle.ktlint") version "12.1.2" @@ -35,26 +34,29 @@ dependencies { implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") implementation(project(":shared-dto")) implementation(project(":shared-clients")) - implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") + runtimeOnly("org.postgresql:r2dbc-postgresql") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-database-postgresql") implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.4") + implementation("org.springdoc:springdoc-openapi-starter-webflux-api:2.8.4") // Spring Security for method-level authorization implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.kafka:spring-kafka") // JWT library for token generation implementation("io.jsonwebtoken:jjwt-api:0.12.3") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3") - runtimeOnly("org.postgresql:postgresql") + implementation("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.projectreactor:reactor-test") testImplementation("org.springframework.security:spring-security-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/UserServiceApplication.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/UserServiceApplication.kt index 12d35a2..1f42590 100644 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/UserServiceApplication.kt +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/UserServiceApplication.kt @@ -6,15 +6,24 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.security.SecurityScheme import io.swagger.v3.oas.annotations.servers.Server import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration import org.springframework.boot.runApplication import org.springframework.cloud.client.discovery.EnableDiscoveryClient import org.springframework.cloud.openfeign.EnableFeignClients import org.springframework.context.annotation.ComponentScan +import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories -@SpringBootApplication +@SpringBootApplication( + exclude = [ + JpaRepositoriesAutoConfiguration::class, + HibernateJpaAutoConfiguration::class, + ], +) @EnableDiscoveryClient @EnableFeignClients(basePackages = ["com.github.butvinmitmo.shared.client"]) @ComponentScan(basePackages = ["com.github.butvinmitmo.userservice", "com.github.butvinmitmo.shared.client"]) +@EnableR2dbcRepositories(basePackages = ["com.github.butvinmitmo.userservice.infrastructure.persistence.repository"]) @OpenAPIDefinition( servers = [Server(url = "http://localhost:8080", description = "API Gateway")], security = [SecurityRequirement(name = "bearerAuth")], diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/controller/AuthController.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/controller/AuthController.kt similarity index 69% rename from user-service/src/main/kotlin/com/github/butvinmitmo/userservice/controller/AuthController.kt rename to user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/controller/AuthController.kt index 8396ba8..93fc594 100644 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/controller/AuthController.kt +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/controller/AuthController.kt @@ -1,8 +1,8 @@ -package com.github.butvinmitmo.userservice.controller +package com.github.butvinmitmo.userservice.api.controller import com.github.butvinmitmo.shared.dto.AuthTokenResponse import com.github.butvinmitmo.shared.dto.LoginRequest -import com.github.butvinmitmo.userservice.service.UserService +import com.github.butvinmitmo.userservice.application.service.UserService import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono @RestController @RequestMapping("/api/v0.0.1/auth") @@ -36,8 +37,17 @@ class AuthController( ) fun login( @Valid @RequestBody request: LoginRequest, - ): ResponseEntity { - val response = userService.authenticate(request) - return ResponseEntity.ok(response) - } + ): Mono> = + userService + .authenticate(request.username, request.password) + .map { result -> + ResponseEntity.ok( + AuthTokenResponse( + token = result.token, + expiresAt = result.expiresAt, + username = result.username, + role = result.role, + ), + ) + } } diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/controller/UserController.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/controller/UserController.kt similarity index 89% rename from user-service/src/main/kotlin/com/github/butvinmitmo/userservice/controller/UserController.kt rename to user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/controller/UserController.kt index 76a7caa..f5f9c4f 100644 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/controller/UserController.kt +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/controller/UserController.kt @@ -1,10 +1,11 @@ -package com.github.butvinmitmo.userservice.controller +package com.github.butvinmitmo.userservice.api.controller import com.github.butvinmitmo.shared.dto.CreateUserRequest import com.github.butvinmitmo.shared.dto.CreateUserResponse import com.github.butvinmitmo.shared.dto.UpdateUserRequest import com.github.butvinmitmo.shared.dto.UserDto -import com.github.butvinmitmo.userservice.service.UserService +import com.github.butvinmitmo.userservice.api.mapper.UserDtoMapper +import com.github.butvinmitmo.userservice.application.service.UserService import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.headers.Header @@ -30,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono import java.util.UUID @RestController @@ -38,6 +40,7 @@ import java.util.UUID @Validated class UserController( private val userService: UserService, + private val userDtoMapper: UserDtoMapper, ) { @PostMapping @PreAuthorize("hasRole('ADMIN')") @@ -98,10 +101,10 @@ class UserController( ) fun createUser( @Valid @RequestBody request: CreateUserRequest, - ): ResponseEntity { - val response = userService.createUser(request) - return ResponseEntity.status(HttpStatus.CREATED).body(response) - } + ): Mono> = + userService + .createUser(request.username, request.password, request.role) + .map { id -> ResponseEntity.status(HttpStatus.CREATED).body(CreateUserResponse(id = id)) } @GetMapping @Operation( @@ -143,13 +146,13 @@ class UserController( @Min(1) @Max(50) size: Int, - ): ResponseEntity> { - val response = userService.getUsers(page, size) - return ResponseEntity - .ok() - .header("X-Total-Count", response.totalElements.toString()) - .body(response.content) - } + ): Mono>> = + userService.getUsers(page, size).map { response -> + ResponseEntity + .ok() + .header("X-Total-Count", response.totalElements.toString()) + .body(response.content.map { userDtoMapper.toDto(it) }) + } @GetMapping("/{id}") @Operation( @@ -187,10 +190,7 @@ class UserController( @Parameter(description = "User ID", required = true) @PathVariable id: UUID, - ): ResponseEntity { - val user = userService.getUser(id) - return ResponseEntity.ok(user) - } + ): Mono> = userService.getUser(id).map { ResponseEntity.ok(userDtoMapper.toDto(it)) } @PutMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") @@ -251,10 +251,10 @@ class UserController( @PathVariable id: UUID, @Valid @RequestBody request: UpdateUserRequest, - ): ResponseEntity { - val updatedUser = userService.updateUser(id, request) - return ResponseEntity.ok(updatedUser) - } + ): Mono> = + userService + .updateUser(id, request.username, request.password, request.role) + .map { ResponseEntity.ok(userDtoMapper.toDto(it)) } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") @@ -298,8 +298,5 @@ class UserController( @Parameter(description = "User ID", required = true) @PathVariable id: UUID, - ): ResponseEntity { - userService.deleteUser(id) - return ResponseEntity.noContent().build() - } + ): Mono> = userService.deleteUser(id).then(Mono.just(ResponseEntity.noContent().build())) } diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/mapper/UserDtoMapper.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/mapper/UserDtoMapper.kt new file mode 100644 index 0000000..0251de9 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/api/mapper/UserDtoMapper.kt @@ -0,0 +1,16 @@ +package com.github.butvinmitmo.userservice.api.mapper + +import com.github.butvinmitmo.shared.dto.UserDto +import com.github.butvinmitmo.userservice.domain.model.User +import org.springframework.stereotype.Component + +@Component +class UserDtoMapper { + fun toDto(user: User): UserDto = + UserDto( + id = user.id!!, + username = user.username, + role = user.role.name, + createdAt = user.createdAt!!, + ) +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/provider/PasswordEncoder.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/provider/PasswordEncoder.kt new file mode 100644 index 0000000..705d231 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/provider/PasswordEncoder.kt @@ -0,0 +1,10 @@ +package com.github.butvinmitmo.userservice.application.interfaces.provider + +interface PasswordEncoder { + fun encode(rawPassword: String): String + + fun matches( + rawPassword: String, + encodedPassword: String, + ): Boolean +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/provider/TokenProvider.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/provider/TokenProvider.kt new file mode 100644 index 0000000..cd11765 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/provider/TokenProvider.kt @@ -0,0 +1,13 @@ +package com.github.butvinmitmo.userservice.application.interfaces.provider + +import com.github.butvinmitmo.userservice.domain.model.User +import java.time.Instant + +data class TokenResult( + val token: String, + val expiresAt: Instant, +) + +interface TokenProvider { + fun generateToken(user: User): TokenResult +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/publisher/UserEventPublisher.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/publisher/UserEventPublisher.kt new file mode 100644 index 0000000..ab6daaa --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/publisher/UserEventPublisher.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.userservice.application.interfaces.publisher + +import com.github.butvinmitmo.userservice.domain.model.User +import reactor.core.publisher.Mono + +interface UserEventPublisher { + fun publishCreated(user: User): Mono + + fun publishUpdated(user: User): Mono + + fun publishDeleted(user: User): Mono +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/repository/RoleRepository.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/repository/RoleRepository.kt new file mode 100644 index 0000000..3441d7c --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/repository/RoleRepository.kt @@ -0,0 +1,11 @@ +package com.github.butvinmitmo.userservice.application.interfaces.repository + +import com.github.butvinmitmo.userservice.domain.model.Role +import reactor.core.publisher.Mono +import java.util.UUID + +interface RoleRepository { + fun findById(id: UUID): Mono + + fun findByName(name: String): Mono +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/repository/UserRepository.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/repository/UserRepository.kt new file mode 100644 index 0000000..c577c7f --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/interfaces/repository/UserRepository.kt @@ -0,0 +1,25 @@ +package com.github.butvinmitmo.userservice.application.interfaces.repository + +import com.github.butvinmitmo.userservice.domain.model.User +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface UserRepository { + fun findById(id: UUID): Mono + + fun findByUsername(username: String): Mono + + fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux + + fun count(): Mono + + fun save(user: User): Mono + + fun existsById(id: UUID): Mono + + fun deleteById(id: UUID): Mono +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/service/UserService.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/service/UserService.kt new file mode 100644 index 0000000..5c06858 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/application/service/UserService.kt @@ -0,0 +1,188 @@ +package com.github.butvinmitmo.userservice.application.service + +import com.github.butvinmitmo.userservice.application.interfaces.provider.PasswordEncoder +import com.github.butvinmitmo.userservice.application.interfaces.provider.TokenProvider +import com.github.butvinmitmo.userservice.application.interfaces.publisher.UserEventPublisher +import com.github.butvinmitmo.userservice.application.interfaces.repository.RoleRepository +import com.github.butvinmitmo.userservice.application.interfaces.repository.UserRepository +import com.github.butvinmitmo.userservice.domain.model.Role +import com.github.butvinmitmo.userservice.domain.model.RoleType +import com.github.butvinmitmo.userservice.domain.model.User +import com.github.butvinmitmo.userservice.exception.ConflictException +import com.github.butvinmitmo.userservice.exception.NotFoundException +import com.github.butvinmitmo.userservice.exception.UnauthorizedException +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.time.Instant +import java.util.UUID + +data class PageResult( + val content: List, + val totalElements: Long, +) + +data class AuthResult( + val token: String, + val expiresAt: Instant, + val username: String, + val role: String, +) + +@Service +class UserService( + private val userRepository: UserRepository, + private val roleRepository: RoleRepository, + private val passwordEncoder: PasswordEncoder, + private val tokenProvider: TokenProvider, + private val userEventPublisher: UserEventPublisher, +) { + private val logger = LoggerFactory.getLogger(UserService::class.java) + + fun authenticate( + username: String, + password: String, + ): Mono = + userRepository + .findByUsername(username) + .switchIfEmpty(Mono.error(UnauthorizedException("Invalid username or password"))) + .flatMap { user -> + Mono + .fromCallable { passwordEncoder.matches(password, user.passwordHash) } + .subscribeOn(Schedulers.boundedElastic()) + .flatMap { matches -> + if (!matches) { + Mono.error(UnauthorizedException("Invalid username or password")) + } else { + val tokenResult = tokenProvider.generateToken(user) + Mono.just( + AuthResult( + token = tokenResult.token, + expiresAt = tokenResult.expiresAt, + username = user.username, + role = user.role.name, + ), + ) + } + } + } + + fun createUser( + username: String, + password: String, + roleName: String?, + ): Mono = + userRepository + .findByUsername(username) + .flatMap { Mono.error(ConflictException("User with this username already exists")) } + .switchIfEmpty( + Mono.defer { + getRoleByName(roleName).flatMap { role -> + Mono + .fromCallable { passwordEncoder.encode(password) } + .subscribeOn(Schedulers.boundedElastic()) + .flatMap { passwordHash -> + val user = + User( + id = null, + username = username, + passwordHash = passwordHash, + role = role, + createdAt = Instant.now(), + ) + userRepository + .save(user) + .flatMap { saved -> + userEventPublisher.publishCreated(saved).thenReturn(saved.id!!) + } + } + } + }, + ) + + fun getUsers( + page: Int, + size: Int, + ): Mono> { + val offset = page.toLong() * size + return userRepository + .count() + .flatMap { totalElements -> + userRepository + .findAllPaginated(offset, size) + .collectList() + .map { users -> + PageResult( + content = users, + totalElements = totalElements, + ) + } + } + } + + fun getUser(id: UUID): Mono = + userRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("User not found"))) + + fun updateUser( + id: UUID, + username: String?, + password: String?, + roleName: String?, + ): Mono = + userRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("User not found"))) + .flatMap { user -> + val roleMono = + if (roleName != null) { + getRoleByName(roleName) + } else { + Mono.just(user.role) + } + + val passwordHashMono = + if (password != null) { + Mono + .fromCallable { passwordEncoder.encode(password) } + .subscribeOn(Schedulers.boundedElastic()) + } else { + Mono.just(user.passwordHash) + } + + Mono + .zip(roleMono, passwordHashMono) + .flatMap { tuple -> + val updatedUser = + user.copy( + username = username ?: user.username, + passwordHash = tuple.t2, + role = tuple.t1, + ) + userRepository + .save(updatedUser) + .flatMap { saved -> + userEventPublisher.publishUpdated(saved).thenReturn(saved) + } + } + } + + fun deleteUser(id: UUID): Mono = + userRepository + .findById(id) + .switchIfEmpty(Mono.error(NotFoundException("User not found"))) + .flatMap { user -> + userRepository + .deleteById(id) + .then(Mono.defer { userEventPublisher.publishDeleted(user) }) + } + + private fun getRoleByName(roleName: String?): Mono { + val effectiveRoleName = roleName ?: RoleType.USER.name + return roleRepository + .findByName(effectiveRoleName) + .switchIfEmpty(Mono.error(NotFoundException("Role not found: $effectiveRoleName"))) + } +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/FeignConfiguration.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/FeignConfiguration.kt new file mode 100644 index 0000000..f50685a --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/FeignConfiguration.kt @@ -0,0 +1,19 @@ +package com.github.butvinmitmo.userservice.config + +import org.springframework.boot.autoconfigure.http.HttpMessageConverters +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter + +/** + * Configuration for Feign clients in WebFlux environment. + * + * Spring WebFlux doesn't provide HttpMessageConverters autoconfiguration + * (unlike Spring MVC), but Feign clients need them for JSON serialization. + * This configuration manually provides the required bean. + */ +@Configuration +class FeignConfiguration { + @Bean + fun httpMessageConverters(): HttpMessageConverters = HttpMessageConverters(MappingJackson2HttpMessageConverter()) +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/KafkaConfig.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/KafkaConfig.kt new file mode 100644 index 0000000..55a495c --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/KafkaConfig.kt @@ -0,0 +1,36 @@ +package com.github.butvinmitmo.userservice.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.butvinmitmo.shared.dto.events.UserEventData +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.boot.autoconfigure.kafka.KafkaProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonSerializer + +@Configuration +class KafkaConfig { + @Bean + fun producerFactory( + kafkaProperties: KafkaProperties, + objectMapper: ObjectMapper, + ): ProducerFactory { + val props = kafkaProperties.buildProducerProperties(null) + val jsonSerializer = + JsonSerializer(objectMapper).apply { + isAddTypeInfo = false + } + return DefaultKafkaProducerFactory( + props, + StringSerializer(), + jsonSerializer, + ) + } + + @Bean + fun kafkaTemplate(producerFactory: ProducerFactory): KafkaTemplate = + KafkaTemplate(producerFactory) +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/SecurityConfig.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/SecurityConfig.kt index 4948aa3..f181d8b 100644 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/SecurityConfig.kt +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/config/SecurityConfig.kt @@ -1,37 +1,30 @@ package com.github.butvinmitmo.userservice.config -import com.github.butvinmitmo.userservice.security.GatewayAuthenticationFilter +import com.github.butvinmitmo.userservice.infrastructure.security.GatewayAuthenticationWebFilter import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity -import org.springframework.security.config.http.SessionCreationPolicy -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.SecurityWebFiltersOrder +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.web.server.SecurityWebFilterChain @Configuration -@EnableWebSecurity -@EnableMethodSecurity(prePostEnabled = true) +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity(useAuthorizationManager = true) class SecurityConfig( - private val gatewayAuthenticationFilter: GatewayAuthenticationFilter, + private val gatewayAuthenticationWebFilter: GatewayAuthenticationWebFilter, ) { @Bean - fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder(10) - - @Bean - fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http .csrf { it.disable() } - .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .authorizeHttpRequests { + .authorizeExchange { it - .requestMatchers("/api/v0.0.1/auth/**", "/actuator/**", "/api-docs/**") + .pathMatchers("/api/v0.0.1/auth/**", "/actuator/**", "/api-docs/**") .permitAll() - .anyRequest() + .anyExchange() .authenticated() - }.addFilterBefore(gatewayAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) + }.addFilterAt(gatewayAuthenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) .build() } diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/entity/Role.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/domain/model/Role.kt similarity index 52% rename from user-service/src/main/kotlin/com/github/butvinmitmo/userservice/entity/Role.kt rename to user-service/src/main/kotlin/com/github/butvinmitmo/userservice/domain/model/Role.kt index 16e8ddf..821bdc9 100644 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/entity/Role.kt +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/domain/model/Role.kt @@ -1,18 +1,9 @@ -package com.github.butvinmitmo.userservice.entity +package com.github.butvinmitmo.userservice.domain.model -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Table import java.util.UUID -@Entity -@Table(name = "role") -class Role( - @Id - @Column(columnDefinition = "uuid") - val id: UUID = UUID.randomUUID(), - @Column(nullable = false, unique = true, length = 50) +data class Role( + val id: UUID, val name: String, ) diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/domain/model/User.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/domain/model/User.kt new file mode 100644 index 0000000..bf8d17e --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/domain/model/User.kt @@ -0,0 +1,12 @@ +package com.github.butvinmitmo.userservice.domain.model + +import java.time.Instant +import java.util.UUID + +data class User( + val id: UUID?, + val username: String, + val passwordHash: String, + val role: Role, + val createdAt: Instant?, +) diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/entity/User.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/entity/User.kt deleted file mode 100644 index 6b8e939..0000000 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/entity/User.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.butvinmitmo.userservice.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToOne -import jakarta.persistence.Table -import org.hibernate.annotations.Generated -import java.time.Instant -import java.util.UUID - -@Entity -@Table(name = "\"user\"") -class User( - @Column(nullable = false, unique = true, length = 128) - var username: String, - @Column(name = "password_hash", nullable = false, length = 255) - var passwordHash: String, - @ManyToOne - @JoinColumn(name = "role_id", nullable = false) - var role: Role, -) { - @Id - @Generated - @Column(columnDefinition = "uuid", insertable = false, nullable = false) - lateinit var id: UUID - - @Generated - @Column(name = "created_at", nullable = false, updatable = false, insertable = false) - lateinit var createdAt: Instant -} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/exception/GlobalExceptionHandler.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/exception/GlobalExceptionHandler.kt index 6e5473d..3b65f11 100644 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/exception/GlobalExceptionHandler.kt +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/exception/GlobalExceptionHandler.kt @@ -7,20 +7,22 @@ import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.access.AccessDeniedException import org.springframework.validation.FieldError -import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice -import org.springframework.web.context.request.WebRequest +import org.springframework.web.bind.support.WebExchangeBindException +import org.springframework.web.server.ServerWebExchange import java.time.Instant @RestControllerAdvice class GlobalExceptionHandler { - @ExceptionHandler(MethodArgumentNotValidException::class) + private val logger = org.slf4j.LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + + @ExceptionHandler(WebExchangeBindException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) fun handleValidationExceptions( - ex: MethodArgumentNotValidException, - request: WebRequest, + ex: WebExchangeBindException, + exchange: ServerWebExchange, ): ResponseEntity { val errors = mutableMapOf() @@ -35,7 +37,7 @@ class GlobalExceptionHandler { error = "VALIDATION_ERROR", message = "Validation failed", timestamp = Instant.now(), - path = request.getDescription(false).removePrefix("uri="), + path = exchange.request.path.value(), fieldErrors = errors, ) @@ -46,14 +48,14 @@ class GlobalExceptionHandler { @ResponseStatus(HttpStatus.NOT_FOUND) fun handleNotFoundException( ex: NotFoundException, - request: WebRequest, + exchange: ServerWebExchange, ): ResponseEntity { val response = ErrorResponse( error = "NOT_FOUND", message = ex.message ?: "Resource not found", timestamp = Instant.now(), - path = request.getDescription(false).removePrefix("uri="), + path = exchange.request.path.value(), ) return ResponseEntity(response, HttpStatus.NOT_FOUND) } @@ -62,14 +64,14 @@ class GlobalExceptionHandler { @ResponseStatus(HttpStatus.CONFLICT) fun handleConflictException( ex: ConflictException, - request: WebRequest, + exchange: ServerWebExchange, ): ResponseEntity { val response = ErrorResponse( error = "CONFLICT", message = ex.message ?: "Conflict occurred", timestamp = Instant.now(), - path = request.getDescription(false).removePrefix("uri="), + path = exchange.request.path.value(), ) return ResponseEntity(response, HttpStatus.CONFLICT) } @@ -78,14 +80,14 @@ class GlobalExceptionHandler { @ResponseStatus(HttpStatus.UNAUTHORIZED) fun handleUnauthorizedException( ex: UnauthorizedException, - request: WebRequest, + exchange: ServerWebExchange, ): ResponseEntity { val response = ErrorResponse( error = "UNAUTHORIZED", message = ex.message ?: "Unauthorized", timestamp = Instant.now(), - path = request.getDescription(false).removePrefix("uri="), + path = exchange.request.path.value(), ) return ResponseEntity(response, HttpStatus.UNAUTHORIZED) } @@ -94,14 +96,14 @@ class GlobalExceptionHandler { @ResponseStatus(HttpStatus.FORBIDDEN) fun handleForbiddenException( ex: ForbiddenException, - request: WebRequest, + exchange: ServerWebExchange, ): ResponseEntity { val response = ErrorResponse( error = "FORBIDDEN", message = ex.message ?: "Forbidden", timestamp = Instant.now(), - path = request.getDescription(false).removePrefix("uri="), + path = exchange.request.path.value(), ) return ResponseEntity(response, HttpStatus.FORBIDDEN) } @@ -110,14 +112,14 @@ class GlobalExceptionHandler { @ResponseStatus(HttpStatus.FORBIDDEN) fun handleAccessDeniedException( ex: AccessDeniedException, - request: WebRequest, + exchange: ServerWebExchange, ): ResponseEntity { val response = ErrorResponse( error = "FORBIDDEN", message = "Access denied", timestamp = Instant.now(), - path = request.getDescription(false).removePrefix("uri="), + path = exchange.request.path.value(), ) return ResponseEntity(response, HttpStatus.FORBIDDEN) } @@ -126,14 +128,14 @@ class GlobalExceptionHandler { @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) fun handleServiceUnavailable( ex: ServiceUnavailableException, - request: WebRequest, + exchange: ServerWebExchange, ): ResponseEntity { val response = ErrorResponse( error = "SERVICE_UNAVAILABLE", message = ex.message ?: "Service temporarily unavailable", timestamp = Instant.now(), - path = request.getDescription(false).removePrefix("uri="), + path = exchange.request.path.value(), ) return ResponseEntity(response, HttpStatus.SERVICE_UNAVAILABLE) } @@ -142,14 +144,15 @@ class GlobalExceptionHandler { @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun handleGenericException( ex: Exception, - request: WebRequest, + exchange: ServerWebExchange, ): ResponseEntity { + logger.error("Unexpected error on ${exchange.request.path.value()}", ex) val response = ErrorResponse( error = "INTERNAL_SERVER_ERROR", message = "An unexpected error occurred", timestamp = Instant.now(), - path = request.getDescription(false).removePrefix("uri="), + path = exchange.request.path.value(), ) return ResponseEntity(response, HttpStatus.INTERNAL_SERVER_ERROR) } diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/messaging/KafkaUserEventPublisher.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/messaging/KafkaUserEventPublisher.kt new file mode 100644 index 0000000..38b4e2c --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/messaging/KafkaUserEventPublisher.kt @@ -0,0 +1,48 @@ +package com.github.butvinmitmo.userservice.infrastructure.messaging + +import com.github.butvinmitmo.shared.dto.events.EventType +import com.github.butvinmitmo.shared.dto.events.UserEventData +import com.github.butvinmitmo.userservice.application.interfaces.publisher.UserEventPublisher +import com.github.butvinmitmo.userservice.domain.model.User +import com.github.butvinmitmo.userservice.infrastructure.messaging.mapper.UserEventDataMapper +import org.apache.kafka.clients.producer.ProducerRecord +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.time.Instant + +@Component +class KafkaUserEventPublisher( + private val kafkaTemplate: KafkaTemplate, + private val mapper: UserEventDataMapper, + @Value("\${kafka.topics.users-events}") private val topic: String, +) : UserEventPublisher { + private val log = LoggerFactory.getLogger(javaClass) + + override fun publishCreated(user: User): Mono = publish(user, EventType.CREATED) + + override fun publishUpdated(user: User): Mono = publish(user, EventType.UPDATED) + + override fun publishDeleted(user: User): Mono = publish(user, EventType.DELETED) + + private fun publish( + user: User, + eventType: EventType, + ): Mono = + Mono + .fromCallable { + val eventData = mapper.toEventData(user) + val record = + ProducerRecord(topic, null, eventData.id.toString(), eventData).apply { + headers().add("eventType", eventType.name.toByteArray()) + headers().add("timestamp", Instant.now().toString().toByteArray()) + } + kafkaTemplate.send(record).get() + log.debug("Published {} event for user {}", eventType, user.id) + }.subscribeOn(Schedulers.boundedElastic()) + .doOnError { e -> log.error("Failed to publish {} event for user {}: {}", eventType, user.id, e.message) } + .then() +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/messaging/mapper/UserEventDataMapper.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/messaging/mapper/UserEventDataMapper.kt new file mode 100644 index 0000000..ed0f3e4 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/messaging/mapper/UserEventDataMapper.kt @@ -0,0 +1,16 @@ +package com.github.butvinmitmo.userservice.infrastructure.messaging.mapper + +import com.github.butvinmitmo.shared.dto.events.UserEventData +import com.github.butvinmitmo.userservice.domain.model.User +import org.springframework.stereotype.Component + +@Component +class UserEventDataMapper { + fun toEventData(user: User): UserEventData = + UserEventData( + id = user.id!!, + username = user.username, + role = user.role.name, + createdAt = user.createdAt!!, + ) +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/R2dbcRoleRepository.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/R2dbcRoleRepository.kt new file mode 100644 index 0000000..6db5962 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/R2dbcRoleRepository.kt @@ -0,0 +1,25 @@ +package com.github.butvinmitmo.userservice.infrastructure.persistence + +import com.github.butvinmitmo.userservice.application.interfaces.repository.RoleRepository +import com.github.butvinmitmo.userservice.domain.model.Role +import com.github.butvinmitmo.userservice.infrastructure.persistence.mapper.RoleEntityMapper +import com.github.butvinmitmo.userservice.infrastructure.persistence.repository.SpringDataRoleRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +class R2dbcRoleRepository( + private val springDataRoleRepository: SpringDataRoleRepository, + private val roleEntityMapper: RoleEntityMapper, +) : RoleRepository { + override fun findById(id: UUID): Mono = + springDataRoleRepository + .findById(id) + .map { roleEntityMapper.toDomain(it) } + + override fun findByName(name: String): Mono = + springDataRoleRepository + .findByName(name) + .map { roleEntityMapper.toDomain(it) } +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/R2dbcUserRepository.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/R2dbcUserRepository.kt new file mode 100644 index 0000000..f62a740 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/R2dbcUserRepository.kt @@ -0,0 +1,88 @@ +package com.github.butvinmitmo.userservice.infrastructure.persistence + +import com.github.butvinmitmo.userservice.application.interfaces.repository.UserRepository +import com.github.butvinmitmo.userservice.domain.model.Role +import com.github.butvinmitmo.userservice.domain.model.User +import com.github.butvinmitmo.userservice.infrastructure.persistence.mapper.RoleEntityMapper +import com.github.butvinmitmo.userservice.infrastructure.persistence.mapper.UserEntityMapper +import com.github.butvinmitmo.userservice.infrastructure.persistence.repository.SpringDataRoleRepository +import com.github.butvinmitmo.userservice.infrastructure.persistence.repository.SpringDataUserRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +@Repository +class R2dbcUserRepository( + private val springDataUserRepository: SpringDataUserRepository, + private val springDataRoleRepository: SpringDataRoleRepository, + private val userEntityMapper: UserEntityMapper, + private val roleEntityMapper: RoleEntityMapper, +) : UserRepository { + override fun findById(id: UUID): Mono = + springDataUserRepository + .findById(id) + .flatMap { userEntity -> + springDataRoleRepository + .findById(userEntity.roleId) + .map { roleEntity -> + userEntityMapper.toDomain(userEntity, roleEntityMapper.toDomain(roleEntity)) + } + } + + override fun findByUsername(username: String): Mono = + springDataUserRepository + .findByUsername(username) + .flatMap { userEntity -> + springDataRoleRepository + .findById(userEntity.roleId) + .map { roleEntity -> + userEntityMapper.toDomain(userEntity, roleEntityMapper.toDomain(roleEntity)) + } + } + + override fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux = + getRoleMap() + .flatMapMany { roleMap -> + springDataUserRepository + .findAllPaginated(offset, limit) + .map { userEntity -> + val role = roleMap[userEntity.roleId]!! + userEntityMapper.toDomain(userEntity, role) + } + } + + override fun count(): Mono = springDataUserRepository.count() + + override fun save(user: User): Mono { + val entity = userEntityMapper.toEntity(user) + return springDataUserRepository + .save(entity) + .flatMap { savedEntity -> + // Re-fetch the entity to get database-generated fields (created_at) + springDataUserRepository + .findById(savedEntity.id!!) + .flatMap { fetchedEntity -> + springDataRoleRepository + .findById(fetchedEntity.roleId) + .map { roleEntity -> + userEntityMapper.toDomain(fetchedEntity, roleEntityMapper.toDomain(roleEntity)) + } + } + } + } + + override fun existsById(id: UUID): Mono = springDataUserRepository.existsById(id) + + override fun deleteById(id: UUID): Mono = springDataUserRepository.deleteById(id) + + private fun getRoleMap(): Mono> = + springDataRoleRepository + .findAll() + .map { roleEntityMapper.toDomain(it) } + .collectList() + .map { roles -> roles.associateBy { it.id } } +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/entity/RoleEntity.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/entity/RoleEntity.kt new file mode 100644 index 0000000..50accf9 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/entity/RoleEntity.kt @@ -0,0 +1,14 @@ +package com.github.butvinmitmo.userservice.infrastructure.persistence.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.util.UUID + +@Table("role") +data class RoleEntity( + @Id + val id: UUID? = null, + @Column("name") + val name: String, +) diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/entity/UserEntity.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/entity/UserEntity.kt new file mode 100644 index 0000000..de83739 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/entity/UserEntity.kt @@ -0,0 +1,21 @@ +package com.github.butvinmitmo.userservice.infrastructure.persistence.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant +import java.util.UUID + +@Table("\"user\"") +data class UserEntity( + @Id + val id: UUID? = null, + @Column("username") + val username: String, + @Column("password_hash") + val passwordHash: String, + @Column("role_id") + val roleId: UUID, + @Column("created_at") + val createdAt: Instant? = null, +) diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/mapper/RoleEntityMapper.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/mapper/RoleEntityMapper.kt new file mode 100644 index 0000000..958407d --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/mapper/RoleEntityMapper.kt @@ -0,0 +1,14 @@ +package com.github.butvinmitmo.userservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.userservice.domain.model.Role +import com.github.butvinmitmo.userservice.infrastructure.persistence.entity.RoleEntity +import org.springframework.stereotype.Component + +@Component +class RoleEntityMapper { + fun toDomain(entity: RoleEntity): Role = + Role( + id = entity.id!!, + name = entity.name, + ) +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/mapper/UserEntityMapper.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/mapper/UserEntityMapper.kt new file mode 100644 index 0000000..dc958a2 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/mapper/UserEntityMapper.kt @@ -0,0 +1,30 @@ +package com.github.butvinmitmo.userservice.infrastructure.persistence.mapper + +import com.github.butvinmitmo.userservice.domain.model.Role +import com.github.butvinmitmo.userservice.domain.model.User +import com.github.butvinmitmo.userservice.infrastructure.persistence.entity.UserEntity +import org.springframework.stereotype.Component + +@Component +class UserEntityMapper { + fun toDomain( + entity: UserEntity, + role: Role, + ): User = + User( + id = entity.id!!, + username = entity.username, + passwordHash = entity.passwordHash, + role = role, + createdAt = entity.createdAt!!, + ) + + fun toEntity(user: User): UserEntity = + UserEntity( + id = user.id, + username = user.username, + passwordHash = user.passwordHash, + roleId = user.role.id, + createdAt = user.createdAt, + ) +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/repository/SpringDataRoleRepository.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/repository/SpringDataRoleRepository.kt new file mode 100644 index 0000000..a8de508 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/repository/SpringDataRoleRepository.kt @@ -0,0 +1,10 @@ +package com.github.butvinmitmo.userservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.userservice.infrastructure.persistence.entity.RoleEntity +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Mono +import java.util.UUID + +interface SpringDataRoleRepository : R2dbcRepository { + fun findByName(name: String): Mono +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/repository/SpringDataUserRepository.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/repository/SpringDataUserRepository.kt new file mode 100644 index 0000000..c8e11ab --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/persistence/repository/SpringDataUserRepository.kt @@ -0,0 +1,21 @@ +package com.github.butvinmitmo.userservice.infrastructure.persistence.repository + +import com.github.butvinmitmo.userservice.infrastructure.persistence.entity.UserEntity +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.UUID + +interface SpringDataUserRepository : R2dbcRepository { + fun findByUsername(username: String): Mono + + @Query("""SELECT * FROM "user" ORDER BY created_at DESC LIMIT :limit OFFSET :offset""") + fun findAllPaginated( + offset: Long, + limit: Int, + ): Flux + + @Query("""SELECT COUNT(*) FROM "user"""") + override fun count(): Mono +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/GatewayAuthenticationWebFilter.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/GatewayAuthenticationWebFilter.kt new file mode 100644 index 0000000..68c3291 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/GatewayAuthenticationWebFilter.kt @@ -0,0 +1,38 @@ +package com.github.butvinmitmo.userservice.infrastructure.security + +import com.github.butvinmitmo.shared.security.GatewayAuthenticationToken +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono +import java.util.UUID + +@Component +class GatewayAuthenticationWebFilter : WebFilter { + override fun filter( + exchange: ServerWebExchange, + chain: WebFilterChain, + ): Mono { + val userIdHeader = exchange.request.headers.getFirst("X-User-Id") + val roleHeader = exchange.request.headers.getFirst("X-User-Role") + + return if (userIdHeader != null && roleHeader != null) { + try { + val userId = UUID.fromString(userIdHeader) + val authentication = GatewayAuthenticationToken(userId, roleHeader) + val securityContext = SecurityContextImpl(authentication) + chain + .filter( + exchange, + ).contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext))) + } catch (e: IllegalArgumentException) { + chain.filter(exchange) + } + } else { + chain.filter(exchange) + } + } +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/security/JwtUtil.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/JwtTokenProvider.kt similarity index 66% rename from user-service/src/main/kotlin/com/github/butvinmitmo/userservice/security/JwtUtil.kt rename to user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/JwtTokenProvider.kt index f7ff348..a386266 100644 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/security/JwtUtil.kt +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/JwtTokenProvider.kt @@ -1,6 +1,8 @@ -package com.github.butvinmitmo.userservice.security +package com.github.butvinmitmo.userservice.infrastructure.security -import com.github.butvinmitmo.userservice.entity.User +import com.github.butvinmitmo.userservice.application.interfaces.provider.TokenProvider +import com.github.butvinmitmo.userservice.application.interfaces.provider.TokenResult +import com.github.butvinmitmo.userservice.domain.model.User import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys import org.springframework.beans.factory.annotation.Value @@ -11,24 +13,24 @@ import java.util.Date import javax.crypto.SecretKey @Component -class JwtUtil( +class JwtTokenProvider( @Value("\${jwt.secret}") private val secret: String, @Value("\${jwt.expiration-hours}") private val expirationHours: Long, -) { +) : TokenProvider { private val secretKey: SecretKey by lazy { Keys.hmacShaKeyFor(secret.toByteArray()) } - fun generateToken(user: User): Pair { + override fun generateToken(user: User): TokenResult { val now = Instant.now() val expiresAt = now.plus(expirationHours, ChronoUnit.HOURS) val token = Jwts .builder() - .subject(user.id.toString()) + .subject(user.id!!.toString()) .claim("username", user.username) .claim("role", user.role.name) .issuedAt(Date.from(now)) @@ -36,6 +38,6 @@ class JwtUtil( .signWith(secretKey) .compact() - return Pair(token, expiresAt) + return TokenResult(token, expiresAt) } } diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/SpringPasswordEncoder.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/SpringPasswordEncoder.kt new file mode 100644 index 0000000..276ddf2 --- /dev/null +++ b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/infrastructure/security/SpringPasswordEncoder.kt @@ -0,0 +1,17 @@ +package com.github.butvinmitmo.userservice.infrastructure.security + +import com.github.butvinmitmo.userservice.application.interfaces.provider.PasswordEncoder +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.stereotype.Component + +@Component +class SpringPasswordEncoder : PasswordEncoder { + private val bCryptPasswordEncoder = BCryptPasswordEncoder(10) + + override fun encode(rawPassword: String): String = bCryptPasswordEncoder.encode(rawPassword) + + override fun matches( + rawPassword: String, + encodedPassword: String, + ): Boolean = bCryptPasswordEncoder.matches(rawPassword, encodedPassword) +} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/mapper/UserMapper.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/mapper/UserMapper.kt deleted file mode 100644 index 842f3de..0000000 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/mapper/UserMapper.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.butvinmitmo.userservice.mapper - -import com.github.butvinmitmo.shared.dto.UserDto -import com.github.butvinmitmo.userservice.entity.User -import org.springframework.stereotype.Component - -@Component -class UserMapper { - fun toDto(entity: User): UserDto = - UserDto( - id = entity.id, - username = entity.username, - role = entity.role.name, - createdAt = entity.createdAt, - ) -} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/repository/RoleRepository.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/repository/RoleRepository.kt deleted file mode 100644 index 72aeda3..0000000 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/repository/RoleRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.butvinmitmo.userservice.repository - -import com.github.butvinmitmo.userservice.entity.Role -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.stereotype.Repository -import java.util.UUID - -@Repository -interface RoleRepository : JpaRepository { - fun findByName(name: String): Role? -} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/repository/UserRepository.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/repository/UserRepository.kt deleted file mode 100644 index 7061f94..0000000 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/repository/UserRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.butvinmitmo.userservice.repository - -import com.github.butvinmitmo.userservice.entity.User -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.stereotype.Repository -import java.util.UUID - -@Repository -interface UserRepository : JpaRepository { - fun findByUsername(username: String): User? -} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/security/GatewayAuthenticationFilter.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/security/GatewayAuthenticationFilter.kt deleted file mode 100644 index d2a1116..0000000 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/security/GatewayAuthenticationFilter.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.butvinmitmo.userservice.security - -import com.github.butvinmitmo.shared.security.GatewayAuthenticationToken -import jakarta.servlet.FilterChain -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.stereotype.Component -import org.springframework.web.filter.OncePerRequestFilter -import java.util.UUID - -@Component -class GatewayAuthenticationFilter : OncePerRequestFilter() { - override fun doFilterInternal( - request: HttpServletRequest, - response: HttpServletResponse, - filterChain: FilterChain, - ) { - val userIdHeader = request.getHeader("X-User-Id") - val roleHeader = request.getHeader("X-User-Role") - - if (userIdHeader != null && roleHeader != null) { - try { - val userId = UUID.fromString(userIdHeader) - val authentication = GatewayAuthenticationToken(userId, roleHeader) - SecurityContextHolder.getContext().authentication = authentication - } catch (e: IllegalArgumentException) { - // Invalid UUID format - continue without authentication - } - } - - filterChain.doFilter(request, response) - } -} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/service/RoleService.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/service/RoleService.kt deleted file mode 100644 index 937e091..0000000 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/service/RoleService.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.butvinmitmo.userservice.service - -import com.github.butvinmitmo.userservice.entity.Role -import com.github.butvinmitmo.userservice.entity.RoleType -import com.github.butvinmitmo.userservice.exception.NotFoundException -import com.github.butvinmitmo.userservice.repository.RoleRepository -import org.springframework.stereotype.Service - -@Service -class RoleService( - private val roleRepository: RoleRepository, -) { - /** - * Get role by name. Defaults to USER role if roleName is null. - * @param roleName The name of the role (USER, MEDIUM, or ADMIN), or null for default USER role - * @return Role entity - * @throws NotFoundException if role does not exist - */ - fun getRoleByName(roleName: String?): Role { - val effectiveRoleName = roleName ?: RoleType.USER.name - return roleRepository.findByName(effectiveRoleName) - ?: throw NotFoundException("Role not found: $effectiveRoleName") - } - - /** - * Get role by RoleType enum. - * @param roleType The RoleType enum value - * @return Role entity - * @throws NotFoundException if role does not exist - */ - fun getRoleByType(roleType: RoleType): Role = - roleRepository.findByName(roleType.name) - ?: throw NotFoundException("Role not found: ${roleType.name}") -} diff --git a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/service/UserService.kt b/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/service/UserService.kt deleted file mode 100644 index 4c43a01..0000000 --- a/user-service/src/main/kotlin/com/github/butvinmitmo/userservice/service/UserService.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.github.butvinmitmo.userservice.service - -import com.github.butvinmitmo.shared.client.DivinationServiceInternalClient -import com.github.butvinmitmo.shared.dto.AuthTokenResponse -import com.github.butvinmitmo.shared.dto.CreateUserRequest -import com.github.butvinmitmo.shared.dto.CreateUserResponse -import com.github.butvinmitmo.shared.dto.LoginRequest -import com.github.butvinmitmo.shared.dto.PageResponse -import com.github.butvinmitmo.shared.dto.UpdateUserRequest -import com.github.butvinmitmo.shared.dto.UserDto -import com.github.butvinmitmo.userservice.entity.User -import com.github.butvinmitmo.userservice.exception.ConflictException -import com.github.butvinmitmo.userservice.exception.NotFoundException -import com.github.butvinmitmo.userservice.exception.UnauthorizedException -import com.github.butvinmitmo.userservice.mapper.UserMapper -import com.github.butvinmitmo.userservice.repository.RoleRepository -import com.github.butvinmitmo.userservice.repository.UserRepository -import com.github.butvinmitmo.userservice.security.JwtUtil -import org.slf4j.LoggerFactory -import org.springframework.data.domain.PageRequest -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.util.UUID - -@Service -class UserService( - private val userRepository: UserRepository, - private val roleRepository: RoleRepository, - private val roleService: RoleService, - private val userMapper: UserMapper, - private val passwordEncoder: PasswordEncoder, - private val jwtUtil: JwtUtil, - private val divinationServiceInternalClient: DivinationServiceInternalClient, -) { - private val logger = LoggerFactory.getLogger(UserService::class.java) - - @Transactional(readOnly = true) - fun authenticate(request: LoginRequest): AuthTokenResponse { - val user = - userRepository.findByUsername(request.username) - ?: throw UnauthorizedException("Invalid username or password") - - if (!passwordEncoder.matches(request.password, user.passwordHash)) { - throw UnauthorizedException("Invalid username or password") - } - - val (token, expiresAt) = jwtUtil.generateToken(user) - - return AuthTokenResponse( - token = token, - expiresAt = expiresAt, - username = user.username, - role = user.role.name, - ) - } - - @Transactional - fun createUser(request: CreateUserRequest): CreateUserResponse { - if (userRepository.findByUsername(request.username) != null) { - throw ConflictException("User with this username already exists") - } - - val userRole = roleService.getRoleByName(request.role) - - val user = - User( - username = request.username, - passwordHash = passwordEncoder.encode(request.password), - role = userRole, - ) - - val saved = userRepository.save(user) - return CreateUserResponse(id = saved.id) - } - - fun getUsers( - page: Int, - size: Int, - ): PageResponse { - val pageable = PageRequest.of(page, size) - val usersPage = userRepository.findAll(pageable) - return PageResponse( - content = usersPage.content.map { userMapper.toDto(it) }, - page = usersPage.number, - size = usersPage.size, - totalElements = usersPage.totalElements, - totalPages = usersPage.totalPages, - isFirst = usersPage.isFirst, - isLast = usersPage.isLast, - ) - } - - fun getUser(id: UUID): UserDto { - val user = - userRepository - .findById(id) - .orElseThrow { NotFoundException("User not found") } - - return userMapper.toDto(user) - } - - @Transactional - fun updateUser( - id: UUID, - request: UpdateUserRequest, - ): UserDto { - val user = - userRepository - .findById(id) - .orElseThrow { NotFoundException("User not found") } - - request.username?.let { user.username = it } - request.password?.let { user.passwordHash = passwordEncoder.encode(it) } - request.role?.let { user.role = roleService.getRoleByName(it) } - - val updated = userRepository.save(user) - return userMapper.toDto(updated) - } - - @Transactional - fun deleteUser(id: UUID) { - if (!userRepository.existsById(id)) { - throw NotFoundException("User not found") - } - - // Clean up user data in divination-service first - // Fallback factory handles service unavailability with proper error message - logger.info("Deleting user data in divination-service for user $id") - divinationServiceInternalClient.deleteUserData(id) - logger.info("Successfully deleted user data in divination-service for user $id") - - userRepository.deleteById(id) - } -} diff --git a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/TestEntityFactory.kt b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/TestEntityFactory.kt index ddf9db5..d11973d 100644 --- a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/TestEntityFactory.kt +++ b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/TestEntityFactory.kt @@ -1,35 +1,44 @@ package com.github.butvinmitmo.userservice -import com.github.butvinmitmo.userservice.entity.Role -import com.github.butvinmitmo.userservice.entity.RoleType -import com.github.butvinmitmo.userservice.entity.User +import com.github.butvinmitmo.userservice.domain.model.Role +import com.github.butvinmitmo.userservice.domain.model.RoleType +import com.github.butvinmitmo.userservice.domain.model.User +import com.github.butvinmitmo.userservice.infrastructure.persistence.entity.UserEntity import java.time.Instant import java.util.UUID object TestEntityFactory { - private val testUserRole = Role(id = RoleType.USER_ID, name = "USER") - private val testAdminRole = Role(id = RoleType.ADMIN_ID, name = "ADMIN") + val testUserRole = Role(id = RoleType.USER_ID, name = "USER") + val testMediumRole = Role(id = RoleType.MEDIUM_ID, name = "MEDIUM") + val testAdminRole = Role(id = RoleType.ADMIN_ID, name = "ADMIN") fun createUser( - id: UUID, + id: UUID? = UUID.randomUUID(), username: String, passwordHash: String = "\$2a\$10\$testHashForTestingPurposesOnly", role: Role = testUserRole, - createdAt: Instant = Instant.now(), - ): User { - val user = User(username = username, passwordHash = passwordHash, role = role) - setPrivateField(user, "id", id) - setPrivateField(user, "createdAt", createdAt) - return user - } + createdAt: Instant? = Instant.now(), + ): User = + User( + id = id, + username = username, + passwordHash = passwordHash, + role = role, + createdAt = createdAt, + ) - private fun setPrivateField( - obj: Any, - fieldName: String, - value: Any, - ) { - val field = obj::class.java.getDeclaredField(fieldName) - field.isAccessible = true - field.set(obj, value) - } + fun createUserEntity( + id: UUID? = UUID.randomUUID(), + username: String, + passwordHash: String = "\$2a\$10\$testHashForTestingPurposesOnly", + roleId: UUID = RoleType.USER_ID, + createdAt: Instant? = Instant.now(), + ): UserEntity = + UserEntity( + id = id, + username = username, + passwordHash = passwordHash, + roleId = roleId, + createdAt = createdAt, + ) } diff --git a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/BaseIntegrationTest.kt b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/BaseIntegrationTest.kt index 86d3985..b958ac0 100644 --- a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/BaseIntegrationTest.kt +++ b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/BaseIntegrationTest.kt @@ -1,7 +1,7 @@ package com.github.butvinmitmo.userservice.integration -import com.github.butvinmitmo.shared.client.DivinationServiceInternalClient -import com.github.butvinmitmo.userservice.repository.UserRepository +import com.github.butvinmitmo.userservice.application.interfaces.publisher.UserEventPublisher +import com.github.butvinmitmo.userservice.infrastructure.persistence.repository.SpringDataUserRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.mockito.kotlin.any @@ -9,12 +9,12 @@ import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.http.ResponseEntity import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.postgresql.PostgreSQLContainer +import reactor.core.publisher.Mono import java.util.UUID @SpringBootTest @@ -22,24 +22,26 @@ import java.util.UUID @Testcontainers abstract class BaseIntegrationTest { @Autowired - protected lateinit var userRepository: UserRepository + protected lateinit var springDataUserRepository: SpringDataUserRepository @MockBean - protected lateinit var divinationServiceInternalClient: DivinationServiceInternalClient + protected lateinit var userEventPublisher: UserEventPublisher @BeforeEach fun setupMocks() { - // Default mock behavior: cleanup always succeeds - whenever(divinationServiceInternalClient.deleteUserData(any())).thenReturn(ResponseEntity.noContent().build()) + whenever(userEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + whenever(userEventPublisher.publishUpdated(any())).thenReturn(Mono.empty()) + whenever(userEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) } @AfterEach fun cleanupDatabase() { val seedUserId = UUID.fromString("00000000-0000-0000-0000-000000000001") - userRepository + springDataUserRepository .findAll() .filter { it.id != seedUserId } - .forEach { userRepository.delete(it) } + .flatMap { springDataUserRepository.delete(it) } + .blockLast() } companion object { @@ -56,10 +58,15 @@ abstract class BaseIntegrationTest { @JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url") { postgres.jdbcUrl } - registry.add("spring.datasource.username") { postgres.username } - registry.add("spring.datasource.password") { postgres.password } - registry.add("spring.jpa.hibernate.ddl-auto") { "validate" } + registry.add("spring.r2dbc.url") { + "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(5432)}/${postgres.databaseName}" + } + registry.add("spring.r2dbc.username") { postgres.username } + registry.add("spring.r2dbc.password") { postgres.password } + registry.add("spring.flyway.url") { postgres.jdbcUrl } + registry.add("spring.flyway.user") { postgres.username } + registry.add("spring.flyway.password") { postgres.password } + registry.add("spring.flyway.enabled") { "true" } } } } diff --git a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/AuthControllerIntegrationTest.kt b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/AuthControllerIntegrationTest.kt index 8d3d4fe..2ba3479 100644 --- a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/AuthControllerIntegrationTest.kt +++ b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/AuthControllerIntegrationTest.kt @@ -5,9 +5,6 @@ import com.github.butvinmitmo.shared.dto.LoginRequest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.http.MediaType -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.util.UUID class AuthControllerIntegrationTest : BaseControllerIntegrationTest() { @@ -17,81 +14,103 @@ class AuthControllerIntegrationTest : BaseControllerIntegrationTest() { @BeforeEach fun setup() { - // Create a test user for authentication tests val createRequest = CreateUserRequest(username = "authuser", password = "Test@123") - mockMvc.perform( - post(usersUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest)), - ) + webTestClient + .post() + .uri(usersUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(createRequest) + .exchange() + .expectStatus() + .isCreated } @Test fun `POST auth login should return 200 and JWT token for valid credentials`() { val loginRequest = LoginRequest(username = "authuser", password = "Test@123") - mockMvc - .perform( - post(authUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequest)), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.token").exists()) - .andExpect(jsonPath("$.token").isString) - .andExpect(jsonPath("$.expiresAt").exists()) - .andExpect(jsonPath("$.username").value("authuser")) - .andExpect(jsonPath("$.role").value("USER")) + webTestClient + .post() + .uri(authUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest) + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.token") + .exists() + .jsonPath("$.token") + .isNotEmpty + .jsonPath("$.expiresAt") + .exists() + .jsonPath("$.username") + .isEqualTo("authuser") + .jsonPath("$.role") + .isEqualTo("USER") } @Test fun `POST auth login should return 401 for invalid username`() { val loginRequest = LoginRequest(username = "nonexistent", password = "Test@123") - mockMvc - .perform( - post(authUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequest)), - ).andExpect(status().isUnauthorized) - .andExpect(jsonPath("$.message").value("Invalid username or password")) + webTestClient + .post() + .uri(authUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest) + .exchange() + .expectStatus() + .isUnauthorized + .expectBody() + .jsonPath("$.message") + .isEqualTo("Invalid username or password") } @Test fun `POST auth login should return 401 for invalid password`() { val loginRequest = LoginRequest(username = "authuser", password = "WrongPassword") - mockMvc - .perform( - post(authUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequest)), - ).andExpect(status().isUnauthorized) - .andExpect(jsonPath("$.message").value("Invalid username or password")) + webTestClient + .post() + .uri(authUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest) + .exchange() + .expectStatus() + .isUnauthorized + .expectBody() + .jsonPath("$.message") + .isEqualTo("Invalid username or password") } @Test fun `POST auth login should return 400 for empty username`() { val loginRequest = LoginRequest(username = "", password = "Test@123") - mockMvc - .perform( - post(authUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequest)), - ).andExpect(status().isBadRequest) + webTestClient + .post() + .uri(authUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest) + .exchange() + .expectStatus() + .isBadRequest } @Test fun `POST auth login should return 400 for empty password`() { val loginRequest = LoginRequest(username = "authuser", password = "") - mockMvc - .perform( - post(authUrl) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequest)), - ).andExpect(status().isBadRequest) + webTestClient + .post() + .uri(authUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest) + .exchange() + .expectStatus() + .isBadRequest } } diff --git a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/BaseControllerIntegrationTest.kt b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/BaseControllerIntegrationTest.kt index 6d3a8e1..16d105b 100644 --- a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/BaseControllerIntegrationTest.kt +++ b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/BaseControllerIntegrationTest.kt @@ -1,53 +1,55 @@ package com.github.butvinmitmo.userservice.integration.controller import com.fasterxml.jackson.databind.ObjectMapper -import com.github.butvinmitmo.shared.client.DivinationServiceInternalClient -import com.github.butvinmitmo.userservice.repository.UserRepository +import com.github.butvinmitmo.userservice.application.interfaces.publisher.UserEventPublisher +import com.github.butvinmitmo.userservice.infrastructure.persistence.repository.SpringDataUserRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.mockito.kotlin.any import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.http.ResponseEntity +import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource -import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.reactive.server.WebTestClient import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.postgresql.PostgreSQLContainer +import reactor.core.publisher.Mono import java.util.UUID @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@AutoConfigureMockMvc +@ActiveProfiles("test") @Testcontainers abstract class BaseControllerIntegrationTest { @Autowired - protected lateinit var mockMvc: MockMvc + protected lateinit var webTestClient: WebTestClient @Autowired protected lateinit var objectMapper: ObjectMapper @Autowired - private lateinit var userRepository: UserRepository + private lateinit var springDataUserRepository: SpringDataUserRepository @MockBean - protected lateinit var divinationServiceInternalClient: DivinationServiceInternalClient + protected lateinit var userEventPublisher: UserEventPublisher @BeforeEach fun setupMocks() { - // Default mock behavior: cleanup always succeeds - whenever(divinationServiceInternalClient.deleteUserData(any())).thenReturn(ResponseEntity.noContent().build()) + whenever(userEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + whenever(userEventPublisher.publishUpdated(any())).thenReturn(Mono.empty()) + whenever(userEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) } @AfterEach fun cleanupDatabase() { val seedUserId = UUID.fromString("00000000-0000-0000-0000-000000000001") - userRepository + springDataUserRepository .findAll() .filter { it.id != seedUserId } - .forEach { userRepository.delete(it) } + .flatMap { springDataUserRepository.delete(it) } + .blockLast() } companion object { @@ -64,12 +66,15 @@ abstract class BaseControllerIntegrationTest { @JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url") { postgres.jdbcUrl } - registry.add("spring.datasource.username") { postgres.username } - registry.add("spring.datasource.password") { postgres.password } - registry.add("spring.jpa.hibernate.ddl-auto") { "validate" } - - // JWT configuration for testing + registry.add("spring.r2dbc.url") { + "r2dbc:postgresql://${postgres.host}:${postgres.getMappedPort(5432)}/${postgres.databaseName}" + } + registry.add("spring.r2dbc.username") { postgres.username } + registry.add("spring.r2dbc.password") { postgres.password } + registry.add("spring.flyway.url") { postgres.jdbcUrl } + registry.add("spring.flyway.user") { postgres.username } + registry.add("spring.flyway.password") { postgres.password } + registry.add("spring.flyway.enabled") { "true" } registry.add("jwt.secret") { "testSecretKeyThatIsLongEnoughForHS256AlgorithmRequirements!!" } registry.add("jwt.expiration-hours") { "24" } } diff --git a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/UserControllerIntegrationTest.kt b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/UserControllerIntegrationTest.kt index 72fe929..05d9c73 100644 --- a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/UserControllerIntegrationTest.kt +++ b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/controller/UserControllerIntegrationTest.kt @@ -1,16 +1,10 @@ package com.github.butvinmitmo.userservice.integration.controller import com.github.butvinmitmo.shared.dto.CreateUserRequest +import com.github.butvinmitmo.shared.dto.CreateUserResponse import com.github.butvinmitmo.shared.dto.UpdateUserRequest import org.junit.jupiter.api.Test import org.springframework.http.MediaType -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.util.UUID class UserControllerIntegrationTest : BaseControllerIntegrationTest() { @@ -21,171 +15,212 @@ class UserControllerIntegrationTest : BaseControllerIntegrationTest() { fun `POST users should create user and return 201`() { val request = CreateUserRequest(username = "newuser", password = "Test@123") - mockMvc - .perform( - post(baseUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.id").exists()) + webTestClient + .post() + .uri(baseUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isCreated + .expectBody() + .jsonPath("$.id") + .exists() } @Test fun `POST users should return 409 for duplicate username`() { val request = CreateUserRequest(username = "duplicateuser", password = "Test@123") - mockMvc - .perform( - post(baseUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)), - ).andExpect(status().isCreated) - - mockMvc - .perform( - post(baseUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)), - ).andExpect(status().isConflict) + webTestClient + .post() + .uri(baseUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isCreated + + webTestClient + .post() + .uri(baseUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isEqualTo(409) } @Test fun `GET users should return paginated list with X-Total-Count header`() { val request = CreateUserRequest(username = "listuser", password = "Test@123") - mockMvc - .perform( - post(baseUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)), - ) - - mockMvc - .perform( - get(baseUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN"), - ).andExpect(status().isOk) - .andExpect(header().exists("X-Total-Count")) - .andExpect(jsonPath("$").isArray) + webTestClient + .post() + .uri(baseUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus() + .isCreated + + webTestClient + .get() + .uri(baseUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .exchange() + .expectStatus() + .isOk + .expectHeader() + .exists("X-Total-Count") + .expectBody() + .jsonPath("$") + .isArray } @Test fun `GET users by id should return user`() { val createRequest = CreateUserRequest(username = "getbyiduser", password = "Test@123") - val createResult = - mockMvc - .perform( - post(baseUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest)), - ).andExpect(status().isCreated) - .andReturn() - - val responseJson = createResult.response.contentAsString - val userId = objectMapper.readTree(responseJson).get("id").asText() - - mockMvc - .perform( - get("$baseUrl/$userId") - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN"), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.id").value(userId)) - .andExpect(jsonPath("$.username").value("getbyiduser")) + val createResponse = + webTestClient + .post() + .uri(baseUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(createRequest) + .exchange() + .expectStatus() + .isCreated + .expectBody(CreateUserResponse::class.java) + .returnResult() + .responseBody!! + + val userId = createResponse.id + + webTestClient + .get() + .uri("$baseUrl/$userId") + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.id") + .isEqualTo(userId.toString()) + .jsonPath("$.username") + .isEqualTo("getbyiduser") } @Test fun `GET users by id should return 404 for non-existent user`() { val nonExistentId = UUID.randomUUID() - mockMvc - .perform( - get("$baseUrl/$nonExistentId") - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN"), - ).andExpect(status().isNotFound) + webTestClient + .get() + .uri("$baseUrl/$nonExistentId") + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .exchange() + .expectStatus() + .isNotFound } @Test fun `PUT users should update user`() { val createRequest = CreateUserRequest(username = "originaluser", password = "Test@123") - val createResult = - mockMvc - .perform( - post(baseUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest)), - ).andExpect(status().isCreated) - .andReturn() - - val responseJson = createResult.response.contentAsString - val userId = objectMapper.readTree(responseJson).get("id").asText() + val createResponse = + webTestClient + .post() + .uri(baseUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(createRequest) + .exchange() + .expectStatus() + .isCreated + .expectBody(CreateUserResponse::class.java) + .returnResult() + .responseBody!! + + val userId = createResponse.id val updateRequest = UpdateUserRequest(username = "updateduser") - mockMvc - .perform( - put("$baseUrl/$userId") - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updateRequest)), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.username").value("updateduser")) + webTestClient + .put() + .uri("$baseUrl/$userId") + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(updateRequest) + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.username") + .isEqualTo("updateduser") } @Test fun `DELETE users should delete user and return 204`() { val createRequest = CreateUserRequest(username = "todelete", password = "Test@123") - val createResult = - mockMvc - .perform( - post(baseUrl) - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest)), - ).andExpect(status().isCreated) - .andReturn() - - val responseJson = createResult.response.contentAsString - val userId = objectMapper.readTree(responseJson).get("id").asText() - - mockMvc - .perform( - delete("$baseUrl/$userId") - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN"), - ).andExpect(status().isNoContent) - - mockMvc - .perform( - get("$baseUrl/$userId") - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN"), - ).andExpect(status().isNotFound) + val createResponse = + webTestClient + .post() + .uri(baseUrl) + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(createRequest) + .exchange() + .expectStatus() + .isCreated + .expectBody(CreateUserResponse::class.java) + .returnResult() + .responseBody!! + + val userId = createResponse.id + + webTestClient + .delete() + .uri("$baseUrl/$userId") + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .exchange() + .expectStatus() + .isNoContent + + webTestClient + .get() + .uri("$baseUrl/$userId") + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .exchange() + .expectStatus() + .isNotFound } @Test fun `DELETE users should return 404 for non-existent user`() { val nonExistentId = UUID.randomUUID() - mockMvc - .perform( - delete("$baseUrl/$nonExistentId") - .header("X-User-Id", testAdminUserId.toString()) - .header("X-User-Role", "ADMIN"), - ).andExpect(status().isNotFound) + webTestClient + .delete() + .uri("$baseUrl/$nonExistentId") + .header("X-User-Id", testAdminUserId.toString()) + .header("X-User-Role", "ADMIN") + .exchange() + .expectStatus() + .isNotFound } } diff --git a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/service/UserServiceIntegrationTest.kt b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/service/UserServiceIntegrationTest.kt index d6fd0fd..a560b18 100644 --- a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/service/UserServiceIntegrationTest.kt +++ b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/integration/service/UserServiceIntegrationTest.kt @@ -1,52 +1,53 @@ package com.github.butvinmitmo.userservice.integration.service -import com.github.butvinmitmo.shared.dto.CreateUserRequest -import com.github.butvinmitmo.shared.dto.UpdateUserRequest +import com.github.butvinmitmo.userservice.application.service.UserService +import com.github.butvinmitmo.userservice.domain.model.RoleType import com.github.butvinmitmo.userservice.exception.ConflictException import com.github.butvinmitmo.userservice.exception.NotFoundException +import com.github.butvinmitmo.userservice.infrastructure.persistence.repository.SpringDataRoleRepository import com.github.butvinmitmo.userservice.integration.BaseIntegrationTest -import com.github.butvinmitmo.userservice.service.UserService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.annotation.Autowired +import reactor.test.StepVerifier import java.util.UUID class UserServiceIntegrationTest : BaseIntegrationTest() { @Autowired private lateinit var userService: UserService + @Autowired + private lateinit var springDataRoleRepository: SpringDataRoleRepository + @Test fun `createUser should persist user to database`() { - val request = CreateUserRequest(username = "integrationuser", password = "Test@123") + val response = userService.createUser("integrationuser", "Test@123", null).block()!! - val response = userService.createUser(request) - - assertNotNull(response.id) - val savedUser = userRepository.findById(response.id).orElse(null) + assertNotNull(response) + val savedUser = springDataUserRepository.findById(response).block() assertNotNull(savedUser) - assertEquals("integrationuser", savedUser.username) + assertEquals("integrationuser", savedUser!!.username) } @Test fun `createUser should throw ConflictException for duplicate username`() { - val request = CreateUserRequest(username = "duplicateuser", password = "Test@123") - userService.createUser(request) + userService.createUser("duplicateuser", "Test@123", null).block() - assertThrows { - userService.createUser(request) - } + StepVerifier + .create(userService.createUser("duplicateuser", "Test@123", null)) + .expectError(ConflictException::class.java) + .verify() } @Test fun `getUser should return existing user`() { - val createResponse = userService.createUser(CreateUserRequest(username = "getuser", password = "Test@123")) + val createResponse = userService.createUser("getuser", "Test@123", null).block()!! - val user = userService.getUser(createResponse.id) + val user = userService.getUser(createResponse).block()!! - assertEquals(createResponse.id, user.id) + assertEquals(createResponse, user.id) assertEquals("getuser", user.username) } @@ -54,118 +55,112 @@ class UserServiceIntegrationTest : BaseIntegrationTest() { fun `getUser should throw NotFoundException for non-existent user`() { val nonExistentId = UUID.randomUUID() - assertThrows { - userService.getUser(nonExistentId) - } + StepVerifier + .create(userService.getUser(nonExistentId)) + .expectError(NotFoundException::class.java) + .verify() } @Test fun `updateUser should update username`() { - val createResponse = userService.createUser(CreateUserRequest(username = "originalname", password = "Test@123")) + val createResponse = userService.createUser("originalname", "Test@123", null).block()!! - val updated = userService.updateUser(createResponse.id, UpdateUserRequest(username = "updatedname")) + val updated = userService.updateUser(createResponse, "updatedname", null, null).block()!! assertEquals("updatedname", updated.username) - val savedUser = userRepository.findById(createResponse.id).orElse(null) + val savedUser = springDataUserRepository.findById(createResponse).block()!! assertEquals("updatedname", savedUser.username) } @Test fun `deleteUser should remove user from database`() { - val createResponse = userService.createUser(CreateUserRequest(username = "todelete", password = "Test@123")) + val createResponse = userService.createUser("todelete", "Test@123", null).block()!! - userService.deleteUser(createResponse.id) + userService.deleteUser(createResponse).block() - assertTrue(userRepository.findById(createResponse.id).isEmpty) + val exists = springDataUserRepository.existsById(createResponse).block()!! + assertTrue(!exists) } @Test fun `deleteUser should throw NotFoundException for non-existent user`() { val nonExistentId = UUID.randomUUID() - assertThrows { - userService.deleteUser(nonExistentId) - } + StepVerifier + .create(userService.deleteUser(nonExistentId)) + .expectError(NotFoundException::class.java) + .verify() } @Test fun `getUsers should return paginated list`() { - userService.createUser(CreateUserRequest(username = "pageuser1", password = "Test@123")) - userService.createUser(CreateUserRequest(username = "pageuser2", password = "Test@123")) + userService.createUser("pageuser1", "Test@123", null).block() + userService.createUser("pageuser2", "Test@123", null).block() - val result = userService.getUsers(0, 10) + val result = userService.getUsers(0, 10).block()!! assertTrue(result.content.size >= 2) } @Test fun `createUser should create user with MEDIUM role when specified`() { - val request = CreateUserRequest(username = "mediumuser", password = "Test@123", role = "MEDIUM") - - val response = userService.createUser(request) + val response = userService.createUser("mediumuser", "Test@123", "MEDIUM").block()!! - assertNotNull(response.id) - val savedUser = userRepository.findById(response.id).orElse(null) + assertNotNull(response) + val savedUser = springDataUserRepository.findById(response).block()!! assertNotNull(savedUser) assertEquals("mediumuser", savedUser.username) - assertEquals("MEDIUM", savedUser.role.name) + assertEquals(RoleType.MEDIUM_ID, savedUser.roleId) } @Test fun `createUser should create user with ADMIN role when specified`() { - val request = CreateUserRequest(username = "adminuser", password = "Test@123", role = "ADMIN") + val response = userService.createUser("adminuser", "Test@123", "ADMIN").block()!! - val response = userService.createUser(request) - - assertNotNull(response.id) - val savedUser = userRepository.findById(response.id).orElse(null) + assertNotNull(response) + val savedUser = springDataUserRepository.findById(response).block()!! assertNotNull(savedUser) assertEquals("adminuser", savedUser.username) - assertEquals("ADMIN", savedUser.role.name) + assertEquals(RoleType.ADMIN_ID, savedUser.roleId) } @Test fun `createUser should default to USER role when role not specified`() { - val request = CreateUserRequest(username = "defaultroleuser", password = "Test@123") - - val response = userService.createUser(request) + val response = userService.createUser("defaultroleuser", "Test@123", null).block()!! - assertNotNull(response.id) - val savedUser = userRepository.findById(response.id).orElse(null) + assertNotNull(response) + val savedUser = springDataUserRepository.findById(response).block()!! assertNotNull(savedUser) assertEquals("defaultroleuser", savedUser.username) - assertEquals("USER", savedUser.role.name) + assertEquals(RoleType.USER_ID, savedUser.roleId) } @Test fun `createUser should throw NotFoundException for invalid role`() { - val request = CreateUserRequest(username = "invalidroleuser", password = "Test@123", role = "INVALID") - - assertThrows { - userService.createUser(request) - } + StepVerifier + .create(userService.createUser("invalidroleuser", "Test@123", "INVALID")) + .expectError(NotFoundException::class.java) + .verify() } @Test fun `updateUser should update user role`() { - val createResponse = userService.createUser(CreateUserRequest(username = "roleupdate", password = "Test@123")) + val createResponse = userService.createUser("roleupdate", "Test@123", null).block()!! - val updated = userService.updateUser(createResponse.id, UpdateUserRequest(role = "MEDIUM")) + val updated = userService.updateUser(createResponse, null, null, "MEDIUM").block()!! - assertEquals("MEDIUM", updated.role) - val savedUser = userRepository.findById(createResponse.id).orElse(null) - assertEquals("MEDIUM", savedUser.role.name) + assertEquals("MEDIUM", updated.role.name) + val savedUser = springDataUserRepository.findById(createResponse).block()!! + assertEquals(RoleType.MEDIUM_ID, savedUser.roleId) } @Test fun `updateUser should throw NotFoundException for invalid role`() { - val createResponse = - userService.createUser( - CreateUserRequest(username = "invalidupdate", password = "Test@123"), - ) - - assertThrows { - userService.updateUser(createResponse.id, UpdateUserRequest(role = "INVALID")) - } + val createResponse = userService.createUser("invalidupdate", "Test@123", null).block()!! + + StepVerifier + .create(userService.updateUser(createResponse, null, null, "INVALID")) + .expectError(NotFoundException::class.java) + .verify() } } diff --git a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/unit/security/JwtUtilTest.kt b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/unit/security/JwtUtilTest.kt index f463cf0..f3160c3 100644 --- a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/unit/security/JwtUtilTest.kt +++ b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/unit/security/JwtUtilTest.kt @@ -1,7 +1,7 @@ package com.github.butvinmitmo.userservice.unit.security import com.github.butvinmitmo.userservice.TestEntityFactory -import com.github.butvinmitmo.userservice.security.JwtUtil +import com.github.butvinmitmo.userservice.infrastructure.security.JwtTokenProvider import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys import org.junit.jupiter.api.Assertions.assertEquals @@ -14,14 +14,15 @@ import java.time.temporal.ChronoUnit import java.util.UUID class JwtUtilTest { - private lateinit var jwtUtil: JwtUtil + private lateinit var jwtTokenProvider: JwtTokenProvider private val testSecret = "testSecretKeyThatIsLongEnoughForHS256AlgorithmRequirements!!" private val expirationHours = 24L private val userId = UUID.randomUUID() + private val testUserRole = TestEntityFactory.testUserRole @BeforeEach fun setup() { - jwtUtil = JwtUtil(testSecret, expirationHours) + jwtTokenProvider = JwtTokenProvider(testSecret, expirationHours) } @Test @@ -29,13 +30,13 @@ class JwtUtilTest { val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = Instant.now()) val beforeGeneration = Instant.now() - val (token, expiresAt) = jwtUtil.generateToken(user) + val result = jwtTokenProvider.generateToken(user) - assertNotNull(token) - assertNotNull(expiresAt) + assertNotNull(result.token) + assertNotNull(result.expiresAt) val expectedExpiration = beforeGeneration.plus(expirationHours, ChronoUnit.HOURS) - val difference = ChronoUnit.SECONDS.between(expiresAt, expectedExpiration) + val difference = ChronoUnit.SECONDS.between(result.expiresAt, expectedExpiration) assertTrue(difference < 2, "Expiration time should be within 2 seconds of expected value") } @@ -43,7 +44,7 @@ class JwtUtilTest { fun `generateToken should include user ID as subject`() { val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = Instant.now()) - val (token, _) = jwtUtil.generateToken(user) + val result = jwtTokenProvider.generateToken(user) val secretKey = Keys.hmacShaKeyFor(testSecret.toByteArray()) val claims = @@ -51,7 +52,7 @@ class JwtUtilTest { .parser() .verifyWith(secretKey) .build() - .parseSignedClaims(token) + .parseSignedClaims(result.token) .payload assertEquals(userId.toString(), claims.subject) @@ -61,7 +62,7 @@ class JwtUtilTest { fun `generateToken should include username claim`() { val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = Instant.now()) - val (token, _) = jwtUtil.generateToken(user) + val result = jwtTokenProvider.generateToken(user) val secretKey = Keys.hmacShaKeyFor(testSecret.toByteArray()) val claims = @@ -69,7 +70,7 @@ class JwtUtilTest { .parser() .verifyWith(secretKey) .build() - .parseSignedClaims(token) + .parseSignedClaims(result.token) .payload assertEquals("testuser", claims["username"]) @@ -79,7 +80,7 @@ class JwtUtilTest { fun `generateToken should include role claim`() { val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = Instant.now()) - val (token, _) = jwtUtil.generateToken(user) + val result = jwtTokenProvider.generateToken(user) val secretKey = Keys.hmacShaKeyFor(testSecret.toByteArray()) val claims = @@ -87,7 +88,7 @@ class JwtUtilTest { .parser() .verifyWith(secretKey) .build() - .parseSignedClaims(token) + .parseSignedClaims(result.token) .payload assertEquals("USER", claims["role"]) @@ -98,7 +99,7 @@ class JwtUtilTest { val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = Instant.now()) val beforeGeneration = Instant.now() - val (token, _) = jwtUtil.generateToken(user) + val result = jwtTokenProvider.generateToken(user) val secretKey = Keys.hmacShaKeyFor(testSecret.toByteArray()) val claims = @@ -106,7 +107,7 @@ class JwtUtilTest { .parser() .verifyWith(secretKey) .build() - .parseSignedClaims(token) + .parseSignedClaims(result.token) .payload val issuedAt = claims.issuedAt.toInstant() @@ -118,7 +119,7 @@ class JwtUtilTest { fun `generateToken should be verifiable with correct secret`() { val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = Instant.now()) - val (token, _) = jwtUtil.generateToken(user) + val result = jwtTokenProvider.generateToken(user) val secretKey = Keys.hmacShaKeyFor(testSecret.toByteArray()) val claims = @@ -126,7 +127,7 @@ class JwtUtilTest { .parser() .verifyWith(secretKey) .build() - .parseSignedClaims(token) + .parseSignedClaims(result.token) assertNotNull(claims) } @@ -135,7 +136,7 @@ class JwtUtilTest { fun `generateToken should include expiration claim matching returned instant`() { val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = Instant.now()) - val (token, expiresAt) = jwtUtil.generateToken(user) + val result = jwtTokenProvider.generateToken(user) val secretKey = Keys.hmacShaKeyFor(testSecret.toByteArray()) val claims = @@ -143,10 +144,10 @@ class JwtUtilTest { .parser() .verifyWith(secretKey) .build() - .parseSignedClaims(token) + .parseSignedClaims(result.token) .payload val tokenExpiration = claims.expiration.toInstant() - assertEquals(expiresAt.epochSecond, tokenExpiration.epochSecond) + assertEquals(result.expiresAt.epochSecond, tokenExpiration.epochSecond) } } diff --git a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/unit/service/UserServiceTest.kt b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/unit/service/UserServiceTest.kt index adcc439..91da674 100644 --- a/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/unit/service/UserServiceTest.kt +++ b/user-service/src/test/kotlin/com/github/butvinmitmo/userservice/unit/service/UserServiceTest.kt @@ -1,42 +1,34 @@ package com.github.butvinmitmo.userservice.unit.service -import com.github.butvinmitmo.shared.client.DivinationServiceInternalClient -import com.github.butvinmitmo.shared.client.ServiceUnavailableException -import com.github.butvinmitmo.shared.dto.CreateUserRequest -import com.github.butvinmitmo.shared.dto.LoginRequest -import com.github.butvinmitmo.shared.dto.UpdateUserRequest import com.github.butvinmitmo.userservice.TestEntityFactory -import com.github.butvinmitmo.userservice.entity.Role -import com.github.butvinmitmo.userservice.entity.RoleType -import com.github.butvinmitmo.userservice.entity.User +import com.github.butvinmitmo.userservice.application.interfaces.provider.PasswordEncoder +import com.github.butvinmitmo.userservice.application.interfaces.provider.TokenProvider +import com.github.butvinmitmo.userservice.application.interfaces.provider.TokenResult +import com.github.butvinmitmo.userservice.application.interfaces.publisher.UserEventPublisher +import com.github.butvinmitmo.userservice.application.interfaces.repository.RoleRepository +import com.github.butvinmitmo.userservice.application.interfaces.repository.UserRepository +import com.github.butvinmitmo.userservice.application.service.UserService +import com.github.butvinmitmo.userservice.domain.model.User import com.github.butvinmitmo.userservice.exception.ConflictException import com.github.butvinmitmo.userservice.exception.NotFoundException import com.github.butvinmitmo.userservice.exception.UnauthorizedException -import com.github.butvinmitmo.userservice.mapper.UserMapper -import com.github.butvinmitmo.userservice.repository.RoleRepository -import com.github.butvinmitmo.userservice.repository.UserRepository -import com.github.butvinmitmo.userservice.security.JwtUtil -import com.github.butvinmitmo.userservice.service.RoleService -import com.github.butvinmitmo.userservice.service.UserService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock +import org.mockito.Mockito.lenient import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import org.springframework.data.domain.PageImpl -import org.springframework.data.domain.PageRequest -import org.springframework.http.ResponseEntity -import org.springframework.security.crypto.password.PasswordEncoder +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.test.StepVerifier import java.time.Instant -import java.util.Optional import java.util.UUID @ExtendWith(MockitoExtension::class) @@ -47,171 +39,156 @@ class UserServiceTest { @Mock private lateinit var roleRepository: RoleRepository - @Mock - private lateinit var roleService: RoleService - @Mock private lateinit var passwordEncoder: PasswordEncoder @Mock - private lateinit var jwtUtil: JwtUtil + private lateinit var tokenProvider: TokenProvider @Mock - private lateinit var divinationServiceInternalClient: DivinationServiceInternalClient + private lateinit var userEventPublisher: UserEventPublisher private lateinit var userService: UserService - private val userMapper = UserMapper() private val userId = UUID.randomUUID() private val createdAt = Instant.now() - private val testUserRole = Role(id = RoleType.USER_ID, name = "USER") + private val testUserRole = TestEntityFactory.testUserRole @BeforeEach fun setup() { + lenient().`when`(userEventPublisher.publishCreated(any())).thenReturn(Mono.empty()) + lenient().`when`(userEventPublisher.publishUpdated(any())).thenReturn(Mono.empty()) + lenient().`when`(userEventPublisher.publishDeleted(any())).thenReturn(Mono.empty()) + userService = UserService( userRepository, roleRepository, - roleService, - userMapper, passwordEncoder, - jwtUtil, - divinationServiceInternalClient, + tokenProvider, + userEventPublisher, ) } @Test fun `createUser should create new user successfully`() { - val request = CreateUserRequest(username = "testuser", password = "Test@123") val savedUser = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = createdAt) - whenever(userRepository.findByUsername("testuser")).thenReturn(null) - whenever(roleService.getRoleByName(null)).thenReturn(testUserRole) + whenever(userRepository.findByUsername("testuser")).thenReturn(Mono.empty()) + whenever(roleRepository.findByName("USER")).thenReturn(Mono.just(testUserRole)) whenever(passwordEncoder.encode("Test@123")).thenReturn("hashedPassword") - whenever(userRepository.save(any())).thenReturn(savedUser) + whenever(userRepository.save(any())).thenReturn(Mono.just(savedUser)) - val result = userService.createUser(request) - - assertNotNull(result) - assertEquals(userId, result.id) + StepVerifier + .create(userService.createUser("testuser", "Test@123", null)) + .assertNext { result -> + assertNotNull(result) + assertEquals(userId, result) + }.verifyComplete() val userCaptor = argumentCaptor() verify(userRepository).save(userCaptor.capture()) assertEquals("testuser", userCaptor.firstValue.username) + verify(userEventPublisher).publishCreated(savedUser) } @Test fun `createUser should throw ConflictException when user already exists`() { - val request = CreateUserRequest(username = "testuser", password = "Test@123") val existingUser = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = createdAt) - whenever(userRepository.findByUsername("testuser")).thenReturn(existingUser) + whenever(userRepository.findByUsername("testuser")).thenReturn(Mono.just(existingUser)) - val exception = - assertThrows { - userService.createUser(request) - } - assertEquals("User with this username already exists", exception.message) + StepVerifier + .create(userService.createUser("testuser", "Test@123", null)) + .expectErrorMatches { it is ConflictException && it.message == "User with this username already exists" } + .verify() - verify(userRepository, never()).save(any()) + verify(userRepository, never()).save(any()) } @Test fun `getUser should return user when found`() { val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = createdAt) - whenever(userRepository.findById(userId)).thenReturn(Optional.of(user)) - - val result = userService.getUser(userId) + whenever(userRepository.findById(userId)).thenReturn(Mono.just(user)) - assertNotNull(result) - assertEquals(userId, result.id) - assertEquals("testuser", result.username) + StepVerifier + .create(userService.getUser(userId)) + .assertNext { result -> + assertNotNull(result) + assertEquals(userId, result.id) + assertEquals("testuser", result.username) + }.verifyComplete() } @Test fun `getUser should throw NotFoundException when user not found`() { - whenever(userRepository.findById(userId)).thenReturn(Optional.empty()) + whenever(userRepository.findById(userId)).thenReturn(Mono.empty()) - val exception = - assertThrows { - userService.getUser(userId) - } - assertEquals("User not found", exception.message) + StepVerifier + .create(userService.getUser(userId)) + .expectErrorMatches { it is NotFoundException && it.message == "User not found" } + .verify() } @Test fun `updateUser should update username when provided`() { val existingUser = TestEntityFactory.createUser(id = userId, username = "oldname", createdAt = createdAt) - val updateRequest = UpdateUserRequest(username = "newname") - whenever(userRepository.findById(userId)).thenReturn(Optional.of(existingUser)) - whenever(userRepository.save(any())).thenAnswer { it.arguments[0] as User } + whenever(userRepository.findById(userId)).thenReturn(Mono.just(existingUser)) + whenever(userRepository.save(any())).thenAnswer { Mono.just(it.arguments[0] as User) } - val result = userService.updateUser(userId, updateRequest) - - assertNotNull(result) - assertEquals("newname", result.username) + StepVerifier + .create(userService.updateUser(userId, "newname", null, null)) + .assertNext { result -> + assertNotNull(result) + assertEquals("newname", result.username) + }.verifyComplete() val userCaptor = argumentCaptor() verify(userRepository).save(userCaptor.capture()) assertEquals("newname", userCaptor.firstValue.username) + verify(userEventPublisher).publishUpdated(any()) } @Test fun `updateUser should throw NotFoundException when user not found`() { - val updateRequest = UpdateUserRequest(username = "newname") - - whenever(userRepository.findById(userId)).thenReturn(Optional.empty()) + whenever(userRepository.findById(userId)).thenReturn(Mono.empty()) - val exception = - assertThrows { - userService.updateUser(userId, updateRequest) - } - assertEquals("User not found", exception.message) + StepVerifier + .create(userService.updateUser(userId, "newname", null, null)) + .expectErrorMatches { it is NotFoundException && it.message == "User not found" } + .verify() - verify(userRepository, never()).save(any()) + verify(userRepository, never()).save(any()) } @Test - fun `deleteUser should delete user when exists and cleanup succeeds`() { - whenever(userRepository.existsById(userId)).thenReturn(true) - whenever(divinationServiceInternalClient.deleteUserData(userId)).thenReturn(ResponseEntity.noContent().build()) + fun `deleteUser should delete user when exists`() { + val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = createdAt) - userService.deleteUser(userId) + whenever(userRepository.findById(userId)).thenReturn(Mono.just(user)) + whenever(userRepository.deleteById(userId)).thenReturn(Mono.empty()) + + StepVerifier + .create(userService.deleteUser(userId)) + .verifyComplete() - verify(divinationServiceInternalClient).deleteUserData(userId) verify(userRepository).deleteById(userId) + verify(userEventPublisher).publishDeleted(user) } @Test fun `deleteUser should throw NotFoundException when user not found`() { - whenever(userRepository.existsById(userId)).thenReturn(false) - - val exception = - assertThrows { - userService.deleteUser(userId) - } - assertEquals("User not found", exception.message) + whenever(userRepository.findById(userId)).thenReturn(Mono.empty()) - verify(divinationServiceInternalClient, never()).deleteUserData(any()) - verify(userRepository, never()).deleteById(any()) - } + StepVerifier + .create(userService.deleteUser(userId)) + .expectErrorMatches { it is NotFoundException && it.message == "User not found" } + .verify() - @Test - fun `deleteUser should throw ServiceUnavailableException when cleanup fails`() { - whenever(userRepository.existsById(userId)).thenReturn(true) - whenever(divinationServiceInternalClient.deleteUserData(userId)) - .thenThrow(ServiceUnavailableException("divination-service")) - - val exception = - assertThrows { - userService.deleteUser(userId) - } - assertEquals("divination-service", exception.serviceName) - - verify(divinationServiceInternalClient).deleteUserData(userId) - verify(userRepository, never()).deleteById(any()) + verify(userRepository, never()).deleteById(any()) + verify(userEventPublisher, never()).publishDeleted(any()) } @Test @@ -221,82 +198,81 @@ class UserServiceTest { TestEntityFactory.createUser(id = UUID.randomUUID(), username = "user1", createdAt = createdAt), TestEntityFactory.createUser(id = UUID.randomUUID(), username = "user2", createdAt = createdAt), ) - val pageable = PageRequest.of(0, 2) - val page = PageImpl(users, pageable, 2) - - whenever(userRepository.findAll(pageable)).thenReturn(page) - val result = userService.getUsers(0, 2) - - assertNotNull(result) - assertEquals(2, result.content.size) - assertEquals("user1", result.content[0].username) - assertEquals("user2", result.content[1].username) + whenever(userRepository.count()).thenReturn(Mono.just(2L)) + whenever(userRepository.findAllPaginated(0L, 2)).thenReturn(Flux.fromIterable(users)) + + StepVerifier + .create(userService.getUsers(0, 2)) + .assertNext { result -> + assertNotNull(result) + assertEquals(2, result.content.size) + assertEquals("user1", result.content[0].username) + assertEquals("user2", result.content[1].username) + }.verifyComplete() } @Test fun `getUsers should return empty list when no users exist`() { - val pageable = PageRequest.of(0, 10) - val emptyPage = PageImpl(emptyList(), pageable, 0) - - whenever(userRepository.findAll(pageable)).thenReturn(emptyPage) - - val result = userService.getUsers(0, 10) - - assertNotNull(result) - assertEquals(0, result.content.size) + whenever(userRepository.count()).thenReturn(Mono.just(0L)) + whenever(userRepository.findAllPaginated(0L, 10)).thenReturn(Flux.empty()) + + StepVerifier + .create(userService.getUsers(0, 10)) + .assertNext { result -> + assertNotNull(result) + assertEquals(0, result.content.size) + }.verifyComplete() } @Test fun `authenticate should return token for valid credentials`() { - val request = LoginRequest(username = "testuser", password = "Test@123") val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = createdAt) val expiresAt = Instant.now().plusSeconds(86400) + val tokenResult = TokenResult("mock-jwt-token", expiresAt) - whenever(userRepository.findByUsername("testuser")).thenReturn(user) + whenever(userRepository.findByUsername("testuser")).thenReturn(Mono.just(user)) whenever(passwordEncoder.matches("Test@123", user.passwordHash)).thenReturn(true) - whenever(jwtUtil.generateToken(user)).thenReturn(Pair("mock-jwt-token", expiresAt)) - - val result = userService.authenticate(request) - - assertNotNull(result) - assertEquals("mock-jwt-token", result.token) - assertEquals(expiresAt, result.expiresAt) - assertEquals("testuser", result.username) - assertEquals("USER", result.role) - verify(jwtUtil).generateToken(user) + whenever(tokenProvider.generateToken(user)).thenReturn(tokenResult) + + StepVerifier + .create(userService.authenticate("testuser", "Test@123")) + .assertNext { result -> + assertNotNull(result) + assertEquals("mock-jwt-token", result.token) + assertEquals(expiresAt, result.expiresAt) + assertEquals("testuser", result.username) + assertEquals("USER", result.role) + }.verifyComplete() + + verify(tokenProvider).generateToken(user) } @Test fun `authenticate should throw UnauthorizedException for invalid username`() { - val request = LoginRequest(username = "nonexistent", password = "Test@123") - - whenever(userRepository.findByUsername("nonexistent")).thenReturn(null) + whenever(userRepository.findByUsername("nonexistent")).thenReturn(Mono.empty()) - val exception = - assertThrows { - userService.authenticate(request) - } - assertEquals("Invalid username or password", exception.message) + StepVerifier + .create(userService.authenticate("nonexistent", "Test@123")) + .expectErrorMatches { it is UnauthorizedException && it.message == "Invalid username or password" } + .verify() verify(passwordEncoder, never()).matches(any(), any()) - verify(jwtUtil, never()).generateToken(any()) + verify(tokenProvider, never()).generateToken(any()) } @Test fun `authenticate should throw UnauthorizedException for invalid password`() { - val request = LoginRequest(username = "testuser", password = "WrongPassword") val user = TestEntityFactory.createUser(id = userId, username = "testuser", createdAt = createdAt) - whenever(userRepository.findByUsername("testuser")).thenReturn(user) + whenever(userRepository.findByUsername("testuser")).thenReturn(Mono.just(user)) whenever(passwordEncoder.matches("WrongPassword", user.passwordHash)).thenReturn(false) - val exception = - assertThrows { - userService.authenticate(request) - } - assertEquals("Invalid username or password", exception.message) + StepVerifier + .create(userService.authenticate("testuser", "WrongPassword")) + .expectErrorMatches { it is UnauthorizedException && it.message == "Invalid username or password" } + .verify() - verify(jwtUtil, never()).generateToken(any()) + verify(tokenProvider, never()).generateToken(any()) } } diff --git a/user-service/src/test/resources/application-test.yml b/user-service/src/test/resources/application-test.yml index 4728ba3..e803b38 100644 --- a/user-service/src/test/resources/application-test.yml +++ b/user-service/src/test/resources/application-test.yml @@ -4,24 +4,22 @@ spring: cloud: config: enabled: false - datasource: - url: jdbc:postgresql://localhost:5432/test_db + r2dbc: + url: r2dbc:postgresql://localhost:5432/test_db username: test_user password: test_password - driver-class-name: org.postgresql.Driver - jpa: - hibernate: - ddl-auto: validate - open-in-view: false - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect + pool: + initial-size: 5 + max-size: 20 flyway: enabled: true locations: classpath:db/migration table: flyway_schema_history_user baseline-on-migrate: true baseline-version: 0 + url: jdbc:postgresql://localhost:5432/test_db + user: test_user + password: test_password eureka: client: diff --git a/websocket-test.html b/websocket-test.html new file mode 100644 index 0000000..f71e395 --- /dev/null +++ b/websocket-test.html @@ -0,0 +1,111 @@ + + + + + + WebSocket Test + + + +

WebSocket Notification Test

+
+ Run via HTTP server: python -m http.server 3000 then open http://localhost:3000/websocket-test.html +
+
+
+ +
+
+ + +
+
Disconnected
+ + +

Messages:

+
+ + + +