From fa567c1c3fec4965a4dceaa6ef91f0f071865173 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Wed, 17 Jun 2026 21:38:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=2011=20test=20coverage=20?= =?UTF-8?q?=E2=80=94=20+166=20unit=20tests,=20schema=20drift=20fix=20(V34)?= =?UTF-8?q?,=20Testcontainers=201.21.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: AssemblyServiceTest (22), EventServiceTest (13), ForumServiceTest (14), InfoBoardServiceTest (10) Phase 3: Camt053ParserTest (19), CsvBankParserTest (14), BankImportServiceTest (14), BankStatementParserServiceTest (9) Phase 4: JwtServiceTest (17), LoginRateLimiterTest (8), TenantFilterAspectTest (8), DocumentServiceTest (12), GlobalExceptionHandlerTest (6) Phase 5: V34 schema drift fix migration, MigrationIntegrationTest + AbstractIntegrationTest fixes Infrastructure: V27 fix (added timestamps), Testcontainers upgrade 1.20.4 -> 1.21.3, test resources (bankimport samples) --- .../db/migration/V27__generated_reports.sql | 4 +- .../V34__fix_member_schema_drift.sql | 11 + .../exception/GlobalExceptionHandlerTest.java | 139 +++++ .../integration/AbstractIntegrationTest.java | 12 +- .../AssemblyLifecycleIntegrationTest.java | 406 +++++++++++++++ .../BankImportLifecycleIntegrationTest.java | 225 ++++++++ .../DistributionLifecycleIntegrationTest.java | 309 +++++++++++ .../integration/MigrationIntegrationTest.java | 88 ++++ .../SecurityConfigIntegrationTest.java | 83 +++ .../api/security/JwtServiceTest.java | 219 ++++++++ .../api/security/LoginRateLimiterTest.java | 120 +++++ .../api/security/TenantFilterAspectTest.java | 165 ++++++ .../service/AssemblyServiceTest.java | 371 +++++++++++++ .../service/DocumentServiceTest.java | 285 ++++++++++ .../cannamanage/service/EventServiceTest.java | 274 ++++++++++ .../cannamanage/service/ForumServiceTest.java | 245 +++++++++ .../service/InfoBoardServiceTest.java | 186 +++++++ .../bankimport/BankImportServiceTest.java | 354 +++++++++++++ .../BankStatementParserServiceTest.java | 190 +++++++ .../service/bankimport/Camt053ParserTest.java | 493 ++++++++++++++++++ .../service/bankimport/CsvBankParserTest.java | 295 +++++++++++ pom.xml | 2 +- 22 files changed, 4472 insertions(+), 4 deletions(-) create mode 100644 cannamanage-api/src/main/resources/db/migration/V34__fix_member_schema_drift.sql create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/exception/GlobalExceptionHandlerTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/AssemblyLifecycleIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/BankImportLifecycleIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/DistributionLifecycleIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/MigrationIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/SecurityConfigIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/security/JwtServiceTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/security/LoginRateLimiterTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/security/TenantFilterAspectTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/AssemblyServiceTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/EventServiceTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/ForumServiceTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/InfoBoardServiceTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/bankimport/BankImportServiceTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/bankimport/BankStatementParserServiceTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/bankimport/Camt053ParserTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/bankimport/CsvBankParserTest.java diff --git a/cannamanage-api/src/main/resources/db/migration/V27__generated_reports.sql b/cannamanage-api/src/main/resources/db/migration/V27__generated_reports.sql index 83c028e..efc1e0e 100644 --- a/cannamanage-api/src/main/resources/db/migration/V27__generated_reports.sql +++ b/cannamanage-api/src/main/resources/db/migration/V27__generated_reports.sql @@ -10,7 +10,9 @@ CREATE TABLE generated_reports ( storage_path VARCHAR(500), parameters JSONB, generated_by UUID NOT NULL REFERENCES users(id), - generated_at TIMESTAMP DEFAULT NOW() + generated_at TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_generated_reports_tenant ON generated_reports(tenant_id); diff --git a/cannamanage-api/src/main/resources/db/migration/V34__fix_member_schema_drift.sql b/cannamanage-api/src/main/resources/db/migration/V34__fix_member_schema_drift.sql new file mode 100644 index 0000000..7a0a3ab --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V34__fix_member_schema_drift.sql @@ -0,0 +1,11 @@ +-- Fix schema drift: members table is missing columns that the JPA Member entity expects. +-- user_id: links member to their login user account (nullable, set on portal registration) +-- iban: member's IBAN for bank statement matching (Sprint 10, nullable, consent-gated) +-- iban_consent_date: timestamp when BANK_DATA consent was granted + +ALTER TABLE members ADD COLUMN IF NOT EXISTS user_id UUID; +ALTER TABLE members ADD COLUMN IF NOT EXISTS iban VARCHAR(34); +ALTER TABLE members ADD COLUMN IF NOT EXISTS iban_consent_date TIMESTAMPTZ; + +-- Index for user_id lookups (portal login → member resolution) +CREATE INDEX IF NOT EXISTS idx_members_user_id ON members(user_id); diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/exception/GlobalExceptionHandlerTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..2a5dd21 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,139 @@ +package de.cannamanage.api.exception; + +import de.cannamanage.service.exception.QuotaExceededException; +import de.cannamanage.service.exception.QuotaViolationCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.core.MethodParameter; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link GlobalExceptionHandler} verifying RFC 9457 ProblemDetail + * responses and ensuring no internal details (stack traces, paths) are leaked. + */ +class GlobalExceptionHandlerTest { + + private GlobalExceptionHandler handler; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(); + } + + @Test + void testHandleValidation_returnsStatus400WithFieldErrors() throws Exception { + // Simulate a validation failure with field errors + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "request"); + bindingResult.addError(new FieldError("request", "email", "must not be blank")); + bindingResult.addError(new FieldError("request", "name", "size must be between 2 and 100")); + + // MethodParameter is needed for MethodArgumentNotValidException constructor + MethodParameter param = new MethodParameter( + this.getClass().getDeclaredMethod("setUp"), -1); + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(param, bindingResult); + + ProblemDetail problem = handler.handleValidation(ex); + + assertThat(problem.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + assertThat(problem.getTitle()).isEqualTo("Bad Request"); + assertThat(problem.getType().toString()).contains("VALIDATION_FAILED"); + assertThat(problem.getProperties()).containsKey("errors"); + @SuppressWarnings("unchecked") + List errors = (List) problem.getProperties().get("errors"); + assertThat(errors).hasSize(2); + assertThat(errors).anyMatch(e -> e.contains("email")); + assertThat(errors).anyMatch(e -> e.contains("name")); + } + + @Test + void testHandleAccessDenied_returnsStatus403WithNoStackTrace() { + AccessDeniedException ex = new AccessDeniedException("You shall not pass"); + + ProblemDetail problem = handler.handleAccessDenied(ex); + + assertThat(problem.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value()); + assertThat(problem.getTitle()).isEqualTo("Forbidden"); + assertThat(problem.getDetail()).isEqualTo("Access denied"); + // SECURITY: original exception message NOT exposed + assertThat(problem.getDetail()).doesNotContain("shall not pass"); + // SECURITY: no stack trace or internal paths + assertThat(problem.getProperties()).doesNotContainKey("stackTrace"); + assertThat(problem.getProperties()).doesNotContainKey("trace"); + } + + @Test + void testHandleGenericException_returnsStatus500WithGenericMessage() { + RuntimeException ex = new RuntimeException( + "NullPointerException at com.internal.Service.process(Service.java:42)"); + + ProblemDetail problem = handler.handleGeneric(ex); + + assertThat(problem.getStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value()); + assertThat(problem.getTitle()).isEqualTo("Internal Server Error"); + assertThat(problem.getDetail()).isEqualTo("An unexpected error occurred"); + // SECURITY: internal details NOT leaked + assertThat(problem.getDetail()).doesNotContain("NullPointerException"); + assertThat(problem.getDetail()).doesNotContain("Service.java"); + assertThat(problem.getDetail()).doesNotContain("com.internal"); + assertThat(problem.getProperties().get("code")).isEqualTo("INTERNAL_ERROR"); + } + + @Test + void testHandleQuotaExceeded_returnsStatus409WithCode() { + QuotaExceededException ex = new QuotaExceededException( + QuotaViolationCode.MEMBER_INACTIVE, "Member is inactive"); + + ProblemDetail problem = handler.handleQuotaExceeded(ex); + + assertThat(problem.getStatus()).isEqualTo(HttpStatus.CONFLICT.value()); + assertThat(problem.getTitle()).isEqualTo("Compliance Violation"); + assertThat(problem.getDetail()).isEqualTo("Member is inactive"); + assertThat(problem.getProperties().get("code")).isEqualTo("MEMBER_INACTIVE"); + assertThat(problem.getProperties()).containsKey("timestamp"); + } + + @Test + void testHandleMemberNotFound_returnsStatus404WithRfc9457Body() { + var ex = new de.cannamanage.service.exception.MemberNotFoundException(UUID.randomUUID()); + + ProblemDetail problem = handler.handleMemberNotFound(ex); + + assertThat(problem.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(problem.getTitle()).isEqualTo("Not Found"); + assertThat(problem.getType().toString()).contains("MEMBER_NOT_FOUND"); + assertThat(problem.getProperties().get("code")).isEqualTo("MEMBER_NOT_FOUND"); + assertThat(problem.getProperties()).containsKey("timestamp"); + } + + @Test + void testAllHandlers_includeTimestamp_neverExposeInternalState() { + // Verify that all handlers set the timestamp property + ProblemDetail p1 = handler.handleAccessDenied(new AccessDeniedException("x")); + ProblemDetail p2 = handler.handleGeneric(new RuntimeException("internal error details")); + ProblemDetail p3 = handler.handleQuotaExceeded( + new QuotaExceededException(QuotaViolationCode.MEMBER_INACTIVE, "msg")); + + assertThat(p1.getProperties()).containsKey("timestamp"); + assertThat(p2.getProperties()).containsKey("timestamp"); + assertThat(p3.getProperties()).containsKey("timestamp"); + + // None should expose stack traces or class paths + for (ProblemDetail p : List.of(p1, p2, p3)) { + assertThat(p.getProperties()).doesNotContainKey("stackTrace"); + assertThat(p.getProperties()).doesNotContainKey("exception"); + if (p.getDetail() != null) { + assertThat(p.getDetail()).doesNotContain(".java:"); + } + } + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/AbstractIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/AbstractIntegrationTest.java index 1d058d3..f55fa82 100644 --- a/cannamanage-api/src/test/java/de/cannamanage/api/integration/AbstractIntegrationTest.java +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/AbstractIntegrationTest.java @@ -8,6 +8,7 @@ import de.cannamanage.api.dto.stock.BatchResponse; import de.cannamanage.api.dto.stock.CreateBatchRequest; import de.cannamanage.domain.entity.Club; import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.TenantContext; import de.cannamanage.domain.entity.User; import de.cannamanage.domain.enums.ClubStatus; import de.cannamanage.domain.enums.UserRole; @@ -105,16 +106,23 @@ public abstract class AbstractIntegrationTest { // --- Test data creation helpers --- /** - * Creates a club (tenant) and returns its ID. + * Creates a club (tenant) and returns its tenant ID. + * IMPORTANT: Sets TenantContext for all subsequent entity creation. + * The returned UUID is the tenantId (same value used for all entities). */ protected UUID createTestClub(String name) { + // Pre-generate the tenant UUID — all entities will share this + UUID tenantId = UUID.randomUUID(); + TenantContext.setCurrentTenant(tenantId); Club club = new Club(); club.setName(name); + club.setLicenseNumber("LIC-" + UUID.randomUUID().toString().substring(0, 8)); club.setStatus(ClubStatus.ACTIVE); club.setMaxMembers(500); club.setMaxPreventionOfficers(3); club = clubRepository.save(club); - return club.getId(); + // TenantContext remains set — @PrePersist will use it for subsequent entities + return tenantId; } /** diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/AssemblyLifecycleIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/AssemblyLifecycleIntegrationTest.java new file mode 100644 index 0000000..c3e815c --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/AssemblyLifecycleIntegrationTest.java @@ -0,0 +1,406 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.api.controller.AssemblyController; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.enums.AgendaItemType; +import de.cannamanage.domain.enums.AssemblyType; +import de.cannamanage.domain.enums.VoteDecision; +import de.cannamanage.domain.enums.VoteType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test verifying the full assembly (Mitgliederversammlung) lifecycle end-to-end. + * Tests creation, quorum enforcement, voting with majority thresholds, and protocol generation. + */ +class AssemblyLifecycleIntegrationTest extends AbstractIntegrationTest { + + private UUID tenantId; + private String adminToken; + private UUID member1Id; + private UUID member2Id; + private UUID member3Id; + + private static final String ADMIN_EMAIL = "asm-admin@test.de"; + private static final String ADMIN_PASSWORD = "AdminPass123!"; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Assembly Test Club"); + createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD); + adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD); + + // Create 3 members for quorum and voting tests + TenantContext.setCurrentTenant(tenantId); + Member m1 = createMemberDirectly(tenantId, "Alice", "Meier", "alice@test.de", LocalDate.of(1990, 1, 1)); + Member m2 = createMemberDirectly(tenantId, "Bob", "Schmidt", "bob@test.de", LocalDate.of(1985, 6, 15)); + Member m3 = createMemberDirectly(tenantId, "Clara", "Weber", "clara@test.de", LocalDate.of(1992, 9, 30)); + member1Id = m1.getId(); + member2Id = m2.getId(); + member3Id = m3.getId(); + TenantContext.clear(); + } + + @Test + @DisplayName("Full assembly lifecycle: Create → Add agenda → Start → Vote → Complete") + void testFullLifecycle_CreateStartVoteComplete() { + // Step 1: Create assembly + Instant scheduledAt = Instant.now().plus(1, ChronoUnit.HOURS); + Map createRequest = Map.of( + "title", "Ordentliche Mitgliederversammlung 2026", + "assemblyType", "REGULAR", + "scheduledAt", scheduledAt.toString(), + "location", "Vereinsheim", + "quorumRequired", 2, + "agendaItems", List.of( + Map.of("title", "Kassenbericht", "description", "Bericht des Schatzmeisters", "itemType", "DISCUSSION"), + Map.of("title", "Vorstandswahl", "description", "Neuwahl des Vorstands", "itemType", "VOTE") + ) + ); + + ResponseEntity createResponse = restClient().post() + .uri("/api/v1/assemblies") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(createRequest) + .retrieve() + .toEntity(String.class); + + assertThat(createResponse.getStatusCode().value()).isEqualTo(200); + assertThat(createResponse.getBody()).contains("Ordentliche Mitgliederversammlung 2026"); + + // Extract assembly ID from response + String assemblyId = extractId(createResponse.getBody()); + assertThat(assemblyId).isNotNull(); + + // Step 2: Check in attendees (quorum = 2, we check in 2 members) + checkInAttendee(assemblyId, member1Id); + checkInAttendee(assemblyId, member2Id); + + // Step 3: Start assembly (quorum met with 2 attendees) + ResponseEntity startResponse = restClient().post() + .uri("/api/v1/assemblies/" + assemblyId + "/start") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(startResponse.getStatusCode().value()).isEqualTo(200); + assertThat(startResponse.getBody()).contains("IN_PROGRESS"); + + // Step 4: Create a vote on the second agenda item + // First get assembly detail to find agenda item IDs + ResponseEntity detailResponse = restClient().get() + .uri("/api/v1/assemblies/" + assemblyId) + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + assertThat(detailResponse.getStatusCode().value()).isEqualTo(200); + + String agendaItemId = extractSecondAgendaItemId(detailResponse.getBody()); + assertThat(agendaItemId).isNotNull(); + + Map voteRequest = Map.of( + "agendaItemId", agendaItemId, + "title", "Vorstandswahl Abstimmung", + "description", "Wahl des neuen Vorstands", + "voteType", "SIMPLE_MAJORITY" + ); + + ResponseEntity voteCreateResponse = restClient().post() + .uri("/api/v1/assemblies/" + assemblyId + "/votes") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(voteRequest) + .retrieve() + .toEntity(String.class); + + assertThat(voteCreateResponse.getStatusCode().value()).isEqualTo(200); + String voteId = extractId(voteCreateResponse.getBody()); + + // Step 5: Cast votes — both members vote YES (simple majority passes) + castVote(voteId, member1Id, "YES"); + castVote(voteId, member2Id, "YES"); + + // Step 6: Close vote + ResponseEntity closeVoteResponse = restClient().post() + .uri("/api/v1/assemblies/votes/" + voteId + "/close") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(closeVoteResponse.getStatusCode().value()).isEqualTo(200); + assertThat(closeVoteResponse.getBody()).contains("PASSED"); + + // Step 7: Complete assembly + ResponseEntity completeResponse = restClient().post() + .uri("/api/v1/assemblies/" + assemblyId + "/complete") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(completeResponse.getStatusCode().value()).isEqualTo(200); + assertThat(completeResponse.getBody()).contains("COMPLETED"); + } + + @Test + @DisplayName("Quorum check: not enough attendees — cannot start") + void testQuorumCheck_InsufficientAttendees_CannotStart() { + // Create assembly requiring quorum of 3 + Map createRequest = Map.of( + "title", "Quorum Test Assembly", + "assemblyType", "REGULAR", + "scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(), + "location", "Online", + "quorumRequired", 3 + ); + + ResponseEntity createResponse = restClient().post() + .uri("/api/v1/assemblies") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(createRequest) + .retrieve() + .toEntity(String.class); + + String assemblyId = extractId(createResponse.getBody()); + + // Check in only 2 members (quorum needs 3) + checkInAttendee(assemblyId, member1Id); + checkInAttendee(assemblyId, member2Id); + + // Try to start — should fail due to quorum + ResponseEntity startResponse = restClient().post() + .uri("/api/v1/assemblies/" + assemblyId + "/start") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + // Expect failure — quorum not met + assertThat(startResponse.getStatusCode().value()).isIn(400, 422, 409); + } + + @Test + @DisplayName("Extraordinary assembly creation succeeds") + void testExtraordinaryAssembly_CreationSucceeds() { + Map createRequest = Map.of( + "title", "Außerordentliche Versammlung", + "assemblyType", "EXTRAORDINARY", + "scheduledAt", Instant.now().plus(2, ChronoUnit.DAYS).toString(), + "location", "Vereinsheim", + "quorumRequired", 2 + ); + + ResponseEntity response = restClient().post() + .uri("/api/v1/assemblies") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(createRequest) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).contains("EXTRAORDINARY"); + assertThat(response.getBody()).contains("Außerordentliche Versammlung"); + } + + @Test + @DisplayName("Vote with SIMPLE_MAJORITY: exact threshold (50%+1) passes") + void testVote_SimpleMajority_ExactThreshold_Passes() { + // Create and start assembly with 3 attendees + String assemblyId = createAndStartAssemblyWith3Attendees(); + + // Get first agenda item ID + ResponseEntity detailResponse = restClient().get() + .uri("/api/v1/assemblies/" + assemblyId) + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + String agendaItemId = extractFirstAgendaItemId(detailResponse.getBody()); + + // Create vote + Map voteRequest = Map.of( + "agendaItemId", agendaItemId, + "title", "Majority Test", + "description", "Testing exact majority threshold", + "voteType", "SIMPLE_MAJORITY" + ); + + ResponseEntity voteResponse = restClient().post() + .uri("/api/v1/assemblies/" + assemblyId + "/votes") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(voteRequest) + .retrieve() + .toEntity(String.class); + + String voteId = extractId(voteResponse.getBody()); + + // Cast votes: 2 YES, 1 NO — 2/3 > 50% → should pass + castVote(voteId, member1Id, "YES"); + castVote(voteId, member2Id, "YES"); + castVote(voteId, member3Id, "NO"); + + // Close vote + ResponseEntity closeResponse = restClient().post() + .uri("/api/v1/assemblies/votes/" + voteId + "/close") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(closeResponse.getStatusCode().value()).isEqualTo(200); + assertThat(closeResponse.getBody()).contains("PASSED"); + } + + @Test + @DisplayName("Archive assembly generates protocol document (PDF downloadable)") + void testComplete_GeneratesProtocol_Downloadable() { + // Create, start, and complete assembly + String assemblyId = createAndStartAssemblyWith3Attendees(); + + // Complete the assembly + restClient().post() + .uri("/api/v1/assemblies/" + assemblyId + "/complete") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + // Download protocol PDF + ResponseEntity protocolResponse = restClient().get() + .uri("/api/v1/assemblies/" + assemblyId + "/protocol") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(byte[].class); + + assertThat(protocolResponse.getStatusCode().value()).isEqualTo(200); + assertThat(protocolResponse.getHeaders().getContentType()) + .isEqualTo(MediaType.APPLICATION_PDF); + assertThat(protocolResponse.getBody()).isNotNull(); + assertThat(protocolResponse.getBody().length).isGreaterThan(0); + } + + // === Helper methods === + + private void checkInAttendee(String assemblyId, UUID memberId) { + Map request = Map.of("memberId", memberId.toString()); + + ResponseEntity response = restClient().post() + .uri("/api/v1/assemblies/" + assemblyId + "/attendees") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + private void castVote(String voteId, UUID memberId, String decision) { + Map request = Map.of( + "memberId", memberId.toString(), + "decision", decision + ); + + ResponseEntity response = restClient().post() + .uri("/api/v1/assemblies/votes/" + voteId + "/cast") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + private String createAndStartAssemblyWith3Attendees() { + Map createRequest = Map.of( + "title", "Test Assembly", + "assemblyType", "REGULAR", + "scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(), + "location", "Online", + "quorumRequired", 2, + "agendaItems", List.of( + Map.of("title", "Tagesordnungspunkt 1", "description", "Test", "itemType", "VOTE") + ) + ); + + ResponseEntity createResponse = restClient().post() + .uri("/api/v1/assemblies") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(createRequest) + .retrieve() + .toEntity(String.class); + + String assemblyId = extractId(createResponse.getBody()); + + // Check in 3 attendees + checkInAttendee(assemblyId, member1Id); + checkInAttendee(assemblyId, member2Id); + checkInAttendee(assemblyId, member3Id); + + // Start assembly + restClient().post() + .uri("/api/v1/assemblies/" + assemblyId + "/start") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + return assemblyId; + } + + /** + * Extracts the "id" field value from a JSON response body. + * Simple regex extraction to avoid Jackson dependency in test. + */ + private String extractId(String jsonBody) { + if (jsonBody == null) return null; + var pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\""); + var matcher = pattern.matcher(jsonBody); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + /** + * Extracts the second agenda item's ID from the assembly detail response. + */ + private String extractSecondAgendaItemId(String jsonBody) { + if (jsonBody == null) return null; + var pattern = java.util.regex.Pattern.compile("\"agendaItems\"\\s*:\\s*\\[.*?\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"[^}]*\\}\\s*,\\s*\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\""); + var matcher = pattern.matcher(jsonBody); + if (matcher.find()) { + return matcher.group(2); + } + // Fallback: try to get any agenda item ID + return extractFirstAgendaItemId(jsonBody); + } + + /** + * Extracts the first agenda item's ID from the assembly detail response. + */ + private String extractFirstAgendaItemId(String jsonBody) { + if (jsonBody == null) return null; + // Look for agendaItems array and extract first ID + var pattern = java.util.regex.Pattern.compile("\"agendaItems\"\\s*:\\s*\\[\\s*\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\""); + var matcher = pattern.matcher(jsonBody); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/BankImportLifecycleIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/BankImportLifecycleIntegrationTest.java new file mode 100644 index 0000000..c22f6b3 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/BankImportLifecycleIntegrationTest.java @@ -0,0 +1,225 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.TenantContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test verifying the full bank import lifecycle end-to-end. + * Tests: upload MT940 → parse → auto-match → confirm → complete, + * duplicate file detection, and session abandonment. + */ +class BankImportLifecycleIntegrationTest extends AbstractIntegrationTest { + + private UUID tenantId; + private String adminToken; + private UUID memberId; + + private static final String ADMIN_EMAIL = "bank-admin@test.de"; + private static final String ADMIN_PASSWORD = "AdminPass123!"; + + /** + * Minimal MT940 statement for testing. Contains one transaction + * that can be auto-matched by name/IBAN. + */ + private static final String SAMPLE_MT940 = """ + :20:STARTUM + :25:10010010/1234567890 + :28C:0 + :60F:C260101EUR1000,00 + :61:2601010101CR50,00N051NONREF + :86:116?00GUTSCHRIFT?20Mitgliedsbeitrag?21Januar 2026?32MEIER ALICE?30TESTDE00?31DE89370400440532013000 + :62F:C260101EUR1050,00 + - + """; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Bank Import Test Club"); + createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD); + adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD); + + // Create a member with IBAN for auto-matching + TenantContext.setCurrentTenant(tenantId); + Member member = createMemberDirectly(tenantId, "Alice", "Meier", + "alice-bank@test.de", LocalDate.of(1990, 5, 20)); + member.setIban("DE89370400440532013000"); + member.setIbanConsentDate(Instant.now()); + memberRepository.save(member); + memberId = member.getId(); + TenantContext.clear(); + } + + @Test + @DisplayName("Full flow: Upload MT940 → parse → confirm matches → complete") + void testFullFlow_UploadMt940_MatchConfirmComplete() { + // Step 1: Upload MT940 file + String sessionId = uploadMt940(SAMPLE_MT940, "statement_jan.mt940"); + assertThat(sessionId).isNotNull(); + + // Step 2: Get session detail — should be OPEN + ResponseEntity sessionResponse = restClient().get() + .uri("/api/v1/finance/import/sessions/" + sessionId) + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(sessionResponse.getStatusCode().value()).isEqualTo(200); + assertThat(sessionResponse.getBody()).contains(sessionId); + + // Step 3: List transactions + ResponseEntity txnResponse = restClient().get() + .uri("/api/v1/finance/import/sessions/" + sessionId + "/transactions") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(txnResponse.getStatusCode().value()).isEqualTo(200); + + // Step 4: Confirm all matched transactions + ResponseEntity confirmAllResponse = restClient().post() + .uri("/api/v1/finance/import/sessions/" + sessionId + "/confirm-all") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(confirmAllResponse.getStatusCode().value()).isEqualTo(200); + + // Step 5: Complete the session (GoBD seal) + ResponseEntity completeResponse = restClient().post() + .uri("/api/v1/finance/import/sessions/" + sessionId + "/complete") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(completeResponse.getStatusCode().value()).isEqualTo(200); + assertThat(completeResponse.getBody()).contains("COMPLETED"); + } + + @Test + @DisplayName("Duplicate file (same SHA-256 hash) rejected on second upload") + void testDuplicateUpload_SameFile_Rejected() { + // First upload — should succeed + String sessionId = uploadMt940(SAMPLE_MT940, "duplicate_test.mt940"); + assertThat(sessionId).isNotNull(); + + // Second upload of same content — should be rejected + ResponseEntity duplicateResponse = uploadMt940Raw(SAMPLE_MT940, "duplicate_test_copy.mt940"); + + assertThat(duplicateResponse.getStatusCode().value()).isIn(409, 400, 422); + } + + @Test + @DisplayName("Unmatched transactions remain in PENDING status") + void testUnmatchedTransactions_RemainPending() { + // MT940 with a transaction that won't match any member's IBAN + String unmatchedMt940 = """ + :20:STARTUM + :25:10010010/1234567890 + :28C:0 + :60F:C260101EUR1000,00 + :61:2601010101CR75,00N051NONREF + :86:116?00GUTSCHRIFT?20Unbekannte Zahlung?21Ref XYZ?32UNBEKANNT PERSON?30NOBANK00?31DE00000000000000000000 + :62F:C260101EUR1075,00 + - + """; + + String sessionId = uploadMt940(unmatchedMt940, "unmatched_test.mt940"); + assertThat(sessionId).isNotNull(); + + // Get transactions filtered by PENDING/UNMATCHED status + ResponseEntity pendingResponse = restClient().get() + .uri("/api/v1/finance/import/sessions/" + sessionId + "/transactions?status=PENDING") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(pendingResponse.getStatusCode().value()).isEqualTo(200); + // Should contain at least one transaction (the unmatched one) + assertThat(pendingResponse.getBody()).isNotNull(); + } + + @Test + @DisplayName("Completed session is immutable — cannot be modified") + void testImmutability_CompleteSessionCannotBeModified() { + // Upload and complete a session + String sessionId = uploadMt940(SAMPLE_MT940 + " ", "immutable_test.mt940"); + assertThat(sessionId).isNotNull(); + + // Complete the session + restClient().post() + .uri("/api/v1/finance/import/sessions/" + sessionId + "/complete") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + // Try to confirm-all on completed session — should fail (GoBD immutability) + ResponseEntity confirmAfterComplete = restClient().post() + .uri("/api/v1/finance/import/sessions/" + sessionId + "/confirm-all") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(confirmAfterComplete.getStatusCode().value()).isIn(400, 409, 422); + } + + // === Helper methods === + + /** + * Uploads an MT940 file and returns the session ID from the response. + */ + private String uploadMt940(String content, String filename) { + ResponseEntity response = uploadMt940Raw(content, filename); + assertThat(response.getStatusCode().value()).isEqualTo(201); + return extractId(response.getBody()); + } + + /** + * Uploads an MT940 file and returns the raw ResponseEntity for assertion. + * Uses multipart/form-data upload matching the controller's @RequestParam("file"). + */ + private ResponseEntity uploadMt940Raw(String content, String filename) { + byte[] fileBytes = content.getBytes(StandardCharsets.UTF_8); + + // Use RestClient with multipart — manual boundary construction + String boundary = "----TestBoundary" + UUID.randomUUID().toString().replace("-", ""); + String body = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n" + + content + "\r\n" + + "--" + boundary + "--\r\n"; + + return restClient().post() + .uri("/api/v1/finance/import/sessions") + .header("Authorization", "Bearer " + adminToken) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + boundary) + .body(body.getBytes(StandardCharsets.UTF_8)) + .retrieve() + .toEntity(String.class); + } + + /** + * Extracts the "id" field value from a JSON response body. + */ + private String extractId(String jsonBody) { + if (jsonBody == null) return null; + var pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\""); + var matcher = pattern.matcher(jsonBody); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/DistributionLifecycleIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/DistributionLifecycleIntegrationTest.java new file mode 100644 index 0000000..573fdb2 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/DistributionLifecycleIntegrationTest.java @@ -0,0 +1,309 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.api.dto.distribution.CreateDistributionRequest; +import de.cannamanage.api.dto.distribution.DistributionResponse; +import de.cannamanage.domain.entity.Batch; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.Strain; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.enums.BatchStatus; +import de.cannamanage.domain.enums.MemberStatus; +import de.cannamanage.service.repository.BatchRepository; +import de.cannamanage.service.repository.StrainRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test verifying the full distribution lifecycle end-to-end. + * Tests CanG §19 compliance checks (daily/monthly quotas, U21 THC limits, inactive member rejection). + */ +class DistributionLifecycleIntegrationTest extends AbstractIntegrationTest { + + @Autowired + private StrainRepository strainRepository; + + @Autowired + private BatchRepository batchRepository; + + private UUID tenantId; + private String adminToken; + private UUID memberId; + private UUID batchId; + + private static final String ADMIN_EMAIL = "dist-admin@test.de"; + private static final String ADMIN_PASSWORD = "AdminPass123!"; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Distribution Test Club"); + createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD); + adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD); + + // Create an active member (adult, born 1990) + TenantContext.setCurrentTenant(tenantId); + Member member = createMemberDirectly(tenantId, "Max", "Muster", + "max@test.de", LocalDate.of(1990, 1, 15)); + memberId = member.getId(); + + // Create a strain + batch with stock + Strain strain = new Strain(); + strain.setTenantId(tenantId); + strain.setName("Test Strain"); + strain.setThcPercentage(new BigDecimal("15.0")); + strain.setCbdPercentage(new BigDecimal("2.0")); + strain = strainRepository.save(strain); + + Batch batch = new Batch(); + batch.setTenantId(tenantId); + batch.setStrainId(strain.getId()); + batch.setQuantityGrams(new BigDecimal("500.0")); + batch.setHarvestDate(LocalDate.now().minusDays(7)); + batch.setBatchCode("BATCH-TEST-001"); + batch.setStatus(BatchStatus.AVAILABLE); + batch = batchRepository.save(batch); + batchId = batch.getId(); + + TenantContext.clear(); + } + + @Test + @DisplayName("Create distribution for member — succeeds and records distribution") + void testCreateDistribution_ValidRequest_Succeeds() { + CreateDistributionRequest request = new CreateDistributionRequest( + memberId, batchId, new BigDecimal("5.0"), "Test distribution"); + + ResponseEntity response = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(DistributionResponse.class); + + assertThat(response.getStatusCode().value()).isEqualTo(201); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().memberId()).isEqualTo(memberId); + assertThat(response.getBody().batchId()).isEqualTo(batchId); + assertThat(response.getBody().quantityGrams()).isEqualByComparingTo(new BigDecimal("5.0")); + assertThat(response.getBody().distributedAt()).isNotNull(); + } + + @Test + @DisplayName("Distribution respects daily quota (25g) — boundary test at limit") + void testCreateDistribution_DailyQuotaExceeded_Rejected() { + // First: distribute 24g (just under limit) + CreateDistributionRequest request1 = new CreateDistributionRequest( + memberId, batchId, new BigDecimal("24.0"), null); + + ResponseEntity response1 = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request1) + .retrieve() + .toEntity(DistributionResponse.class); + assertThat(response1.getStatusCode().value()).isEqualTo(201); + + // Second: distribute 1g more (should work — exactly at 25g) + CreateDistributionRequest request2 = new CreateDistributionRequest( + memberId, batchId, new BigDecimal("1.0"), null); + + ResponseEntity response2 = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request2) + .retrieve() + .toEntity(DistributionResponse.class); + assertThat(response2.getStatusCode().value()).isEqualTo(201); + + // Third: 0.01g more — exceeds daily limit of 25g + CreateDistributionRequest request3 = new CreateDistributionRequest( + memberId, batchId, new BigDecimal("0.01"), null); + + ResponseEntity response3 = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request3) + .retrieve() + .toEntity(String.class); + + assertThat(response3.getStatusCode().value()).isIn(422, 400); + } + + @Test + @DisplayName("Distribution respects monthly quota (50g) — boundary test at limit") + void testCreateDistribution_MonthlyQuotaExceeded_Rejected() { + // Distribute 25g (daily max) — first day + CreateDistributionRequest request1 = new CreateDistributionRequest( + memberId, batchId, new BigDecimal("25.0"), null); + + ResponseEntity response1 = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request1) + .retrieve() + .toEntity(DistributionResponse.class); + assertThat(response1.getStatusCode().value()).isEqualTo(201); + + // Now try to distribute 25.01g more — would exceed monthly 50g for adults + // (in reality this is the same day so daily limit triggers first at 25g, + // but the monthly check also applies) + CreateDistributionRequest request2 = new CreateDistributionRequest( + memberId, batchId, new BigDecimal("25.01"), null); + + ResponseEntity response2 = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request2) + .retrieve() + .toEntity(String.class); + + // Should be rejected (either daily or monthly limit) + assertThat(response2.getStatusCode().value()).isIn(422, 400); + } + + @Test + @DisplayName("U21 member gets lower THC restriction — high-THC strain rejected") + void testCreateDistribution_Under21HighThc_Rejected() { + // Create an under-21 member (born 5 years ago = 5 years old, but set under21=true) + TenantContext.setCurrentTenant(tenantId); + Member youngMember = new Member(); + youngMember.setTenantId(tenantId); + youngMember.setClubId(tenantId); + youngMember.setFirstName("Jung"); + youngMember.setLastName("Mitglied"); + youngMember.setEmail("jung@test.de"); + youngMember.setDateOfBirth(LocalDate.now().minusYears(19)); + youngMember.setMembershipDate(LocalDate.now()); + youngMember.setMembershipNumber("M-U21-001"); + youngMember.setUnder21(true); + youngMember.setStatus(MemberStatus.ACTIVE); + youngMember = memberRepository.save(youngMember); + + // Create a strain with THC > 10% (the U21 limit) + Strain highThcStrain = new Strain(); + highThcStrain.setTenantId(tenantId); + highThcStrain.setName("High THC Strain"); + highThcStrain.setThcPercentage(new BigDecimal("15.0")); + highThcStrain.setCbdPercentage(new BigDecimal("1.0")); + highThcStrain = strainRepository.save(highThcStrain); + + Batch highThcBatch = new Batch(); + highThcBatch.setTenantId(tenantId); + highThcBatch.setStrainId(highThcStrain.getId()); + highThcBatch.setQuantityGrams(new BigDecimal("100.0")); + highThcBatch.setHarvestDate(LocalDate.now().minusDays(3)); + highThcBatch.setBatchCode("BATCH-HIGH-THC-001"); + highThcBatch.setStatus(BatchStatus.AVAILABLE); + highThcBatch = batchRepository.save(highThcBatch); + TenantContext.clear(); + + // Try to distribute high-THC strain to U21 member + CreateDistributionRequest request = new CreateDistributionRequest( + youngMember.getId(), highThcBatch.getId(), new BigDecimal("3.0"), null); + + ResponseEntity response = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isIn(422, 400); + assertThat(response.getBody()).containsIgnoringCase("THC"); + } + + @Test + @DisplayName("Distribution to inactive member is rejected") + void testCreateDistribution_InactiveMember_Rejected() { + // Create an inactive member + TenantContext.setCurrentTenant(tenantId); + Member inactiveMember = new Member(); + inactiveMember.setTenantId(tenantId); + inactiveMember.setClubId(tenantId); + inactiveMember.setFirstName("Inaktiv"); + inactiveMember.setLastName("Mitglied"); + inactiveMember.setEmail("inaktiv@test.de"); + inactiveMember.setDateOfBirth(LocalDate.of(1985, 6, 1)); + inactiveMember.setMembershipDate(LocalDate.now()); + inactiveMember.setMembershipNumber("M-INACTIVE-001"); + inactiveMember.setUnder21(false); + inactiveMember.setStatus(MemberStatus.SUSPENDED); + inactiveMember = memberRepository.save(inactiveMember); + TenantContext.clear(); + + CreateDistributionRequest request = new CreateDistributionRequest( + inactiveMember.getId(), batchId, new BigDecimal("5.0"), null); + + ResponseEntity response = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isIn(422, 400); + } + + @Test + @DisplayName("Batch distribution — multiple distributions in sequence succeed within limits") + void testCreateDistribution_BatchMultipleMembers_Succeeds() { + // Create a second member + TenantContext.setCurrentTenant(tenantId); + Member member2 = createMemberDirectly(tenantId, "Anna", "Beispiel", + "anna@test.de", LocalDate.of(1992, 3, 20)); + TenantContext.clear(); + + // Distribute to first member + CreateDistributionRequest request1 = new CreateDistributionRequest( + memberId, batchId, new BigDecimal("10.0"), null); + + ResponseEntity response1 = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request1) + .retrieve() + .toEntity(DistributionResponse.class); + assertThat(response1.getStatusCode().value()).isEqualTo(201); + + // Distribute to second member + CreateDistributionRequest request2 = new CreateDistributionRequest( + member2.getId(), batchId, new BigDecimal("15.0"), null); + + ResponseEntity response2 = restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request2) + .retrieve() + .toEntity(DistributionResponse.class); + assertThat(response2.getStatusCode().value()).isEqualTo(201); + + // Verify both distributions are listed + ResponseEntity listResponse = restClient().get() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + assertThat(listResponse.getStatusCode().value()).isEqualTo(200); + assertThat(listResponse.getBody()).contains(memberId.toString()); + assertThat(listResponse.getBody()).contains(member2.getId().toString()); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/MigrationIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/MigrationIntegrationTest.java new file mode 100644 index 0000000..5e540c6 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/MigrationIntegrationTest.java @@ -0,0 +1,88 @@ +package de.cannamanage.api.integration; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Integration test verifying Flyway migrations apply cleanly to a fresh PostgreSQL database. + * Validates schema integrity, idempotency, and expected table existence. + */ +class MigrationIntegrationTest extends AbstractIntegrationTest { + + @Autowired + private DataSource dataSource; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("All Flyway migrations (V1–V34) apply cleanly on fresh database") + void testFlywayMigration_AllMigrationsApply_NoErrors() { + // The application context starts with Flyway auto-migration enabled, + // so if we reach this point, all migrations applied successfully. + // Verify via flyway_schema_history table. + Integer migrationCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM flyway_schema_history WHERE success = true", + Integer.class); + + assertThat(migrationCount).isGreaterThanOrEqualTo(34); + } + + @Test + @DisplayName("Running Flyway migrate again is idempotent (no new migrations applied)") + void testFlywayMigration_Idempotent_SecondRunNoOp() { + // Grab a Flyway instance pointing at the same datasource + Flyway flyway = Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .load(); + + // Running migrate again should be a no-op (0 new migrations) + assertThatNoException().isThrownBy(flyway::migrate); + + // Verify no pending migrations + var info = flyway.info(); + assertThat(info.pending()).isEmpty(); + } + + @Test + @DisplayName("Schema contains all expected core tables after migration") + void testFlywayMigration_ExpectedTablesExist() { + // Spot-check critical tables from various migrations + List expectedTables = List.of( + "users", + "members", + "distributions", + "clubs", + "audit_events", + "bank_import_sessions", + "assemblies", + "forum_topics", + "batches", + "strains", + "monthly_quotas", + "bank_transactions", + "assembly_votes", + "documents" + ); + + for (String table : expectedTables) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = 'public' AND table_name = ?", + Integer.class, table); + assertThat(count) + .as("Table '%s' should exist in the schema", table) + .isEqualTo(1); + } + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/SecurityConfigIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/SecurityConfigIntegrationTest.java new file mode 100644 index 0000000..1a39c6d --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/SecurityConfigIntegrationTest.java @@ -0,0 +1,83 @@ +package de.cannamanage.api.integration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test verifying Spring Security filter chain behavior end-to-end. + * Tests public endpoints, JWT-protected endpoints, and CORS configuration. + */ +class SecurityConfigIntegrationTest extends AbstractIntegrationTest { + + private UUID tenantId; + private static final String ADMIN_EMAIL = "sec-admin@test.de"; + private static final String ADMIN_PASSWORD = "SecurePass123!"; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Security Config Test Club"); + createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD); + } + + @Test + @DisplayName("Unauthenticated request to public endpoint (actuator/health) returns 200") + void testUnauthenticated_PublicEndpoint_Allowed() { + ResponseEntity response = restClient().get() + .uri("/actuator/health") + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("Unauthenticated request to protected endpoint returns 401/403") + void testUnauthenticated_ProtectedEndpoint_Returns401() { + ResponseEntity response = restClient().get() + .uri("/api/v1/members") + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isIn(401, 403); + } + + @Test + @DisplayName("Authenticated request to protected endpoint returns 200") + void testAuthenticated_ProtectedEndpoint_Returns200() { + String token = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD); + + ResponseEntity response = restClient().get() + .uri("/api/v1/members") + .header("Authorization", "Bearer " + token) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("CORS headers present on OPTIONS preflight request") + void testCorsHeaders_PresentOnOptions() { + ResponseEntity response = restClient().options() + .uri("/api/v1/members") + .header(HttpHeaders.ORIGIN, "http://localhost:3000") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Authorization") + .retrieve() + .toEntity(String.class); + + // Should not be blocked — allowed origin + assertThat(response.getStatusCode().value()).isIn(200, 204); + assertThat(response.getHeaders().getAccessControlAllowOrigin()) + .isEqualTo("http://localhost:3000"); + assertThat(response.getHeaders().getAccessControlAllowMethods()) + .isNotEmpty(); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/security/JwtServiceTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/security/JwtServiceTest.java new file mode 100644 index 0000000..4d6f435 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/security/JwtServiceTest.java @@ -0,0 +1,219 @@ +package de.cannamanage.api.security; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.lang.reflect.Field; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link JwtService} covering token generation, parsing, + * claim extraction, and security attack vectors. + */ +class JwtServiceTest { + + private JwtService jwtService; + + // A valid base64-encoded 256-bit secret for testing + private static final String TEST_SECRET = Base64.getEncoder().encodeToString( + "ThisIsA32ByteSecretKeyForTests!!".getBytes()); + + private UUID userId; + private UUID tenantId; + + @BeforeEach + void setUp() throws Exception { + jwtService = new JwtService(); + setField(jwtService, "secretKey", TEST_SECRET); + setField(jwtService, "accessTokenExpiry", 3600L); + setField(jwtService, "refreshTokenExpiry", 2592000L); + + userId = UUID.randomUUID(); + tenantId = UUID.randomUUID(); + } + + @Test + void testGenerateAccessToken_validClaims_containsExpectedFields() { + String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "test@example.com"); + + assertThat(token).isNotNull().isNotBlank(); + assertThat(jwtService.extractSubject(token)).isEqualTo(userId.toString()); + assertThat(jwtService.extractTenantId(token)).isEqualTo(tenantId); + assertThat(jwtService.extractRole(token)).isEqualTo("ADMIN"); + assertThat(jwtService.extractEmail(token)).isEqualTo("test@example.com"); + assertThat(jwtService.extractJti(token)).isNotNull().isNotBlank(); + } + + @Test + void testExtractUserId_validToken_returnsCorrectUuid() { + String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "user@club.de"); + + UUID extracted = jwtService.extractUserId(token); + assertThat(extracted).isEqualTo(userId); + } + + @Test + void testExtractRole_staffToken_returnsStaff() { + String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de", + List.of("MANAGE_MEMBERS", "VIEW_FINANCES")); + + assertThat(jwtService.extractRole(token)).isEqualTo("STAFF"); + } + + @Test + void testExtractPermissions_staffToken_returnsPermissionsList() { + List permissions = List.of("MANAGE_MEMBERS", "VIEW_FINANCES", "MANAGE_GROW"); + String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de", permissions); + + List extracted = jwtService.extractPermissions(token); + assertThat(extracted).containsExactlyInAnyOrderElementsOf(permissions); + } + + @Test + void testExtractPermissions_nonStaffToken_returnsEmptyList() { + String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "admin@club.de"); + + List extracted = jwtService.extractPermissions(token); + assertThat(extracted).isEmpty(); + } + + @Test + void testExtractTenantId_validToken_returnsCorrectTenantUuid() { + String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "m@club.de"); + + UUID extracted = jwtService.extractTenantId(token); + assertThat(extracted).isEqualTo(tenantId); + } + + @Test + void testIsTokenValid_freshToken_returnsTrue() { + String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com"); + + assertThat(jwtService.isTokenValid(token)).isTrue(); + } + + @Test + void testIsTokenValid_expiredToken_returnsFalse() throws Exception { + // Create a service with 0 second expiry + JwtService shortLived = new JwtService(); + setField(shortLived, "secretKey", TEST_SECRET); + setField(shortLived, "accessTokenExpiry", 0L); + setField(shortLived, "refreshTokenExpiry", 0L); + + String token = shortLived.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com"); + + // Token with 0-second expiry is immediately expired + Thread.sleep(50); + assertThat(jwtService.isTokenValid(token)).isFalse(); + } + + @Test + void testIsTokenValid_invalidSignature_returnsFalse() { + // Generate token with a different key + String differentSecret = Base64.getEncoder().encodeToString( + "ACompletelyDifferentKey1234567!!".getBytes()); + SecretKey wrongKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(differentSecret)); + + String forgedToken = Jwts.builder() + .subject(userId.toString()) + .claim("tenant_id", tenantId.toString()) + .claim("role", "ADMIN") + .issuedAt(Date.from(Instant.now())) + .expiration(Date.from(Instant.now().plusSeconds(3600))) + .signWith(wrongKey) + .compact(); + + assertThat(jwtService.isTokenValid(forgedToken)).isFalse(); + } + + @Test + void testIsTokenValid_malformedToken_returnsFalse() { + assertThat(jwtService.isTokenValid("not.a.valid.jwt.token")).isFalse(); + } + + @Test + void testIsTokenValid_nullToken_returnsFalse() { + assertThat(jwtService.isTokenValid(null)).isFalse(); + } + + @Test + void testIsTokenValid_emptyToken_returnsFalse() { + assertThat(jwtService.isTokenValid("")).isFalse(); + } + + @Test + void testIsTokenValid_tamperedPayload_returnsFalse() { + String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "m@club.de"); + // Tamper with the payload (second segment) by modifying a character + String[] parts = token.split("\\."); + // Flip a character in the payload + byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]); + payloadBytes[5] = (byte) (payloadBytes[5] ^ 0xFF); + parts[1] = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadBytes); + String tampered = String.join(".", parts); + + assertThat(jwtService.isTokenValid(tampered)).isFalse(); + } + + @Test + void testGenerateRefreshToken_containsRefreshType() { + String token = jwtService.generateRefreshToken(userId, tenantId); + + assertThat(token).isNotNull(); + assertThat(jwtService.extractSubject(token)).isEqualTo(userId.toString()); + assertThat(jwtService.extractTenantId(token)).isEqualTo(tenantId); + assertThat(jwtService.extractJti(token)).isNotNull(); + } + + @Test + void testValidateSecret_tooShort_throwsIllegalState() throws Exception { + JwtService invalid = new JwtService(); + setField(invalid, "secretKey", "short"); + setField(invalid, "accessTokenExpiry", 3600L); + setField(invalid, "refreshTokenExpiry", 2592000L); + + assertThatThrownBy(invalid::validateSecret) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("JWT secret is not configured"); + } + + @Test + void testValidateSecret_defaultPlaceholder_throwsIllegalState() throws Exception { + JwtService invalid = new JwtService(); + setField(invalid, "secretKey", JwtService.UNCONFIGURED_SECRET_MARKER); + setField(invalid, "accessTokenExpiry", 3600L); + setField(invalid, "refreshTokenExpiry", 2592000L); + + assertThatThrownBy(invalid::validateSecret) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("JWT secret is not configured"); + } + + @Test + void testExtractExpirationInstant_returnsNonNullFutureInstant() { + String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com"); + + Instant expiration = jwtService.extractExpirationInstant(token); + assertThat(expiration).isAfter(Instant.now()); + } + + // --- Utility --- + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/security/LoginRateLimiterTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/security/LoginRateLimiterTest.java new file mode 100644 index 0000000..79f5926 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/security/LoginRateLimiterTest.java @@ -0,0 +1,120 @@ +package de.cannamanage.api.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link LoginRateLimiter} covering rate limiting logic, + * IP isolation, boundary conditions, and counter reset. + */ +class LoginRateLimiterTest { + + private LoginRateLimiter rateLimiter; + + @BeforeEach + void setUp() { + rateLimiter = new LoginRateLimiter(); + } + + @Test + void testTryAcquire_firstAttempt_allowed() { + boolean result = rateLimiter.tryAcquire("192.168.1.1"); + + assertThat(result).isTrue(); + } + + @Test + void testTryAcquire_withinLimit_allAllowed() { + String ip = "10.0.0.1"; + + // Attempts 1 through 4 (under the limit of 5) should all be allowed + for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW - 1; i++) { + assertThat(rateLimiter.tryAcquire(ip)).isTrue(); + } + } + + @Test + void testTryAcquire_exactlyAtLimit_stillAllowed() { + String ip = "10.0.0.2"; + + // Use up exactly MAX_ATTEMPTS_PER_WINDOW attempts + for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) { + assertThat(rateLimiter.tryAcquire(ip)).isTrue(); + } + } + + @Test + void testTryAcquire_oneOverLimit_blocked() { + String ip = "10.0.0.3"; + + // Exhaust the quota + for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) { + rateLimiter.tryAcquire(ip); + } + + // Next attempt should be blocked + assertThat(rateLimiter.tryAcquire(ip)).isFalse(); + } + + @Test + void testTryAcquire_differentIps_trackedIndependently() { + String ip1 = "192.168.1.100"; + String ip2 = "192.168.1.200"; + + // Exhaust quota for ip1 + for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) { + rateLimiter.tryAcquire(ip1); + } + assertThat(rateLimiter.tryAcquire(ip1)).isFalse(); + + // ip2 should still be allowed + assertThat(rateLimiter.tryAcquire(ip2)).isTrue(); + } + + @Test + void testResetCounters_afterReset_attemptsAllowedAgain() { + String ip = "10.0.0.4"; + + // Exhaust the quota + for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) { + rateLimiter.tryAcquire(ip); + } + assertThat(rateLimiter.tryAcquire(ip)).isFalse(); + + // Reset counters (simulating the scheduled task) + rateLimiter.resetCounters(); + + // Should be allowed again + assertThat(rateLimiter.tryAcquire(ip)).isTrue(); + } + + @Test + void testTryAcquire_ipv6Address_handledCorrectly() { + String ipv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + + assertThat(rateLimiter.tryAcquire(ipv6)).isTrue(); + + // Exhaust quota for IPv6 + for (int i = 1; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) { + rateLimiter.tryAcquire(ipv6); + } + // Should still pass at limit + assertThat(rateLimiter.tryAcquire(ipv6)).isFalse(); + } + + @Test + void testTryAcquire_nullOrBlankIp_treatedAsUnknown() { + // null and blank IPs should not throw — they get mapped to "unknown" + assertThat(rateLimiter.tryAcquire(null)).isTrue(); + assertThat(rateLimiter.tryAcquire("")).isTrue(); + assertThat(rateLimiter.tryAcquire(" ")).isTrue(); + + // All null/blank share the "unknown" bucket — 3 attempts above, 2 more allowed + assertThat(rateLimiter.tryAcquire(null)).isTrue(); + assertThat(rateLimiter.tryAcquire("")).isTrue(); + // 6th attempt (over limit of 5) should be blocked + assertThat(rateLimiter.tryAcquire(null)).isFalse(); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/security/TenantFilterAspectTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/security/TenantFilterAspectTest.java new file mode 100644 index 0000000..165ea7a --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/security/TenantFilterAspectTest.java @@ -0,0 +1,165 @@ +package de.cannamanage.api.security; + +import de.cannamanage.domain.entity.TenantContext; +import jakarta.persistence.EntityManager; +import org.hibernate.Filter; +import org.hibernate.Session; +import org.junit.jupiter.api.AfterEach; +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.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.UUID; + +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link TenantFilterAspect} verifying that the Hibernate + * tenant filter is correctly activated/skipped based on TenantContext state. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TenantFilterAspectTest { + + @Mock + private EntityManager entityManager; + + @Mock + private Session session; + + @Mock + private Filter filter; + + private TenantFilterAspect aspect; + + @BeforeEach + void setUp() { + // Use doReturn to handle the generic unwrap() method properly + doReturn(session).when(entityManager).unwrap(Session.class); + when(session.enableFilter("tenantFilter")).thenReturn(filter); + + aspect = new TenantFilterAspect(entityManager); + } + + @AfterEach + void tearDown() { + TenantContext.clear(); + } + + @Test + void testActivateTenantFilter_withTenantSet_enablesFilter() { + UUID tenantId = UUID.randomUUID(); + TenantContext.setCurrentTenant(tenantId); + + aspect.activateTenantFilter(); + + verify(session).enableFilter("tenantFilter"); + verify(filter).setParameter("tenantId", tenantId); + } + + @Test + void testActivateTenantFilter_differentTenants_getDifferentFilterValues() { + UUID tenant1 = UUID.randomUUID(); + UUID tenant2 = UUID.randomUUID(); + + // First tenant + TenantContext.setCurrentTenant(tenant1); + aspect.activateTenantFilter(); + verify(filter).setParameter("tenantId", tenant1); + + // Second tenant + TenantContext.setCurrentTenant(tenant2); + aspect.activateTenantFilter(); + verify(filter).setParameter("tenantId", tenant2); + } + + @Test + void testActivateTenantFilter_noTenantInContext_filterNotActivated() { + // TenantContext is empty (no tenant set) + aspect.activateTenantFilter(); + + verify(entityManager, never()).unwrap(Session.class); + } + + @Test + void testActivateTenantFilter_tenantCleared_filterNotActivated() { + // Set and then clear tenant + TenantContext.setCurrentTenant(UUID.randomUUID()); + TenantContext.clear(); + + aspect.activateTenantFilter(); + + verify(entityManager, never()).unwrap(Session.class); + } + + @Test + void testActivateTenantFilter_multipleCallsSameTenant_enablesFilterEachTime() { + UUID tenantId = UUID.randomUUID(); + TenantContext.setCurrentTenant(tenantId); + + // Aspect is called per-repository-method; it should enable filter every time + aspect.activateTenantFilter(); + aspect.activateTenantFilter(); + aspect.activateTenantFilter(); + + verify(session, times(3)).enableFilter("tenantFilter"); + verify(filter, times(3)).setParameter("tenantId", tenantId); + } + + @Test + void testActivateTenantFilter_concurrentRequests_isolatedByThread() throws Exception { + UUID tenant1 = UUID.randomUUID(); + UUID tenant2 = UUID.randomUUID(); + + // Simulate concurrent requests on different threads + Thread thread1 = new Thread(() -> { + TenantContext.setCurrentTenant(tenant1); + aspect.activateTenantFilter(); + TenantContext.clear(); + }); + + Thread thread2 = new Thread(() -> { + TenantContext.setCurrentTenant(tenant2); + aspect.activateTenantFilter(); + TenantContext.clear(); + }); + + thread1.start(); + thread2.start(); + thread1.join(); + thread2.join(); + + // Both tenants should have been set (order not guaranteed) + verify(filter).setParameter("tenantId", tenant1); + verify(filter).setParameter("tenantId", tenant2); + } + + @Test + void testTenantContext_clear_preventsLeakage() { + UUID tenantId = UUID.randomUUID(); + TenantContext.setCurrentTenant(tenantId); + TenantContext.clear(); + + // After clear, no tenant should be active + aspect.activateTenantFilter(); + + verify(entityManager, never()).unwrap(Session.class); + } + + @Test + void testActivateTenantFilter_adminCannotAccessOtherClub_filterUsesContextTenant() { + // Even for admin role, the filter is activated with whatever tenant is in context. + // Cross-tenant access is prevented by TenantContext being set per-request. + UUID adminTenant = UUID.randomUUID(); + TenantContext.setCurrentTenant(adminTenant); + + aspect.activateTenantFilter(); + + // Filter is always set to the context tenant — no bypass possible + verify(filter).setParameter("tenantId", adminTenant); + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/AssemblyServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/AssemblyServiceTest.java new file mode 100644 index 0000000..9568371 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/AssemblyServiceTest.java @@ -0,0 +1,371 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.*; +import de.cannamanage.domain.enums.*; +import de.cannamanage.service.repository.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.Instant; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for AssemblyService — Mitgliederversammlung lifecycle. + */ +class AssemblyServiceTest extends AbstractServiceTest { + + @Mock private AssemblyRepository assemblyRepository; + @Mock private AssemblyAgendaItemRepository agendaItemRepository; + @Mock private AssemblyAttendeeRepository attendeeRepository; + @Mock private AssemblyVoteRepository voteRepository; + @Mock private AssemblyVoteRecordRepository voteRecordRepository; + @Mock private MemberRepository memberRepository; + @Mock private NotificationService notificationService; + @Mock private AuditService auditService; + @Mock private AssemblyProtocolService assemblyProtocolService; + @Mock private DocumentService documentArchiveService; + + @InjectMocks + private AssemblyService assemblyService; + + private Assembly assembly; + private static final UUID ASSEMBLY_ID = UUID.fromString("11112222-3333-4444-5555-666677778888"); + + @BeforeEach + void setUp() { + assembly = new Assembly(); + assembly.setId(ASSEMBLY_ID); + assembly.setClubId(TEST_CLUB_ID); + assembly.setTitle("Ordentliche MV 2026"); + assembly.setAssemblyType(AssemblyType.ORDINARY); + assembly.setScheduledAt(TEST_INSTANT.plusSeconds(86400)); + assembly.setLocation("Vereinsheim"); + assembly.setQuorumRequired(10); + assembly.setCreatedBy(TEST_USER_ID); + assembly.setStatus(AssemblyStatus.PLANNED); + } + + // === Create Assembly === + + @Test + void testCreateAssembly_ordinary_success() { + when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> { + Assembly a = inv.getArgument(0); + a.setId(ASSEMBLY_ID); + return a; + }); + + Assembly result = assemblyService.createAssembly( + TEST_CLUB_ID, "Ordentliche MV 2026", AssemblyType.ORDINARY, + TEST_INSTANT.plusSeconds(86400), "Vereinsheim", 10, TEST_USER_ID, null); + + assertThat(result.getStatus()).isEqualTo(AssemblyStatus.PLANNED); + assertThat(result.getAssemblyType()).isEqualTo(AssemblyType.ORDINARY); + assertThat(result.getTitle()).isEqualTo("Ordentliche MV 2026"); + verify(assemblyRepository).save(any(Assembly.class)); + verify(auditService).log(any(AuditEventType.class), any(UUID.class), any(String.class), any(String.class)); + } + + @Test + void testCreateAssembly_extraordinary_success() { + when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> { + Assembly a = inv.getArgument(0); + a.setId(ASSEMBLY_ID); + return a; + }); + + Assembly result = assemblyService.createAssembly( + TEST_CLUB_ID, "Außerordentliche MV", AssemblyType.EXTRAORDINARY, + TEST_INSTANT.plusSeconds(86400), "Online", 5, TEST_USER_ID, null); + + assertThat(result.getAssemblyType()).isEqualTo(AssemblyType.EXTRAORDINARY); + } + + @Test + void testCreateAssembly_withAgendaItems_createsItems() { + when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> { + Assembly a = inv.getArgument(0); + a.setId(ASSEMBLY_ID); + return a; + }); + when(agendaItemRepository.save(any(AssemblyAgendaItem.class))).thenAnswer(inv -> inv.getArgument(0)); + + var items = List.of( + new AssemblyService.AgendaItemInput("TOP 1: Begrüßung", "Eröffnung", AgendaItemType.INFORMATION), + new AssemblyService.AgendaItemInput("TOP 2: Satzungsänderung", "§5 anpassen", AgendaItemType.VOTE) + ); + + assemblyService.createAssembly(TEST_CLUB_ID, "MV", AssemblyType.ORDINARY, + TEST_INSTANT.plusSeconds(86400), "Ort", 10, TEST_USER_ID, items); + + verify(agendaItemRepository, times(2)).save(any(AssemblyAgendaItem.class)); + } + + // === Start / Complete Assembly === + + @Test + void testStartAssembly_fromPlanned_success() { + assembly.setStatus(AssemblyStatus.PLANNED); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0)); + + Assembly result = assemblyService.startAssembly(ASSEMBLY_ID, TEST_USER_ID); + + assertThat(result.getStatus()).isEqualTo(AssemblyStatus.IN_PROGRESS); + assertThat(result.getOpenedAt()).isNotNull(); + } + + @Test + void testStartAssembly_fromInvited_success() { + assembly.setStatus(AssemblyStatus.INVITED); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0)); + + Assembly result = assemblyService.startAssembly(ASSEMBLY_ID, TEST_USER_ID); + + assertThat(result.getStatus()).isEqualTo(AssemblyStatus.IN_PROGRESS); + } + + @Test + void testStartAssembly_fromCompleted_throwsException() { + assembly.setStatus(AssemblyStatus.COMPLETED); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + + assertThatThrownBy(() -> assemblyService.startAssembly(ASSEMBLY_ID, TEST_USER_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot start assembly in status"); + } + + @Test + void testCompleteAssembly_inProgress_success() { + assembly.setStatus(AssemblyStatus.IN_PROGRESS); + assembly.setTenantId(TEST_CLUB_ID); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0)); + when(assemblyProtocolService.generateProtocol(ASSEMBLY_ID)).thenReturn(new byte[]{1, 2, 3}); + when(documentArchiveService.archiveProtocol(any(), any(), any(), any())).thenReturn(UUID.randomUUID()); + + Assembly result = assemblyService.completeAssembly(ASSEMBLY_ID, TEST_USER_ID); + + assertThat(result.getStatus()).isEqualTo(AssemblyStatus.COMPLETED); + assertThat(result.getClosedAt()).isNotNull(); + } + + @Test + void testCompleteAssembly_notInProgress_throwsException() { + assembly.setStatus(AssemblyStatus.PLANNED); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + + assertThatThrownBy(() -> assemblyService.completeAssembly(ASSEMBLY_ID, TEST_USER_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot complete assembly in status"); + } + + // === Cancel Assembly === + + @Test + void testCancelAssembly_success() { + assembly.setInvitationSentAt(Instant.now()); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0)); + + Assembly result = assemblyService.cancelAssembly(ASSEMBLY_ID, TEST_USER_ID); + + assertThat(result.getStatus()).isEqualTo(AssemblyStatus.CANCELLED); + verify(notificationService).sendToAllMembers(any(), any(), any(), any()); + } + + @Test + void testCancelAssembly_noInvitationsSent_noNotification() { + assembly.setInvitationSentAt(null); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + when(assemblyRepository.save(any(Assembly.class))).thenAnswer(inv -> inv.getArgument(0)); + + assemblyService.cancelAssembly(ASSEMBLY_ID, TEST_USER_ID); + + verify(notificationService, never()).sendToAllMembers(any(), any(), any(), any()); + } + + // === Voting — VoteType scenarios === + + @Test + void testCloseVote_simpleMajority_accepted() { + AssemblyVote vote = createVote(VoteType.SIMPLE_MAJORITY, 6, 4, 2); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0)); + + AssemblyVote result = assemblyService.closeVote(vote.getId()); + + assertThat(result.getResult()).isEqualTo(VoteResult.ACCEPTED); + } + + @Test + void testCloseVote_simpleMajority_rejected() { + AssemblyVote vote = createVote(VoteType.SIMPLE_MAJORITY, 4, 6, 2); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0)); + + AssemblyVote result = assemblyService.closeVote(vote.getId()); + + assertThat(result.getResult()).isEqualTo(VoteResult.REJECTED); + } + + @Test + void testCloseVote_twoThirds_accepted() { + AssemblyVote vote = createVote(VoteType.TWO_THIRDS, 8, 4, 0); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0)); + + AssemblyVote result = assemblyService.closeVote(vote.getId()); + + assertThat(result.getResult()).isEqualTo(VoteResult.ACCEPTED); + } + + @Test + void testCloseVote_twoThirds_rejected() { + AssemblyVote vote = createVote(VoteType.TWO_THIRDS, 7, 5, 0); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0)); + + AssemblyVote result = assemblyService.closeVote(vote.getId()); + + assertThat(result.getResult()).isEqualTo(VoteResult.REJECTED); + } + + @Test + void testCloseVote_unanimous_accepted() { + AssemblyVote vote = createVote(VoteType.UNANIMOUS, 10, 0, 3); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0)); + + AssemblyVote result = assemblyService.closeVote(vote.getId()); + + assertThat(result.getResult()).isEqualTo(VoteResult.ACCEPTED); + } + + @Test + void testCloseVote_unanimous_rejected() { + AssemblyVote vote = createVote(VoteType.UNANIMOUS, 9, 1, 2); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0)); + + AssemblyVote result = assemblyService.closeVote(vote.getId()); + + assertThat(result.getResult()).isEqualTo(VoteResult.REJECTED); + } + + // === Quorum boundary === + + @Test + void testCalculateQuorum_exactlyAtQuorum_met() { + assembly.setQuorumRequired(5); + assembly.setTenantId(TEST_CLUB_ID); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + when(attendeeRepository.countByAssemblyId(ASSEMBLY_ID)).thenReturn(5L); + when(memberRepository.countByTenantIdAndStatus(TEST_CLUB_ID, MemberStatus.ACTIVE)).thenReturn(20L); + + AssemblyService.QuorumInfo info = assemblyService.calculateQuorum(ASSEMBLY_ID); + + assertThat(info.quorumMet()).isTrue(); + assertThat(info.attendees()).isEqualTo(5); + assertThat(info.required()).isEqualTo(5); + } + + @Test + void testCalculateQuorum_oneBelowQuorum_notMet() { + assembly.setQuorumRequired(5); + assembly.setTenantId(TEST_CLUB_ID); + when(assemblyRepository.findById(ASSEMBLY_ID)).thenReturn(Optional.of(assembly)); + when(attendeeRepository.countByAssemblyId(ASSEMBLY_ID)).thenReturn(4L); + when(memberRepository.countByTenantIdAndStatus(TEST_CLUB_ID, MemberStatus.ACTIVE)).thenReturn(20L); + + AssemblyService.QuorumInfo info = assemblyService.calculateQuorum(ASSEMBLY_ID); + + assertThat(info.quorumMet()).isFalse(); + assertThat(info.attendees()).isEqualTo(4); + } + + // === Abstention handling === + + @Test + void testCloseVote_abstentionsNotCountedTowardMajority() { + // 3 yes, 2 no, 10 abstain — abstentions don't count: 3/5 = 60% → ACCEPTED + AssemblyVote vote = createVote(VoteType.SIMPLE_MAJORITY, 3, 2, 10); + when(voteRepository.findById(any())).thenReturn(Optional.of(vote)); + when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0)); + + AssemblyVote result = assemblyService.closeVote(vote.getId()); + + assertThat(result.getResult()).isEqualTo(VoteResult.ACCEPTED); + } + + // === Cast Vote === + + @Test + void testCastVote_success() { + AssemblyVote vote = new AssemblyVote(); + vote.setId(UUID.randomUUID()); + vote.setAssemblyId(ASSEMBLY_ID); + vote.setTitle("Satzungsänderung"); + vote.setVoteType(VoteType.SIMPLE_MAJORITY); + vote.setYesCount(0); + vote.setNoCount(0); + vote.setAbstainCount(0); + + when(voteRepository.findById(vote.getId())).thenReturn(Optional.of(vote)); + when(voteRecordRepository.existsByVoteIdAndMemberId(vote.getId(), TEST_MEMBER_ID)).thenReturn(false); + when(voteRecordRepository.save(any(AssemblyVoteRecord.class))).thenAnswer(inv -> inv.getArgument(0)); + when(voteRepository.save(any(AssemblyVote.class))).thenAnswer(inv -> inv.getArgument(0)); + + AssemblyVote result = assemblyService.castVote(vote.getId(), TEST_MEMBER_ID, VoteDecision.YES, TEST_USER_ID); + + assertThat(result.getYesCount()).isEqualTo(1); + verify(voteRecordRepository).save(any(AssemblyVoteRecord.class)); + } + + @Test + void testCastVote_alreadyVoted_throwsException() { + UUID voteId = UUID.randomUUID(); + when(voteRecordRepository.existsByVoteIdAndMemberId(voteId, TEST_MEMBER_ID)).thenReturn(true); + + assertThatThrownBy(() -> assemblyService.castVote(voteId, TEST_MEMBER_ID, VoteDecision.YES, TEST_USER_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already voted"); + } + + @Test + void testCastVote_voteAlreadyClosed_throwsException() { + AssemblyVote vote = new AssemblyVote(); + vote.setId(UUID.randomUUID()); + vote.setResult(VoteResult.ACCEPTED); // already closed + + when(voteRecordRepository.existsByVoteIdAndMemberId(vote.getId(), TEST_MEMBER_ID)).thenReturn(false); + when(voteRepository.findById(vote.getId())).thenReturn(Optional.of(vote)); + + assertThatThrownBy(() -> assemblyService.castVote(vote.getId(), TEST_MEMBER_ID, VoteDecision.NO, TEST_USER_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already closed"); + } + + // === Helper === + + private AssemblyVote createVote(VoteType type, int yes, int no, int abstain) { + AssemblyVote vote = new AssemblyVote(); + vote.setId(UUID.randomUUID()); + vote.setAssemblyId(ASSEMBLY_ID); + vote.setTitle("Abstimmung"); + vote.setVoteType(type); + vote.setYesCount(yes); + vote.setNoCount(no); + vote.setAbstainCount(abstain); + vote.setResult(null); // not yet closed + return vote; + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java new file mode 100644 index 0000000..203bb99 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java @@ -0,0 +1,285 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.Document; +import de.cannamanage.domain.enums.AuditEventType; +import de.cannamanage.domain.enums.DocumentAccessLevel; +import de.cannamanage.domain.enums.DocumentCategory; +import de.cannamanage.service.repository.DocumentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link DocumentService} covering upload validation, + * filename sanitization (path traversal prevention), and tenant checks. + * Filesystem operations are mocked via Mockito static mocking. + */ +@ExtendWith(MockitoExtension.class) +class DocumentServiceTest { + + @Mock + private DocumentRepository documentRepository; + + @Mock + private AuditService auditService; + + @InjectMocks + private DocumentService documentService; + + private UUID clubId; + private UUID uploadedBy; + + @BeforeEach + void setUp() { + clubId = UUID.randomUUID(); + uploadedBy = UUID.randomUUID(); + } + + @Test + void testUploadDocument_validFile_savesSuccessfully() throws IOException { + MultipartFile file = mockValidFile("report.pdf", "application/pdf", 1024); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null); + filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null); + + Document result = documentService.uploadDocument( + clubId, "Test Report", DocumentCategory.PROTOKOLL, + DocumentAccessLevel.ALL_MEMBERS, "description", file, uploadedBy); + + assertThat(result).isNotNull(); + assertThat(result.getTitle()).isEqualTo("Test Report"); + assertThat(result.getClubId()).isEqualTo(clubId); + assertThat(result.getFilename()).isEqualTo("report.pdf"); + verify(documentRepository).save(any(Document.class)); + verify(auditService).log(eq(AuditEventType.DOCUMENT_UPLOADED), eq(uploadedBy), eq(clubId), anyString()); + } + } + + @Test + void testUploadDocument_pathTraversal_sanitizedToSafeName() throws IOException { + MultipartFile file = mockValidFile("../../etc/passwd", "application/pdf", 512); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null); + filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null); + + Document result = documentService.uploadDocument( + clubId, "Hacked", DocumentCategory.SONSTIGES, + DocumentAccessLevel.BOARD_ONLY, null, file, uploadedBy); + + // Path traversal stripped — only the basename remains + assertThat(result.getFilename()).doesNotContain(".."); + assertThat(result.getFilename()).doesNotContain("/"); + } + } + + @Test + void testUploadDocument_backslashPathTraversal_sanitized() throws IOException { + MultipartFile file = mockValidFile("..\\windows\\system32\\file.pdf", "application/pdf", 512); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null); + filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null); + + Document result = documentService.uploadDocument( + clubId, "Win Traversal", DocumentCategory.SONSTIGES, + DocumentAccessLevel.BOARD_ONLY, null, file, uploadedBy); + + // On Unix, backslashes are not path separators — they get replaced with _ + // The filename won't contain literal backslash characters + assertThat(result.getFilename()).doesNotContain("\\"); + // The sanitized name should not allow filesystem escape + assertThat(result.getFilename()).doesNotContain("/"); + } + } + + @Test + void testUploadDocument_nullByteInFilename_sanitized() throws IOException { + MultipartFile file = mockValidFile("file\u0000.exe.pdf", "application/pdf", 512); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null); + filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null); + + Document result = documentService.uploadDocument( + clubId, "Null Byte", DocumentCategory.SONSTIGES, + DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy); + + assertThat(result.getFilename()).doesNotContain("\0"); + } + } + + @Test + void testUploadDocument_emptyFilename_uuidFallback() throws IOException { + MultipartFile file = mockValidFile("", "application/pdf", 512); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null); + filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null); + + Document result = documentService.uploadDocument( + clubId, "Empty Name", DocumentCategory.SONSTIGES, + DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy); + + // Empty filename falls back to UUID-based name + assertThat(result.getFilename()).isNotBlank(); + assertThat(result.getFilename()).matches("[a-f0-9\\-]+"); + } + } + + @Test + void testUploadDocument_singleDotFilename_uuidFallback() throws IOException { + // Single "." and ".." are caught by sanitizeFilename and replaced with UUID + MultipartFile file = mockValidFile("..", "application/pdf", 512); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null); + filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null); + + Document result = documentService.uploadDocument( + clubId, "Dots", DocumentCategory.SONSTIGES, + DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy); + + // ".." is explicitly caught → UUID fallback + assertThat(result.getFilename()).isNotEqualTo(".."); + assertThat(result.getFilename()).isNotBlank(); + assertThat(result.getFilename()).matches("[a-f0-9\\-]+"); + } + } + + @Test + void testUploadDocument_doubleExtension_preservedAsIs() throws IOException { + MultipartFile file = mockValidFile("document.pdf.exe", "application/pdf", 512); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null); + filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null); + + Document result = documentService.uploadDocument( + clubId, "Double Ext", DocumentCategory.SONSTIGES, + DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy); + + assertThat(result.getFilename()).contains("document"); + } + } + + @Test + void testUploadDocument_fileTooLarge_throwsException() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn((long) (11 * 1024 * 1024)); + + assertThatThrownBy(() -> documentService.uploadDocument( + clubId, "Large", DocumentCategory.SONSTIGES, + DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maximum size"); + } + + @Test + void testUploadDocument_disallowedContentType_throwsException() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(512L); + when(file.getContentType()).thenReturn("application/x-msdownload"); + + assertThatThrownBy(() -> documentService.uploadDocument( + clubId, "Exe", DocumentCategory.SONSTIGES, + DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not allowed"); + } + + @Test + void testUploadDocument_emptyFile_throwsException() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(true); + + assertThatThrownBy(() -> documentService.uploadDocument( + clubId, "Empty", DocumentCategory.SONSTIGES, + DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty"); + } + + @Test + void testDeleteDocument_existingDocument_deletesAndAudits() throws IOException { + UUID docId = UUID.randomUUID(); + Document doc = new Document(); + doc.setId(docId); + doc.setClubId(clubId); + doc.setTitle("To Delete"); + doc.setStoragePath(clubId + "/" + docId + "_test.pdf"); + when(documentRepository.findById(docId)).thenReturn(Optional.of(doc)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.delete(any(Path.class))).then(inv -> null); + + documentService.deleteDocument(docId, uploadedBy, clubId); + + verify(documentRepository).delete(doc); + verify(auditService).log(eq(AuditEventType.DOCUMENT_DELETED), eq(uploadedBy), eq(clubId), anyString()); + } + } + + @Test + void testUploadDocument_controlCharsInFilename_stripped() throws IOException { + MultipartFile file = mockValidFile("file\u0007name\u001B.pdf", "application/pdf", 512); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(null); + filesMock.when(() -> Files.write(any(Path.class), any(byte[].class))).thenReturn(null); + + Document result = documentService.uploadDocument( + clubId, "Control Chars", DocumentCategory.SONSTIGES, + DocumentAccessLevel.ALL_MEMBERS, null, file, uploadedBy); + + // Control characters should be replaced with underscores + assertThat(result.getFilename()).doesNotContain("\u0007"); + assertThat(result.getFilename()).doesNotContain("\u001B"); + } + } + + // --- Helpers --- + + private MultipartFile mockValidFile(String filename, String contentType, long size) { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(size); + when(file.getContentType()).thenReturn(contentType); + when(file.getOriginalFilename()).thenReturn(filename); + try { + when(file.getBytes()).thenReturn(new byte[(int) Math.min(size, 1024)]); + } catch (IOException e) { + throw new RuntimeException(e); + } + return file; + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/EventServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/EventServiceTest.java new file mode 100644 index 0000000..1159a20 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/EventServiceTest.java @@ -0,0 +1,274 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.ClubEvent; +import de.cannamanage.domain.entity.EventRsvp; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.enums.*; +import de.cannamanage.service.repository.ClubEventRepository; +import de.cannamanage.service.repository.EventRsvpRepository; +import de.cannamanage.service.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.*; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for EventService — club event lifecycle, RSVP, recurring expansion. + */ +class EventServiceTest extends AbstractServiceTest { + + @Mock private ClubEventRepository eventRepository; + @Mock private EventRsvpRepository rsvpRepository; + @Mock private MemberRepository memberRepository; + @Mock private NotificationService notificationService; + @Mock private InfoBoardService infoBoardService; + @Mock private AuditService auditService; + + @InjectMocks + private EventService eventService; + + private ClubEvent event; + private static final UUID EVENT_ID = UUID.fromString("aaaa1111-bbbb-2222-cccc-333344445555"); + + @BeforeEach + void setUp() { + event = new ClubEvent(TEST_CLUB_ID, "Vereinsabend", "Monatlicher Stammtisch", + EventType.OTHER, TEST_INSTANT.plusSeconds(86400), + TEST_INSTANT.plusSeconds(86400 + 7200), "Vereinsheim", 30, TEST_USER_ID); + event.setId(EVENT_ID); + event.setRecurring(false); + } + + // === Create Event === + + @Test + void testCreateEvent_singleEvent_success() { + when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> { + ClubEvent e = inv.getArgument(0); + e.setId(EVENT_ID); + return e; + }); + when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList()); + + ClubEvent result = eventService.createEvent( + TEST_CLUB_ID, "Vereinsabend", "Stammtisch", EventType.OTHER, + TEST_INSTANT.plusSeconds(86400), TEST_INSTANT.plusSeconds(86400 + 7200), + "Vereinsheim", 30, false, null, null, TEST_USER_ID, false); + + assertThat(result.getTitle()).isEqualTo("Vereinsabend"); + assertThat(result.isRecurring()).isFalse(); + verify(eventRepository).save(any(ClubEvent.class)); + verify(auditService).log(eq(AuditEventType.EVENT_CREATED), eq("ClubEvent"), anyString(), anyString()); + } + + @Test + void testCreateEvent_recurringWeekly_success() { + when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> { + ClubEvent e = inv.getArgument(0); + e.setId(EVENT_ID); + return e; + }); + when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList()); + + ClubEvent result = eventService.createEvent( + TEST_CLUB_ID, "Wöchentliches Meeting", "Standup", EventType.MEETING, + TEST_INSTANT, TEST_INSTANT.plusSeconds(3600), + "Online", null, true, RecurrenceRule.WEEKLY, + LocalDate.of(2026, 12, 31), TEST_USER_ID, false); + + assertThat(result.isRecurring()).isTrue(); + assertThat(result.getRecurrenceRule()).isEqualTo(RecurrenceRule.WEEKLY); + } + + @Test + void testCreateEvent_recurringBiweekly_success() { + when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> { + ClubEvent e = inv.getArgument(0); + e.setId(EVENT_ID); + return e; + }); + when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList()); + + ClubEvent result = eventService.createEvent( + TEST_CLUB_ID, "Vorstand", "Vorstandssitzung", EventType.BOARD_MEETING, + TEST_INSTANT, TEST_INSTANT.plusSeconds(3600), + "Büro", 10, true, RecurrenceRule.BIWEEKLY, + LocalDate.of(2026, 12, 31), TEST_USER_ID, false); + + assertThat(result.getRecurrenceRule()).isEqualTo(RecurrenceRule.BIWEEKLY); + } + + @Test + void testCreateEvent_recurringMonthly_success() { + when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> { + ClubEvent e = inv.getArgument(0); + e.setId(EVENT_ID); + return e; + }); + when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList()); + + ClubEvent result = eventService.createEvent( + TEST_CLUB_ID, "MV-Vorbereitung", "Monatlich", EventType.MEETING, + TEST_INSTANT, TEST_INSTANT.plusSeconds(7200), + "Vereinsheim", null, true, RecurrenceRule.MONTHLY, + LocalDate.of(2027, 6, 1), TEST_USER_ID, false); + + assertThat(result.getRecurrenceRule()).isEqualTo(RecurrenceRule.MONTHLY); + } + + @Test + void testCreateEvent_withInfoBoardPost_postsToBoard() { + when(eventRepository.save(any(ClubEvent.class))).thenAnswer(inv -> { + ClubEvent e = inv.getArgument(0); + e.setId(EVENT_ID); + return e; + }); + when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList()); + + eventService.createEvent( + TEST_CLUB_ID, "Grillfest", "Sommer", EventType.HARVEST_FESTIVAL, + TEST_INSTANT.plusSeconds(86400 * 7), null, + "Garten", 50, false, null, null, TEST_USER_ID, true); + + verify(infoBoardService).createPost(eq(TEST_CLUB_ID), contains("Grillfest"), + any(), eq(InfoBoardCategory.EVENT), eq(false), eq(TEST_USER_ID)); + } + + // === RSVP === + + @Test + void testRsvp_accept_success() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event)); + when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.empty()); + when(rsvpRepository.countByEventIdAndStatus(EVENT_ID, RsvpStatus.ACCEPTED)).thenReturn(5L); + when(rsvpRepository.save(any(EventRsvp.class))).thenAnswer(inv -> inv.getArgument(0)); + + EventRsvp result = eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.ACCEPTED); + + assertThat(result.getStatus()).isEqualTo(RsvpStatus.ACCEPTED); + verify(rsvpRepository).save(any(EventRsvp.class)); + } + + @Test + void testRsvp_decline_success() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event)); + when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.empty()); + when(rsvpRepository.save(any(EventRsvp.class))).thenAnswer(inv -> inv.getArgument(0)); + + EventRsvp result = eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.DECLINED); + + assertThat(result.getStatus()).isEqualTo(RsvpStatus.DECLINED); + } + + @Test + void testRsvp_idempotent_updatesExisting() { + EventRsvp existing = new EventRsvp(event, TEST_MEMBER_ID, RsvpStatus.DECLINED); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event)); + when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.of(existing)); + when(rsvpRepository.save(any(EventRsvp.class))).thenAnswer(inv -> inv.getArgument(0)); + + EventRsvp result = eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.ACCEPTED); + + assertThat(result.getStatus()).isEqualTo(RsvpStatus.ACCEPTED); + } + + // === Cancel Event === + + @Test + void testCancelEvent_notifiesAttendees() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event)); + var rsvp = new EventRsvp(event, TEST_MEMBER_ID, RsvpStatus.ACCEPTED); + when(rsvpRepository.findByEventIdAndStatusIn(eq(EVENT_ID), any())).thenReturn(List.of(rsvp)); + Member member = new Member(); + member.setId(TEST_MEMBER_ID); + member.setUserId(TEST_USER_ID); + when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member)); + + eventService.cancelEvent(EVENT_ID); + + verify(eventRepository).delete(event); + verify(notificationService).sendNotification(eq(TEST_USER_ID), eq(NotificationType.EVENT_CANCELLED), any(), any(), any()); + verify(auditService).log(eq(AuditEventType.EVENT_CANCELLED), eq("ClubEvent"), anyString(), anyString()); + } + + // === Max Capacity Enforcement === + + @Test + void testRsvp_maxCapacityReached_throwsException() { + event.setMaxAttendees(5); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event)); + when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.empty()); + when(rsvpRepository.countByEventIdAndStatus(EVENT_ID, RsvpStatus.ACCEPTED)).thenReturn(5L); + + assertThatThrownBy(() -> eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.ACCEPTED)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("EVENT_FULL"); + } + + @Test + void testRsvp_maxCapacityReached_declineStillWorks() { + event.setMaxAttendees(5); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(event)); + when(rsvpRepository.findByEventIdAndMemberId(EVENT_ID, TEST_MEMBER_ID)).thenReturn(Optional.empty()); + when(rsvpRepository.save(any(EventRsvp.class))).thenAnswer(inv -> inv.getArgument(0)); + + // Declining should work regardless of capacity + EventRsvp result = eventService.rsvp(EVENT_ID, TEST_MEMBER_ID, RsvpStatus.DECLINED); + + assertThat(result.getStatus()).isEqualTo(RsvpStatus.DECLINED); + } + + // === DST Transition Edge Case === + + @Test + void testExpandRecurring_dstTransition_octoberLastSunday() { + // DST transition in Germany: last Sunday of October 2026 is Oct 25 + // Clock goes back 1 hour at 03:00 → 02:00 + ZoneId berlinZone = ZoneId.of("Europe/Berlin"); + // Start event at Oct 12 2026, 19:00 Berlin time (weekly) + LocalDateTime oct12 = LocalDateTime.of(2026, 10, 12, 19, 0); + Instant startInstant = oct12.atZone(berlinZone).toInstant(); + + ClubEvent recurringEvent = new ClubEvent(TEST_CLUB_ID, "Wöchentlicher Treff", null, + EventType.OTHER, startInstant, startInstant.plusSeconds(7200), + "Vereinsheim", null, TEST_USER_ID); + recurringEvent.setId(EVENT_ID); + recurringEvent.setRecurring(true); + recurringEvent.setRecurrenceRule(RecurrenceRule.WEEKLY); + recurringEvent.setRecurrenceEndDate(LocalDate.of(2026, 11, 15)); + + // Range covering the DST switch + Instant from = oct12.plusDays(1).atZone(berlinZone).toInstant(); + Instant to = LocalDateTime.of(2026, 11, 10, 23, 59).atZone(berlinZone).toInstant(); + + List occurrences = eventService.expandRecurring(recurringEvent, from, to); + + // Should produce occurrences for Oct 19, Oct 26, Nov 2, Nov 9 + assertThat(occurrences).hasSizeGreaterThanOrEqualTo(4); + // After DST switch, the event should still be at 19:00 local time + for (ClubEvent occ : occurrences) { + LocalTime localTime = occ.getStartAt().atZone(berlinZone).toLocalTime(); + assertThat(localTime).isEqualTo(LocalTime.of(19, 0)); + } + } + + // === Event not found === + + @Test + void testCancelEvent_notFound_throwsException() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> eventService.cancelEvent(EVENT_ID)) + .isInstanceOf(NoSuchElementException.class); + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/ForumServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/ForumServiceTest.java new file mode 100644 index 0000000..71fa31d --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/ForumServiceTest.java @@ -0,0 +1,245 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.*; +import de.cannamanage.domain.enums.*; +import de.cannamanage.service.repository.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.Duration; +import java.time.Instant; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for ForumService — topics, replies, reactions, reports, moderation. + */ +class ForumServiceTest extends AbstractServiceTest { + + @Mock private ForumTopicRepository topicRepository; + @Mock private ForumReplyRepository replyRepository; + @Mock private ForumReactionRepository reactionRepository; + @Mock private ForumReportRepository reportRepository; + @Mock private MemberRepository memberRepository; + @Mock private NotificationService notificationService; + @Mock private AuditService auditService; + + @InjectMocks + private ForumService forumService; + + private ForumTopic topic; + private ForumReply reply; + private static final UUID TOPIC_ID = UUID.fromString("aabb1122-ccdd-3344-eeff-556677889900"); + private static final UUID REPLY_ID = UUID.fromString("11223344-5566-7788-99aa-bbccddeeff00"); + private static final UUID MODERATOR_ID = UUID.fromString("99998888-7777-6666-5555-444433332222"); + + @BeforeEach + void setUp() { + topic = new ForumTopic(TEST_CLUB_ID, "Anbaufrage", "Welche Sorte empfiehlt ihr?", TEST_MEMBER_ID); + topic.setId(TOPIC_ID); + topic.setClubId(TEST_CLUB_ID); + topic.setLocked(false); + topic.setPinned(false); + topic.setReplyCount(0); + topic.setAuthorId(TEST_MEMBER_ID); + + reply = new ForumReply(TOPIC_ID, TEST_CLUB_ID, "Ich empfehle Sorte A", TEST_USER_ID); + reply.setId(REPLY_ID); + reply.setCreatedAt(Instant.now()); + reply.setAuthorId(TEST_USER_ID); + } + + // === Topics === + + @Test + void testCreateTopic_success() { + when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> { + ForumTopic t = inv.getArgument(0); + t.setId(TOPIC_ID); + return t; + }); + when(memberRepository.findByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList()); + + ForumTopic result = forumService.createTopic(TEST_CLUB_ID, "Neue Frage", "Inhalt", TEST_MEMBER_ID); + + assertThat(result.getTitle()).isEqualTo("Neue Frage"); + verify(topicRepository).save(any(ForumTopic.class)); + verify(auditService).logEvent(eq(AuditEventType.FORUM_TOPIC_CREATED), eq(TEST_MEMBER_ID), any(), any()); + } + + // === Replies === + + @Test + void testCreateReply_success() { + when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic)); + when(replyRepository.save(any(ForumReply.class))).thenAnswer(inv -> { + ForumReply r = inv.getArgument(0); + r.setId(REPLY_ID); + return r; + }); + when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0)); + + ForumReply result = forumService.createReply(TOPIC_ID, "Antwort", TEST_USER_ID); + + assertThat(result).isNotNull(); + verify(replyRepository).save(any(ForumReply.class)); + verify(auditService).logEvent(eq(AuditEventType.FORUM_REPLY_CREATED), eq(TEST_USER_ID), any(), any()); + } + + @Test + void testCreateReply_lockedTopic_throwsException() { + topic.setLocked(true); + when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic)); + + assertThatThrownBy(() -> forumService.createReply(TOPIC_ID, "Antwort", TEST_USER_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("locked topic"); + } + + @Test + void testEditReply_withinTimeWindow_success() { + reply.setCreatedAt(Instant.now().minus(Duration.ofMinutes(30))); // within 60-min window + when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(reply)); + when(replyRepository.save(any(ForumReply.class))).thenAnswer(inv -> inv.getArgument(0)); + + ForumReply result = forumService.editReply(REPLY_ID, "Aktualisierte Antwort", TEST_USER_ID); + + assertThat(result.getContent()).isEqualTo("Aktualisierte Antwort"); + assertThat(result.isEdited()).isTrue(); + } + + @Test + void testEditReply_pastTimeWindow_throwsException() { + reply.setCreatedAt(Instant.now().minus(Duration.ofMinutes(61))); // past 60-min window + when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(reply)); + + assertThatThrownBy(() -> forumService.editReply(REPLY_ID, "Zu spät", TEST_USER_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Edit window"); + } + + @Test + void testEditReply_notAuthor_throwsException() { + reply.setCreatedAt(Instant.now().minus(Duration.ofMinutes(5))); + reply.setAuthorId(TEST_MEMBER_ID); // different from TEST_USER_ID + when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(reply)); + + assertThatThrownBy(() -> forumService.editReply(REPLY_ID, "Fremde Antwort", TEST_USER_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Only the author"); + } + + @Test + void testDeleteReply_moderator_success() { + when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(reply)); + when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0)); + + forumService.deleteReply(REPLY_ID, MODERATOR_ID); + + verify(replyRepository).delete(reply); + verify(auditService).logEvent(eq(AuditEventType.FORUM_REPLY_DELETED), eq(MODERATOR_ID), any(), any()); + } + + // === Reactions === + + @Test + void testToggleReaction_add_success() { + when(reactionRepository.findByTargetTypeAndTargetIdAndUserId( + ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID)).thenReturn(Optional.empty()); + when(reactionRepository.save(any(ForumReaction.class))).thenAnswer(inv -> inv.getArgument(0)); + + Optional result = forumService.toggleReaction( + ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_UP); + + assertThat(result).isPresent(); + assertThat(result.get().getReactionType()).isEqualTo(ReactionType.THUMBS_UP); + } + + @Test + void testToggleReaction_remove_sameReaction() { + ForumReaction existing = new ForumReaction(ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_UP); + when(reactionRepository.findByTargetTypeAndTargetIdAndUserId( + ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID)).thenReturn(Optional.of(existing)); + + Optional result = forumService.toggleReaction( + ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_UP); + + assertThat(result).isEmpty(); // toggled off + verify(reactionRepository).delete(existing); + } + + @Test + void testToggleReaction_changeToDifferentType() { + ForumReaction existing = new ForumReaction(ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_UP); + when(reactionRepository.findByTargetTypeAndTargetIdAndUserId( + ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID)).thenReturn(Optional.of(existing)); + when(reactionRepository.save(any(ForumReaction.class))).thenAnswer(inv -> inv.getArgument(0)); + + Optional result = forumService.toggleReaction( + ForumTargetType.REPLY, REPLY_ID, TEST_USER_ID, ReactionType.THUMBS_DOWN); + + assertThat(result).isPresent(); + assertThat(result.get().getReactionType()).isEqualTo(ReactionType.THUMBS_DOWN); + } + + // === Pin / Unpin === + + @Test + void testPinTopic_success() { + when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0)); + + ForumTopic result = forumService.pinTopic(TOPIC_ID, MODERATOR_ID); + + assertThat(result.isPinned()).isTrue(); + } + + @Test + void testUnpinTopic_success() { + topic.setPinned(true); + when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0)); + + ForumTopic result = forumService.unpinTopic(TOPIC_ID, MODERATOR_ID); + + assertThat(result.isPinned()).isFalse(); + } + + // === Report Content === + + @Test + void testReportContent_success() { + when(reportRepository.save(any(ForumReport.class))).thenAnswer(inv -> { + ForumReport r = inv.getArgument(0); + r.setId(UUID.randomUUID()); + return r; + }); + + ForumReport result = forumService.reportContent( + TEST_CLUB_ID, ForumTargetType.REPLY, REPLY_ID, TEST_MEMBER_ID, "Beleidigend"); + + assertThat(result).isNotNull(); + verify(reportRepository).save(any(ForumReport.class)); + } + + // === Lock / Unlock (close topic) === + + @Test + void testLockTopic_preventsNewReplies() { + when(topicRepository.findById(TOPIC_ID)).thenReturn(Optional.of(topic)); + when(topicRepository.save(any(ForumTopic.class))).thenAnswer(inv -> inv.getArgument(0)); + + ForumTopic locked = forumService.lockTopic(TOPIC_ID, MODERATOR_ID); + + assertThat(locked.isLocked()).isTrue(); + verify(auditService).logEvent(eq(AuditEventType.FORUM_TOPIC_LOCKED), eq(MODERATOR_ID), any(), any()); + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/InfoBoardServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/InfoBoardServiceTest.java new file mode 100644 index 0000000..8fc2aae --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/InfoBoardServiceTest.java @@ -0,0 +1,186 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.InfoBoardPost; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.PostReadStatus; +import de.cannamanage.domain.enums.AuditEventType; +import de.cannamanage.domain.enums.InfoBoardCategory; +import de.cannamanage.domain.enums.NotificationType; +import de.cannamanage.service.repository.InfoBoardPostRepository; +import de.cannamanage.service.repository.MemberRepository; +import de.cannamanage.service.repository.PostReadStatusRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for InfoBoardService — Schwarzes Brett (info board) CRUD & read tracking. + */ +class InfoBoardServiceTest extends AbstractServiceTest { + + @Mock private InfoBoardPostRepository postRepository; + @Mock private PostReadStatusRepository readStatusRepository; + @Mock private MemberRepository memberRepository; + @Mock private NotificationService notificationService; + @Mock private AuditService auditService; + + @InjectMocks + private InfoBoardService infoBoardService; + + private InfoBoardPost post; + private static final UUID POST_ID = UUID.fromString("55556666-7777-8888-9999-aaaa0000bbbb"); + + @BeforeEach + void setUp() { + post = new InfoBoardPost(TEST_CLUB_ID, "Wichtige Mitteilung", "Inhalt der Mitteilung", + InfoBoardCategory.GENERAL, TEST_USER_ID); + post.setId(POST_ID); + post.setPinned(false); + post.setArchived(false); + } + + // === Create Post === + + @Test + void testCreatePost_general_success() { + when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> { + InfoBoardPost p = inv.getArgument(0); + p.setId(POST_ID); + return p; + }); + when(memberRepository.findAllByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList()); + + InfoBoardPost result = infoBoardService.createPost( + TEST_CLUB_ID, "Wichtige Mitteilung", "Inhalt", + InfoBoardCategory.GENERAL, false, TEST_USER_ID); + + assertThat(result.getTitle()).isEqualTo("Wichtige Mitteilung"); + assertThat(result.getCategory()).isEqualTo(InfoBoardCategory.GENERAL); + verify(postRepository).save(any(InfoBoardPost.class)); + } + + @Test + void testCreatePost_event_pinned() { + when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> { + InfoBoardPost p = inv.getArgument(0); + p.setId(POST_ID); + return p; + }); + when(memberRepository.findAllByClubId(TEST_CLUB_ID)).thenReturn(Collections.emptyList()); + + InfoBoardPost result = infoBoardService.createPost( + TEST_CLUB_ID, "Erntefest", "Am Samstag", + InfoBoardCategory.EVENT, true, TEST_USER_ID); + + assertThat(result.isPinned()).isTrue(); + assertThat(result.getCategory()).isEqualTo(InfoBoardCategory.EVENT); + } + + @Test + void testCreatePost_notifiesMembers() { + when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> { + InfoBoardPost p = inv.getArgument(0); + p.setId(POST_ID); + return p; + }); + Member member = new Member(); + member.setId(TEST_MEMBER_ID); + member.setUserId(TEST_USER_ID); + when(memberRepository.findAllByClubId(TEST_CLUB_ID)).thenReturn(List.of(member)); + + infoBoardService.createPost(TEST_CLUB_ID, "News", "Content", + InfoBoardCategory.GENERAL, false, TEST_USER_ID); + + verify(notificationService).sendNotification(eq(TEST_USER_ID), + eq(NotificationType.INFO_BOARD_POST), any(), any(), any()); + } + + // === Toggle Pin === + + @Test + void testTogglePin_unpinnedToPin() { + post.setPinned(false); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> inv.getArgument(0)); + + InfoBoardPost result = infoBoardService.togglePin(POST_ID); + + assertThat(result.isPinned()).isTrue(); + } + + @Test + void testTogglePin_pinnedToUnpin() { + post.setPinned(true); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> inv.getArgument(0)); + + InfoBoardPost result = infoBoardService.togglePin(POST_ID); + + assertThat(result.isPinned()).isFalse(); + } + + // === Archive / Delete === + + @Test + void testArchivePost_success() { + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> inv.getArgument(0)); + + InfoBoardPost result = infoBoardService.archivePost(POST_ID); + + assertThat(result.isArchived()).isTrue(); + } + + @Test + void testDeletePost_success() { + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + + infoBoardService.deletePost(POST_ID); + + verify(postRepository).delete(post); + } + + // === Update Post === + + @Test + void testUpdatePost_success() { + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + when(postRepository.save(any(InfoBoardPost.class))).thenAnswer(inv -> inv.getArgument(0)); + + InfoBoardPost result = infoBoardService.updatePost(POST_ID, "Neuer Titel", "Neuer Inhalt", + InfoBoardCategory.RULE, true); + + assertThat(result.getTitle()).isEqualTo("Neuer Titel"); + assertThat(result.getContent()).isEqualTo("Neuer Inhalt"); + assertThat(result.getCategory()).isEqualTo(InfoBoardCategory.RULE); + assertThat(result.isPinned()).isTrue(); + } + + // === Mark as Read === + + @Test + void testMarkAsRead_firstTime_saves() { + when(readStatusRepository.existsByPostIdAndMemberId(POST_ID, TEST_MEMBER_ID)).thenReturn(false); + + infoBoardService.markAsRead(POST_ID, TEST_MEMBER_ID); + + verify(readStatusRepository).save(any(PostReadStatus.class)); + } + + @Test + void testMarkAsRead_alreadyRead_noOp() { + when(readStatusRepository.existsByPostIdAndMemberId(POST_ID, TEST_MEMBER_ID)).thenReturn(true); + + infoBoardService.markAsRead(POST_ID, TEST_MEMBER_ID); + + verify(readStatusRepository, never()).save(any()); + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/BankImportServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/BankImportServiceTest.java new file mode 100644 index 0000000..aa98889 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/BankImportServiceTest.java @@ -0,0 +1,354 @@ +package de.cannamanage.service.bankimport; + +import de.cannamanage.domain.entity.BankImportSession; +import de.cannamanage.domain.entity.BankTransaction; +import de.cannamanage.domain.entity.CsvColumnMapping; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.Payment; +import de.cannamanage.domain.enums.BankFormat; +import de.cannamanage.domain.enums.ImportSessionStatus; +import de.cannamanage.domain.enums.MatchStatus; +import de.cannamanage.service.AbstractServiceTest; +import de.cannamanage.service.AuditService; +import de.cannamanage.service.FinanceService; +import de.cannamanage.service.NotificationService; +import de.cannamanage.service.repository.BankImportSessionRepository; +import de.cannamanage.service.repository.BankTransactionRepository; +import de.cannamanage.service.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Sprint 11 — BankImportServiceTest verifies the orchestrator for bank statement import. + *

+ * Tests cover: upload validation, duplicate detection, format detection delegation, + * session lifecycle (PENDING → IN_REVIEW → COMPLETED / FAILED), GoBD immutability + * enforcement, confirm/skip/assign operations, and file size limits. + */ +@DisplayName("BankImportService — Sprint 10 import orchestrator") +class BankImportServiceTest extends AbstractServiceTest { + + @Mock private BankImportSessionRepository sessionRepository; + @Mock private BankTransactionRepository transactionRepository; + @Mock private MemberRepository memberRepository; + @Mock private BankStatementParserService parserService; + @Mock private PaymentMatchingService matchingService; + @Mock private FinanceService financeService; + @Mock private AuditService auditService; + @Mock private NotificationService notificationService; + + @InjectMocks + private BankImportService service; + + private static final UUID SESSION_ID = UUID.fromString("99999999-9999-9999-9999-999999999999"); + private static final UUID TXN_ID = UUID.fromString("88888888-8888-8888-8888-888888888888"); + + private BankImportSession activeSession; + private BankTransaction sampleTransaction; + + @BeforeEach + void setUp() { + activeSession = new BankImportSession(); + activeSession.setId(SESSION_ID); + activeSession.setClubId(TEST_CLUB_ID); + activeSession.setStatus(ImportSessionStatus.IN_REVIEW); + activeSession.setFilename("test.mt940"); + activeSession.setFormat(BankFormat.MT940); + activeSession.setUploadedBy(TEST_USER_ID); + activeSession.setConfirmedCount(0); + activeSession.setSkippedCount(0); + + sampleTransaction = new BankTransaction(); + sampleTransaction.setId(TXN_ID); + sampleTransaction.setSessionId(SESSION_ID); + sampleTransaction.setAmountCents(5000); + sampleTransaction.setBookingDate(LocalDate.of(2026, 6, 15)); + sampleTransaction.setMatchStatus(MatchStatus.MATCHED); + sampleTransaction.setMatchedMemberId(TEST_MEMBER_ID); + sampleTransaction.setMatchConfidence(95); + } + + // ───────────────────────────────────────────────────────────────────────── + // Upload + Parse + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Upload and parse") + class UploadAndParse { + + @Test + @DisplayName("#1 Upload valid file creates IN_REVIEW session") + void testUploadAndParse_ValidFile_CreatesSession() throws IOException { + MultipartFile file = mockFile("statement.mt940", "valid content".getBytes(), 100); + + when(sessionRepository.existsByClubIdAndFileHash(eq(TEST_CLUB_ID), anyString())).thenReturn(false); + when(parserService.detectFormat(anyString(), any(byte[].class))).thenReturn(BankFormat.MT940); + when(parserService.parse(any(), anyString(), eq(BankFormat.MT940), any())) + .thenReturn(new ParseResult( + List.of(new ParsedTransaction(LocalDate.of(2026, 6, 15), LocalDate.of(2026, 6, 15), + 5000, "EUR", "Beitrag", "Max", "DE89370400440532013000", "REF1")), + "DE89370400440532013000", LocalDate.of(2026, 6, 15), 100000, 105000, List.of())); + when(matchingService.matchTransactions(any(), eq(TEST_CLUB_ID), any())) + .thenReturn(List.of(sampleTransaction)); + when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> { + BankImportSession s = inv.getArgument(0); + if (s.getId() == null) s.setId(SESSION_ID); + return s; + }); + + BankImportSession result = service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null); + + assertThat(result.getStatus()).isEqualTo(ImportSessionStatus.IN_REVIEW); + verify(sessionRepository, atLeastOnce()).save(any(BankImportSession.class)); + verify(auditService).log(any(), eq(TEST_USER_ID), anyString(), anyString()); + } + + @Test + @DisplayName("#2 Upload duplicate file (same hash) throws CONFLICT") + void testUploadAndParse_DuplicateHash_ThrowsConflict() throws IOException { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(100L); + when(file.getBytes()).thenReturn("duplicate content".getBytes()); + + when(sessionRepository.existsByClubIdAndFileHash(eq(TEST_CLUB_ID), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("bereits importiert"); + } + + @Test + @DisplayName("#3 Upload empty file throws BAD_REQUEST") + void testUploadAndParse_EmptyFile_ThrowsBadRequest() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(true); + + assertThatThrownBy(() -> service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("leer"); + } + + @Test + @DisplayName("#4 Upload file exceeding max size throws PAYLOAD_TOO_LARGE") + void testUploadAndParse_OversizedFile_ThrowsPayloadTooLarge() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(BankImportService.MAX_FILE_SIZE_BYTES + 1); + + assertThatThrownBy(() -> service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("zu groß"); + } + + @Test + @DisplayName("#5 Invalid format rejection throws BAD_REQUEST") + void testUploadAndParse_UnrecognizedFormat_ThrowsBadRequest() throws IOException { + MultipartFile file = mockFile("garbage.bin", "not a bank file".getBytes(), 100); + + when(sessionRepository.existsByClubIdAndFileHash(eq(TEST_CLUB_ID), anyString())).thenReturn(false); + when(parserService.detectFormat(anyString(), any(byte[].class))) + .thenThrow(new BankStatementParserService.UnrecognizedFormatException("Unknown format")); + when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> { + BankImportSession s = inv.getArgument(0); + if (s.getId() == null) s.setId(SESSION_ID); + return s; + }); + + assertThatThrownBy(() -> service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("nicht erkannt"); + } + + @Test + @DisplayName("#6 File format auto-detection delegates to parserService") + void testUploadAndParse_AutoDetectsFormat() throws IOException { + MultipartFile file = mockFile("export.xml", "".getBytes(), 100); + + when(sessionRepository.existsByClubIdAndFileHash(eq(TEST_CLUB_ID), anyString())).thenReturn(false); + when(parserService.detectFormat(anyString(), any(byte[].class))).thenReturn(BankFormat.CAMT053); + when(parserService.parse(any(), anyString(), eq(BankFormat.CAMT053), any())) + .thenReturn(new ParseResult(List.of(), null, null, null, null, List.of())); + when(matchingService.matchTransactions(any(), eq(TEST_CLUB_ID), any())) + .thenReturn(List.of()); + when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> { + BankImportSession s = inv.getArgument(0); + if (s.getId() == null) s.setId(SESSION_ID); + return s; + }); + + service.uploadAndParse(TEST_CLUB_ID, TEST_USER_ID, file, null); + + verify(parserService).detectFormat(eq("export.xml"), any(byte[].class)); + verify(parserService).parse(any(), eq("export.xml"), eq(BankFormat.CAMT053), any()); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Session lifecycle + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Session lifecycle") + class SessionLifecycle { + + @Test + @DisplayName("#7 completeSession transitions to COMPLETED") + void testCompleteSession_TransitionsToCompleted() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession)); + when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> inv.getArgument(0)); + + BankImportSession result = service.completeSession(SESSION_ID, TEST_USER_ID); + + assertThat(result.getStatus()).isEqualTo(ImportSessionStatus.COMPLETED); + assertThat(result.getCompletedAt()).isNotNull(); + verify(auditService).log(any(), eq(TEST_USER_ID), anyString(), anyString()); + } + + @Test + @DisplayName("#8 completeSession on COMPLETED session throws CONFLICT (GoBD)") + void testCompleteSession_AlreadyCompleted_ThrowsConflict() { + activeSession.setStatus(ImportSessionStatus.COMPLETED); + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession)); + + assertThatThrownBy(() -> service.completeSession(SESSION_ID, TEST_USER_ID)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("GoBD"); + } + + @Test + @DisplayName("#9 Operations on FAILED session throw CONFLICT") + void testMutation_FailedSession_ThrowsConflict() { + activeSession.setStatus(ImportSessionStatus.FAILED); + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession)); + + assertThatThrownBy(() -> service.completeSession(SESSION_ID, TEST_USER_ID)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("fehlgeschlagen"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Confirm / skip / assign + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Confirm, skip, assign") + class ConfirmSkipAssign { + + @Test + @DisplayName("#10 confirmMatch creates payment and sets CONFIRMED") + void testConfirmMatch_ValidTransaction_CreatesPayment() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession)); + when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction)); + Member member = new Member(); + member.setId(TEST_MEMBER_ID); + member.setClubId(TEST_CLUB_ID); + when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member)); + Payment payment = new Payment(); + payment.setId(TEST_PAYMENT_ID); + when(financeService.recordPayment(any(), any(), anyInt(), any(), any(), any(), any(), any(), any())) + .thenReturn(payment); + when(transactionRepository.save(any(BankTransaction.class))).thenAnswer(inv -> inv.getArgument(0)); + when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> inv.getArgument(0)); + + BankTransaction result = service.confirmMatch(SESSION_ID, TXN_ID, TEST_MEMBER_ID, TEST_USER_ID); + + assertThat(result.getMatchStatus()).isEqualTo(MatchStatus.CONFIRMED); + assertThat(result.getMatchedPaymentId()).isEqualTo(TEST_PAYMENT_ID); + } + + @Test + @DisplayName("#11 confirmMatch on already-confirmed transaction throws CONFLICT") + void testConfirmMatch_AlreadyConfirmed_ThrowsConflict() { + sampleTransaction.setMatchStatus(MatchStatus.CONFIRMED); + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession)); + when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction)); + + assertThatThrownBy(() -> service.confirmMatch(SESSION_ID, TXN_ID, TEST_MEMBER_ID, TEST_USER_ID)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("bereits bestätigt"); + } + + @Test + @DisplayName("#12 skipTransaction marks as SKIPPED") + void testSkipTransaction_SetsSkippedStatus() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession)); + when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction)); + when(transactionRepository.save(any(BankTransaction.class))).thenAnswer(inv -> inv.getArgument(0)); + when(sessionRepository.save(any(BankImportSession.class))).thenAnswer(inv -> inv.getArgument(0)); + + BankTransaction result = service.skipTransaction(SESSION_ID, TXN_ID, "Nicht relevant", TEST_USER_ID); + + assertThat(result.getMatchStatus()).isEqualTo(MatchStatus.SKIPPED); + assertThat(result.getSkipReason()).isEqualTo("Nicht relevant"); + } + + @Test + @DisplayName("#13 manualAssign sets member and 100% confidence") + void testManualAssign_SetsMatchedWith100Confidence() { + sampleTransaction.setMatchStatus(MatchStatus.UNMATCHED); + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession)); + when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction)); + Member member = new Member(); + member.setId(TEST_MEMBER_ID); + member.setClubId(TEST_CLUB_ID); + when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member)); + when(transactionRepository.save(any(BankTransaction.class))).thenAnswer(inv -> inv.getArgument(0)); + + BankTransaction result = service.manualAssign(SESSION_ID, TXN_ID, TEST_MEMBER_ID, TEST_USER_ID); + + assertThat(result.getMatchStatus()).isEqualTo(MatchStatus.MATCHED); + assertThat(result.getMatchConfidence()).isEqualTo(100); + assertThat(result.getMatchedMemberId()).isEqualTo(TEST_MEMBER_ID); + } + + @Test + @DisplayName("#14 confirmMatch rejects member from different club") + void testConfirmMatch_WrongClub_ThrowsForbidden() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(activeSession)); + when(transactionRepository.findById(TXN_ID)).thenReturn(Optional.of(sampleTransaction)); + Member wrongClubMember = new Member(); + wrongClubMember.setId(TEST_MEMBER_ID); + wrongClubMember.setClubId(UUID.fromString("77777777-7777-7777-7777-777777777777")); // different club + when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(wrongClubMember)); + + assertThatThrownBy(() -> service.confirmMatch(SESSION_ID, TXN_ID, TEST_MEMBER_ID, TEST_USER_ID)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("nicht zum aktuellen Verein"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static MultipartFile mockFile(String filename, byte[] content, long size) throws IOException { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(size); + when(file.getBytes()).thenReturn(content); + when(file.getOriginalFilename()).thenReturn(filename); + return file; + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/BankStatementParserServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/BankStatementParserServiceTest.java new file mode 100644 index 0000000..1aa8a89 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/BankStatementParserServiceTest.java @@ -0,0 +1,190 @@ +package de.cannamanage.service.bankimport; + +import de.cannamanage.domain.entity.CsvColumnMapping; +import de.cannamanage.domain.enums.BankFormat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Sprint 11 — BankStatementParserServiceTest verifies the façade that detects + * bank statement formats and routes parsing to the correct parser. + *

+ * Tests cover: format detection delegation, MT940/CAMT.053/CSV routing, + * unknown format exception, null/empty input handling, and the detectAndParse + * convenience method. + */ +@DisplayName("BankStatementParserService — format detection + routing façade") +class BankStatementParserServiceTest { + + private BankStatementParserService service; + private Mt940Parser mt940Parser; + private Camt053Parser camt053Parser; + private CsvBankParser csvParser; + + @BeforeEach + void setUp() { + mt940Parser = new Mt940Parser(); + camt053Parser = new Camt053Parser(); + csvParser = new CsvBankParser(); + service = new BankStatementParserService(List.of(mt940Parser, camt053Parser, csvParser)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Format detection + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Format detection") + class FormatDetection { + + @Test + @DisplayName("#1 Detect MT940 format from content") + void testDetectFormat_Mt940Content_ReturnsMt940() { + byte[] content = ":20:STARTUMSE\n:25:50050201/0001234567\n:60F:C260601EUR100,00\n" + .getBytes(StandardCharsets.ISO_8859_1); + + BankFormat result = service.detectFormat("statement.sta", content); + + assertThat(result).isEqualTo(BankFormat.MT940); + } + + @Test + @DisplayName("#2 Detect CAMT.053 format from XML content") + void testDetectFormat_CamtContent_ReturnsCamt053() { + byte[] content = """ + + + + + """.getBytes(StandardCharsets.UTF_8); + + BankFormat result = service.detectFormat("export.xml", content); + + assertThat(result).isEqualTo(BankFormat.CAMT053); + } + + @Test + @DisplayName("#3 Detect CSV format from extension and content") + void testDetectFormat_CsvContent_ReturnsCsv() { + byte[] content = "Datum;Betrag;Verwendungszweck\n15.06.2026;50,00;Beitrag\n" + .getBytes(StandardCharsets.UTF_8); + + BankFormat result = service.detectFormat("umsaetze.csv", content); + + assertThat(result).isEqualTo(BankFormat.CSV); + } + + @Test + @DisplayName("#4 Unknown format throws UnrecognizedFormatException") + void testDetectFormat_UnknownContent_ThrowsException() { + byte[] content = "TOTALLY RANDOM BINARY CONTENT 0x00 0xFF".getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> service.detectFormat("mystery.dat", content)) + .isInstanceOf(BankStatementParserService.UnrecognizedFormatException.class) + .hasMessageContaining("mystery.dat"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Parse routing + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Parse routing") + class ParseRouting { + + @Test + @DisplayName("#5 Null/empty input throws NullPointerException") + void testParse_NullInput_Throws() { + assertThatThrownBy(() -> service.parse(null, "test.xml", BankFormat.CAMT053, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("#6 CSV format without mapping throws IllegalArgumentException") + void testParse_CsvWithoutMapping_Throws() { + InputStream is = new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> service.parse(is, "test.csv", BankFormat.CSV, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("csvMapping is required"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // detectAndParse convenience + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("detectAndParse convenience method") + class DetectAndParse { + + @Test + @DisplayName("#7 detectAndParse routes MT940 content to MT940 parser") + void testDetectAndParse_Mt940_RoutesCorrectly() { + // Minimal MT940 that the parser can handle + String mt940 = """ + :20:STARTUMSE + :25:50050201/0001234567 + :28C:00001/001 + :60F:C260601EUR100,00 + :61:2606150615CR50,00NTRFNONREF//BANKREF + :86:Mitgliedsbeitrag + :62F:C260615EUR150,00 + """; + byte[] content = mt940.getBytes(StandardCharsets.ISO_8859_1); + + ParseResult result = service.detectAndParse(content, "export.sta", null); + + assertThat(result.transactions()).isNotEmpty(); + assertThat(result.transactions().get(0).amountCents()).isEqualTo(5000); + } + + @Test + @DisplayName("#8 detectAndParse routes CAMT.053 to CAMT parser") + void testDetectAndParse_Camt053_RoutesCorrectly() { + String camt = """ + + + + + S1 + DE89370400440532013000 + + 42.00 + CRDT +

2026-06-15
+ REF1 + + + + + """; + byte[] content = camt.getBytes(StandardCharsets.UTF_8); + + ParseResult result = service.detectAndParse(content, "statement.xml", null); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.transactions().get(0).amountCents()).isEqualTo(4200); + assertThat(result.accountIban()).isEqualTo("DE89370400440532013000"); + } + + @Test + @DisplayName("#9 supportedFormats returns all three formats") + void testSupportedFormats_ReturnsAllThree() { + assertThat(service.supportedFormats()) + .containsExactlyInAnyOrder(BankFormat.MT940, BankFormat.CAMT053, BankFormat.CSV); + } + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/Camt053ParserTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/Camt053ParserTest.java new file mode 100644 index 0000000..86c708f --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/Camt053ParserTest.java @@ -0,0 +1,493 @@ +package de.cannamanage.service.bankimport; + +import de.cannamanage.domain.enums.BankFormat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Sprint 11 — Camt053ParserTest verifies the ISO 20022 CAMT.053 XML parser. + *

+ * Tests cover: happy-path parsing, multi-statement files, debit handling, + * empty documents, XXE hardening, encoding, and date formats. + */ +@DisplayName("Camt053Parser — Sprint 10 CAMT.053 XML parser") +class Camt053ParserTest { + + private final Camt053Parser parser = new Camt053Parser(); + + // Minimal valid CAMT.053 template + private static final String CAMT_HEADER = """ + + + + """; + private static final String CAMT_FOOTER = """ + + + """; + + private static String stmt(String iban, String entries) { + return """ + + STMT001 + %s + + OPBD + 1000.00 + CRDT +

2026-06-01
+ + + CLBD + 1050.00 + CRDT +
2026-06-15
+
+ %s + + """.formatted(iban, entries); + } + + private static String entry(String amount, String cdtDbt, String date, String ref, String name) { + return """ + + %s + %s +
%s
+
%s
+ %s + + Mitgliedsbeitrag Juni + %s + +
+ """.formatted(amount, cdtDbt, date, date, ref, name); + } + + private ParseResult parse(String xml) { + InputStream is = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)); + return parser.parse(is, "test.xml", null); + } + + // ───────────────────────────────────────────────────────────────────────── + // Format detection + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Format detection") + class FormatDetection { + + @Test + @DisplayName("#1 getSupportedFormat returns CAMT053") + void testGetSupportedFormat_ReturnsCamt053() { + assertThat(parser.getSupportedFormat()).isEqualTo(BankFormat.CAMT053); + } + + @Test + @DisplayName("#2 canParse: null/empty bytes → false") + void testCanParse_EmptyOrNull_ReturnsFalse() { + assertThat(parser.canParse("test.xml", null)).isFalse(); + assertThat(parser.canParse("test.xml", new byte[0])).isFalse(); + } + + @Test + @DisplayName("#3 canParse: XML with BkToCstmrStmt → true") + void testCanParse_WithBkToCstmrStmt_ReturnsTrue() { + byte[] header = CAMT_HEADER.getBytes(StandardCharsets.UTF_8); + assertThat(parser.canParse("statement.xml", header)).isTrue(); + } + + @Test + @DisplayName("#4 canParse: XML with camt.053 namespace → true") + void testCanParse_WithCamtNamespace_ReturnsTrue() { + byte[] header = "".getBytes(StandardCharsets.UTF_8); + assertThat(parser.canParse("export.xml", header)).isTrue(); + } + + @Test + @DisplayName("#5 canParse: non-XML content → false") + void testCanParse_NonXml_ReturnsFalse() { + byte[] header = ":20:STARTUMSE\n:25:50050201".getBytes(StandardCharsets.ISO_8859_1); + assertThat(parser.canParse("statement.sta", header)).isFalse(); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Happy path + // ──────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Happy path parsing") + class HappyPath { + + @Test + @DisplayName("#6 Parse valid single-entry CAMT.053") + void testParse_ValidSingleEntry_ReturnsOneTransaction() { + String xml = CAMT_HEADER + + stmt("DE89370400440532013000", entry("50.00", "CRDT", "2026-06-10", "REF001", "Max Mustermann")) + + CAMT_FOOTER; + + ParseResult result = parse(xml); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.accountIban()).isEqualTo("DE89370400440532013000"); + assertThat(result.openingBalanceCents()).isEqualTo(100000); + assertThat(result.closingBalanceCents()).isEqualTo(105000); + + ParsedTransaction tx = result.transactions().get(0); + assertThat(tx.amountCents()).isEqualTo(5000); + assertThat(tx.bookingDate()).isEqualTo(LocalDate.of(2026, 6, 10)); + assertThat(tx.currency()).isEqualTo("EUR"); + assertThat(tx.bankReference()).isEqualTo("REF001"); + assertThat(tx.referenceText()).isEqualTo("Mitgliedsbeitrag Juni"); + } + + @Test + @DisplayName("#7 Parse multi-statement file") + void testParse_MultiStatement_AggregatesEntries() { + String xml = CAMT_HEADER + + stmt("DE11111111111111111111", entry("25.00", "CRDT", "2026-06-01", "R1", "Alice")) + + stmt("DE22222222222222222222", entry("30.00", "CRDT", "2026-06-02", "R2", "Bob")) + + CAMT_FOOTER; + + ParseResult result = parse(xml); + + // Both statements' entries are collected + assertThat(result.transactions()).hasSize(2); + // First IBAN encountered is kept + assertThat(result.accountIban()).isEqualTo("DE11111111111111111111"); + } + + @Test + @DisplayName("#8 Parse with multiple entries in one statement") + void testParse_MultipleEntries_ReturnsAll() { + String entries = entry("10.00", "CRDT", "2026-06-05", "A", "Alice") + + entry("20.00", "CRDT", "2026-06-06", "B", "Bob") + + entry("30.00", "CRDT", "2026-06-07", "C", "Carol"); + String xml = CAMT_HEADER + stmt("DE89370400440532013000", entries) + CAMT_FOOTER; + + ParseResult result = parse(xml); + + assertThat(result.transactions()).hasSize(3); + assertThat(result.transactions().get(0).amountCents()).isEqualTo(1000); + assertThat(result.transactions().get(1).amountCents()).isEqualTo(2000); + assertThat(result.transactions().get(2).amountCents()).isEqualTo(3000); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Debit / negative amounts + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Debit handling") + class DebitHandling { + + @Test + @DisplayName("#9 Negative amount for debit entries") + void testParse_DebitEntry_NegativeAmount() { + String xml = CAMT_HEADER + + stmt("DE89370400440532013000", entry("75.50", "DBIT", "2026-06-12", "DEBIT1", "Stromversorger")) + + CAMT_FOOTER; + + ParseResult result = parse(xml); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.transactions().get(0).amountCents()).isEqualTo(-7550); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Edge cases + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Edge cases") + class EdgeCases { + + @Test + @DisplayName("#10 Empty document (no entries)") + void testParse_EmptyDocument_NoTransactions() { + String xml = CAMT_HEADER + """ + + EMPTY + DE89370400440532013000 + + """ + CAMT_FOOTER; + + ParseResult result = parse(xml); + + assertThat(result.transactions()).isEmpty(); + assertThat(result.warnings()).isEmpty(); + } + + @Test + @DisplayName("#11 Missing mandatory fields produces warning, not crash") + void testParse_MissingMandatoryFields_WarningNotCrash() { + // Entry without CdtDbtInd — should be skipped with a warning + String xml = CAMT_HEADER + """ + + S1 + DE89370400440532013000 + + 50.00 +
2026-06-10
+ REF_INCOMPLETE +
+
+ """ + CAMT_FOOTER; + + ParseResult result = parse(xml); + + assertThat(result.transactions()).isEmpty(); + assertThat(result.warnings()).hasSize(1); + assertThat(result.warnings().get(0)).contains("missing required fields"); + } + + @Test + @DisplayName("#12 Currency code from Ccy attribute") + void testParse_CurrencyCode_ExtractedFromAttribute() { + String xml = CAMT_HEADER + """ + + S1 + DE89370400440532013000 + + 100.00 + CRDT +
2026-06-10
+ CHF_REF +
+
+ """ + CAMT_FOOTER; + + ParseResult result = parse(xml); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.transactions().get(0).currency()).isEqualTo("CHF"); + } + + @Test + @DisplayName("#13 Date parsing with datetime format (T-suffix stripped)") + void testParse_DateWithTimePortion_ParsesCorrectly() { + // Use dateTime style "2026-06-10T14:30:00" in BookgDt + String xml = CAMT_HEADER + """ + + S1 + DE89370400440532013000 + + 42.00 + CRDT +
2026-06-10T14:30:00
+ DATETIME_REF +
+
+ """ + CAMT_FOOTER; + + ParseResult result = parse(xml); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.transactions().get(0).bookingDate()).isEqualTo(LocalDate.of(2026, 6, 10)); + } + + @Test + @DisplayName("#14 UTF-8 with German special characters (ü, ö, ä, ß)") + void testParse_Utf8SpecialChars_PreservedInOutput() { + String xml = CAMT_HEADER + """ + + S1 + DE89370400440532013000 + + 15.00 + CRDT +
2026-06-10
+ UMLAUT + + Überweisung für Größe + Jürgen Müller-Straße + +
+
+ """ + CAMT_FOOTER; + + ParseResult result = parse(xml); + + assertThat(result.transactions()).hasSize(1); + ParsedTransaction tx = result.transactions().get(0); + assertThat(tx.referenceText()).isEqualTo("Überweisung für Größe"); + assertThat(tx.counterpartyName()).isEqualTo("Jürgen Müller-Straße"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // XXE hardening (Security) + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("XXE hardening") + class XxeHardening { + + @Test + @DisplayName("#15 XXE prevention: DOCTYPE entity injection — entity not resolved") + void testParse_XxeDoctype_EntityNotResolved() { + // With SUPPORT_DTD=false the StAX parser silently ignores DTDs. + // The security guarantee: /etc/passwd content is never exposed. + String xxeXml = """ + + + ]> + + + + CLEAN + DE89370400440532013000 + + 10.00 + CRDT +
2026-06-10
+ SAFE_REF +
+
+
+
+ """; + + try { + ParseResult result = parse(xxeXml); + // Parsed successfully — verify no sensitive file content leaked + assertThat(result.accountIban()).doesNotContain("root:"); + for (ParsedTransaction tx : result.transactions()) { + assertThat(tx.bankReference()).doesNotContain("root:"); + assertThat(tx.referenceText() == null ? "" : tx.referenceText()).doesNotContain("root:"); + } + } catch (BankStatementParseException e) { + // Throwing is also acceptable — DTD was rejected outright + assertThat(e.getMessage()).contains("XML"); + } + } + + @Test + @DisplayName("#16 Billion laughs attack — entities not expanded") + void testParse_BillionLaughs_EntitiesNotExpanded() { + // With DTD support disabled, recursive entity expansion cannot happen. + String billionLaughs = """ + + + + + ]> + + + + SAFE + DE89370400440532013000 + + + + """; + + try { + ParseResult result = parse(billionLaughs); + // If it parses, the entities were NOT expanded (no memory bomb) + assertThat(result).isNotNull(); + } catch (BankStatementParseException e) { + // Throwing is also acceptable + assertThat(e.getMessage()).contains("XML"); + } + } + + @Test + @DisplayName("#17 SSRF via external entity — entity not resolved") + void testParse_SsrfExternalEntity_EntityNotResolved() { + String ssrfXml = """ + + + ]> + + + + SAFE + DE89370400440532013000 + + 5.00 + CRDT +
2026-06-10
+ SAFE +
+
+
+
+ """; + + try { + ParseResult result = parse(ssrfXml); + // No external content fetched — entities remain unresolved + assertThat(result.transactions()).isNotNull(); + for (ParsedTransaction tx : result.transactions()) { + assertThat(tx.bankReference()).doesNotContain("evil"); + } + } catch (BankStatementParseException e) { + // Throwing is also acceptable + assertThat(e.getMessage()).contains("XML"); + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Performance + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Performance") + class Performance { + + @Test + @DisplayName("#18 Large file (500 entries) completes within 2 seconds") + void testParse_LargeFile_CompletesWithinTimeout() { + StringBuilder xml = new StringBuilder(CAMT_HEADER); + xml.append("LARGEDE89370400440532013000"); + for (int i = 0; i < 500; i++) { + xml.append(entry(String.valueOf(i + 1) + ".00", "CRDT", + "2026-06-" + String.format("%02d", (i % 28) + 1), "REF" + i, "Member" + i)); + } + xml.append(""); + xml.append(CAMT_FOOTER); + + long start = System.currentTimeMillis(); + ParseResult result = parse(xml.toString()); + long elapsed = System.currentTimeMillis() - start; + + assertThat(result.transactions()).hasSize(500); + assertThat(elapsed).isLessThan(2000); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Unit: parseAmountToCents + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("parseAmountToCents") + class AmountParsing { + + @Test + @DisplayName("#19 Standard amounts") + void testParseAmountToCents_StandardAmounts() { + assertThat(Camt053Parser.parseAmountToCents("50.00")).isEqualTo(5000); + assertThat(Camt053Parser.parseAmountToCents("1234.56")).isEqualTo(123456); + assertThat(Camt053Parser.parseAmountToCents("0.99")).isEqualTo(99); + assertThat(Camt053Parser.parseAmountToCents("100")).isEqualTo(10000); + } + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/CsvBankParserTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/CsvBankParserTest.java new file mode 100644 index 0000000..54b4ef1 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/bankimport/CsvBankParserTest.java @@ -0,0 +1,295 @@ +package de.cannamanage.service.bankimport; + +import de.cannamanage.domain.entity.CsvColumnMapping; +import de.cannamanage.domain.enums.BankFormat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Sprint 11 — CsvBankParserTest verifies the generic CSV bank statement parser. + *

+ * CSV exports vary wildly by bank: delimiter, encoding, column layout, header rows. + * This parser relies on {@link CsvColumnMapping} for configuration. Tests cover + * semicolons, quoted fields, BOM handling, tab-separated, empty lines, and encoding. + */ +@DisplayName("CsvBankParser — Sprint 10 generic CSV bank parser") +class CsvBankParserTest { + + private final CsvBankParser parser = new CsvBankParser(); + + /** Standard German Sparkasse-style mapping: semicolon, dd.MM.yyyy, comma decimal. */ + private CsvColumnMapping sparkasseMapping; + + @BeforeEach + void setUp() { + sparkasseMapping = new CsvColumnMapping(); + sparkasseMapping.setName("Sparkasse Export"); + sparkasseMapping.setDateColumn(0); + sparkasseMapping.setAmountColumn(1); + sparkasseMapping.setReferenceColumn(2); + sparkasseMapping.setCounterpartyColumn(3); + sparkasseMapping.setIbanColumn(4); + sparkasseMapping.setDelimiter(";"); + sparkasseMapping.setDateFormat("dd.MM.yyyy"); + sparkasseMapping.setDecimalSeparator(","); + sparkasseMapping.setSkipHeaderRows(1); + sparkasseMapping.setEncoding("UTF-8"); + } + + private ParseResult parse(String csv) { + return parse(csv, sparkasseMapping); + } + + private ParseResult parse(String csv, CsvColumnMapping mapping) { + InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.UTF_8)); + return parser.parse(is, "test.csv", mapping); + } + + // ───────────────────────────────────────────────────────────────────────── + // Format detection + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Format detection") + class FormatDetection { + + @Test + @DisplayName("#1 getSupportedFormat returns CSV") + void testGetSupportedFormat_ReturnsCsv() { + assertThat(parser.getSupportedFormat()).isEqualTo(BankFormat.CSV); + } + + @Test + @DisplayName("#2 canParse: .csv extension → true") + void testCanParse_CsvExtension_ReturnsTrue() { + byte[] header = "Datum;Betrag;Verwendungszweck\n".getBytes(StandardCharsets.UTF_8); + assertThat(parser.canParse("umsaetze.csv", header)).isTrue(); + } + + @Test + @DisplayName("#3 canParse: null filename or bytes → false") + void testCanParse_NullInputs_ReturnsFalse() { + assertThat(parser.canParse(null, new byte[]{1})).isFalse(); + assertThat(parser.canParse("file.csv", null)).isFalse(); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Happy path: standard CSV parsing + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Happy path parsing") + class HappyPath { + + @Test + @DisplayName("#4 Parse valid CSV with standard semicolon columns") + void testParse_ValidSemicolonCsv_ReturnsTransactions() { + String csv = """ + Datum;Betrag;Verwendungszweck;Name;IBAN + 15.06.2026;50,00;Mitgliedsbeitrag;Max Mustermann;DE89370400440532013000 + 14.06.2026;-30,00;Stromrechnung;Stadtwerke;DE11111111111111111111 + """; + + ParseResult result = parse(csv); + + assertThat(result.transactions()).hasSize(2); + ParsedTransaction tx1 = result.transactions().get(0); + assertThat(tx1.bookingDate()).isEqualTo(LocalDate.of(2026, 6, 15)); + assertThat(tx1.amountCents()).isEqualTo(5000); + assertThat(tx1.referenceText()).isEqualTo("Mitgliedsbeitrag"); + assertThat(tx1.counterpartyName()).isEqualTo("Max Mustermann"); + assertThat(tx1.counterpartyIban()).isEqualTo("DE89370400440532013000"); + + ParsedTransaction tx2 = result.transactions().get(1); + assertThat(tx2.amountCents()).isEqualTo(-3000); + } + + @Test + @DisplayName("#5 Quoted fields with embedded separators") + void testParse_QuotedFieldsWithSeparators_ParsedCorrectly() { + String csv = """ + Datum;Betrag;Verwendungszweck;Name;IBAN + 10.06.2026;100,00;"Beitrag; Juni 2026";Hans Schmidt;DE89370400440532013000 + """; + + ParseResult result = parse(csv); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.transactions().get(0).referenceText()).isEqualTo("Beitrag; Juni 2026"); + } + + @Test + @DisplayName("#6 Tab-separated variant") + void testParse_TabSeparated_ParsedCorrectly() { + CsvColumnMapping tabMapping = new CsvColumnMapping(); + tabMapping.setName("Tab-separated"); + tabMapping.setDateColumn(0); + tabMapping.setAmountColumn(1); + tabMapping.setReferenceColumn(2); + tabMapping.setDelimiter("\\t"); + tabMapping.setDateFormat("dd.MM.yyyy"); + tabMapping.setDecimalSeparator(","); + tabMapping.setSkipHeaderRows(1); + tabMapping.setEncoding("UTF-8"); + + String csv = "Datum\tBetrag\tVerwendungszweck\n" + + "15.06.2026\t42,50\tMitgliedsbeitrag\n"; + + ParseResult result = parse(csv, tabMapping); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.transactions().get(0).amountCents()).isEqualTo(4250); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Edge cases + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Edge cases") + class EdgeCases { + + @Test + @DisplayName("#7 BOM (byte order mark) at start of file") + void testParse_Bom_HandledGracefully() { + // UTF-8 BOM: EF BB BF — skip it by using a mapping that skips 1 header row + // The BOM will be on the header row which gets skipped + String csvWithBom = "\uFEFF" + """ + Datum;Betrag;Ref + 15.06.2026;25,00;Beitrag + """; + + CsvColumnMapping mapping = new CsvColumnMapping(); + mapping.setName("BOM test"); + mapping.setDateColumn(0); + mapping.setAmountColumn(1); + mapping.setReferenceColumn(2); + mapping.setDelimiter(";"); + mapping.setDateFormat("dd.MM.yyyy"); + mapping.setDecimalSeparator(","); + mapping.setSkipHeaderRows(1); + mapping.setEncoding("UTF-8"); + + ParseResult result = parse(csvWithBom, mapping); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.transactions().get(0).amountCents()).isEqualTo(2500); + } + + @Test + @DisplayName("#8 Empty lines handling") + void testParse_EmptyLines_Ignored() { + String csv = """ + Datum;Betrag;Ref;Name;IBAN + 15.06.2026;10,00;Ref1;Alice;DE11111111111111111111 + + 16.06.2026;20,00;Ref2;Bob;DE22222222222222222222 + + """; + + ParseResult result = parse(csv); + + assertThat(result.transactions()).hasSize(2); + } + + @Test + @DisplayName("#9 Header-only file (no data rows)") + void testParse_HeaderOnly_EmptyResult() { + String csv = "Datum;Betrag;Verwendungszweck;Name;IBAN\n"; + + ParseResult result = parse(csv); + + assertThat(result.transactions()).isEmpty(); + } + + @Test + @DisplayName("#10 Null mapping throws BankStatementParseException") + void testParse_NullMapping_Throws() { + InputStream is = new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> parser.parse(is, "test.csv", null)) + .isInstanceOf(BankStatementParseException.class) + .hasMessageContaining("CsvColumnMapping"); + } + + @Test + @DisplayName("#11 Large file (1000 rows) completes without error") + void testParse_LargeFile_CompletesSuccessfully() { + StringBuilder csv = new StringBuilder("Datum;Betrag;Ref;Name;IBAN\n"); + for (int i = 0; i < 1000; i++) { + csv.append(String.format("15.06.2026;%d,00;REF%d;Member%d;DE89370400440532013000%n", + i + 1, i, i)); + } + + ParseResult result = parse(csv.toString()); + + assertThat(result.transactions()).hasSize(1000); + } + + @Test + @DisplayName("#12 Wrong encoding detection falls back to ISO-8859-1") + void testParse_UnknownEncoding_FallsBackToIso() { + CsvColumnMapping mapping = new CsvColumnMapping(); + mapping.setName("Bad encoding"); + mapping.setDateColumn(0); + mapping.setAmountColumn(1); + mapping.setDelimiter(";"); + mapping.setDateFormat("dd.MM.yyyy"); + mapping.setDecimalSeparator(","); + mapping.setSkipHeaderRows(0); + mapping.setEncoding("TOTALLY-INVALID-CHARSET"); + + // ISO-8859-1 encoded content should still parse fine with fallback + String csv = "15.06.2026;99,00\n"; + InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.ISO_8859_1)); + + ParseResult result = parser.parse(is, "test.csv", mapping); + + assertThat(result.transactions()).hasSize(1); + assertThat(result.transactions().get(0).amountCents()).isEqualTo(9900); + } + + @Test + @DisplayName("#13 Trailing newlines don't produce extra transactions") + void testParse_TrailingNewlines_NoExtraTransactions() { + String csv = "Datum;Betrag;Ref;Name;IBAN\n" + + "15.06.2026;50,00;Ref1;Alice;DE11111111111111111111\n" + + "\n\n\n"; + + ParseResult result = parse(csv); + + assertThat(result.transactions()).hasSize(1); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Unit: parseAmount + // ───────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("parseAmount") + class AmountParsing { + + @Test + @DisplayName("#14 German amounts with comma decimal separator") + void testParseAmount_GermanFormat() { + assertThat(CsvBankParser.parseAmount("1.234,56", ',')).isEqualTo(123456); + assertThat(CsvBankParser.parseAmount("-30,00", ',')).isEqualTo(-3000); + assertThat(CsvBankParser.parseAmount("100", ',')).isEqualTo(10000); + assertThat(CsvBankParser.parseAmount("0,5", ',')).isEqualTo(50); + assertThat(CsvBankParser.parseAmount("+42,99", ',')).isEqualTo(4299); + } + } +} diff --git a/pom.xml b/pom.xml index a60e23d..156beef 100644 --- a/pom.xml +++ b/pom.xml @@ -31,7 +31,7 @@ UTF-8 UTF-8 - 1.20.4 + 1.21.3 0.8.13 0.80