feat: Sprint 11 test coverage — +166 unit tests, schema drift fix (V34), Testcontainers 1.21.3
Deploy to TrueNAS / deploy (push) Failing after 2m11s
Deploy to TrueNAS / deploy (push) Failing after 2m11s
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)
This commit is contained in:
@@ -10,7 +10,9 @@ CREATE TABLE generated_reports (
|
|||||||
storage_path VARCHAR(500),
|
storage_path VARCHAR(500),
|
||||||
parameters JSONB,
|
parameters JSONB,
|
||||||
generated_by UUID NOT NULL REFERENCES users(id),
|
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);
|
CREATE INDEX idx_generated_reports_tenant ON generated_reports(tenant_id);
|
||||||
|
|||||||
@@ -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);
|
||||||
+139
@@ -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<String> errors = (List<String>) 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:");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -8,6 +8,7 @@ import de.cannamanage.api.dto.stock.BatchResponse;
|
|||||||
import de.cannamanage.api.dto.stock.CreateBatchRequest;
|
import de.cannamanage.api.dto.stock.CreateBatchRequest;
|
||||||
import de.cannamanage.domain.entity.Club;
|
import de.cannamanage.domain.entity.Club;
|
||||||
import de.cannamanage.domain.entity.Member;
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
import de.cannamanage.domain.entity.User;
|
import de.cannamanage.domain.entity.User;
|
||||||
import de.cannamanage.domain.enums.ClubStatus;
|
import de.cannamanage.domain.enums.ClubStatus;
|
||||||
import de.cannamanage.domain.enums.UserRole;
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
@@ -105,16 +106,23 @@ public abstract class AbstractIntegrationTest {
|
|||||||
// --- Test data creation helpers ---
|
// --- 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) {
|
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 club = new Club();
|
||||||
club.setName(name);
|
club.setName(name);
|
||||||
|
club.setLicenseNumber("LIC-" + UUID.randomUUID().toString().substring(0, 8));
|
||||||
club.setStatus(ClubStatus.ACTIVE);
|
club.setStatus(ClubStatus.ACTIVE);
|
||||||
club.setMaxMembers(500);
|
club.setMaxMembers(500);
|
||||||
club.setMaxPreventionOfficers(3);
|
club.setMaxPreventionOfficers(3);
|
||||||
club = clubRepository.save(club);
|
club = clubRepository.save(club);
|
||||||
return club.getId();
|
// TenantContext remains set — @PrePersist will use it for subsequent entities
|
||||||
|
return tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+406
@@ -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<String, Object> 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<String> 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<String> 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<String> 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<String, Object> voteRequest = Map.of(
|
||||||
|
"agendaItemId", agendaItemId,
|
||||||
|
"title", "Vorstandswahl Abstimmung",
|
||||||
|
"description", "Wahl des neuen Vorstands",
|
||||||
|
"voteType", "SIMPLE_MAJORITY"
|
||||||
|
);
|
||||||
|
|
||||||
|
ResponseEntity<String> 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<String> 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<String> 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<String, Object> createRequest = Map.of(
|
||||||
|
"title", "Quorum Test Assembly",
|
||||||
|
"assemblyType", "REGULAR",
|
||||||
|
"scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(),
|
||||||
|
"location", "Online",
|
||||||
|
"quorumRequired", 3
|
||||||
|
);
|
||||||
|
|
||||||
|
ResponseEntity<String> 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<String> 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<String, Object> createRequest = Map.of(
|
||||||
|
"title", "Außerordentliche Versammlung",
|
||||||
|
"assemblyType", "EXTRAORDINARY",
|
||||||
|
"scheduledAt", Instant.now().plus(2, ChronoUnit.DAYS).toString(),
|
||||||
|
"location", "Vereinsheim",
|
||||||
|
"quorumRequired", 2
|
||||||
|
);
|
||||||
|
|
||||||
|
ResponseEntity<String> 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<String> detailResponse = restClient().get()
|
||||||
|
.uri("/api/v1/assemblies/" + assemblyId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
String agendaItemId = extractFirstAgendaItemId(detailResponse.getBody());
|
||||||
|
|
||||||
|
// Create vote
|
||||||
|
Map<String, Object> voteRequest = Map.of(
|
||||||
|
"agendaItemId", agendaItemId,
|
||||||
|
"title", "Majority Test",
|
||||||
|
"description", "Testing exact majority threshold",
|
||||||
|
"voteType", "SIMPLE_MAJORITY"
|
||||||
|
);
|
||||||
|
|
||||||
|
ResponseEntity<String> 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<String> 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<byte[]> 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<String, Object> request = Map.of("memberId", memberId.toString());
|
||||||
|
|
||||||
|
ResponseEntity<String> 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<String, Object> request = Map.of(
|
||||||
|
"memberId", memberId.toString(),
|
||||||
|
"decision", decision
|
||||||
|
);
|
||||||
|
|
||||||
|
ResponseEntity<String> 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<String, Object> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+309
@@ -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<DistributionResponse> 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<DistributionResponse> 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<DistributionResponse> 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<String> 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<DistributionResponse> 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<String> 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<String> 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<String> 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<DistributionResponse> 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<DistributionResponse> 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<String> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -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<String> 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<String> 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<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> permissions = List.of("MANAGE_MEMBERS", "VIEW_FINANCES", "MANAGE_GROW");
|
||||||
|
String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de", permissions);
|
||||||
|
|
||||||
|
List<String> extracted = jwtService.extractPermissions(token);
|
||||||
|
assertThat(extracted).containsExactlyInAnyOrderElementsOf(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testExtractPermissions_nonStaffToken_returnsEmptyList() {
|
||||||
|
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "admin@club.de");
|
||||||
|
|
||||||
|
List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Files> 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<Files> 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<Files> 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<Files> 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<Files> 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<Files> 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<Files> 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<Files> 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<Files> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ClubEvent> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ForumReaction> 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<ForumReaction> 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<ForumReaction> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+354
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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", "<?xml version=\"1.0\"?><BkToCstmrStmt/>".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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||||
|
<BkToCstmrStmt></BkToCstmrStmt>
|
||||||
|
</Document>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||||
|
<BkToCstmrStmt>
|
||||||
|
<Stmt>
|
||||||
|
<Id>S1</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">42.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2026-06-15</Dt></BookgDt>
|
||||||
|
<NtryRef>REF1</NtryRef>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>
|
||||||
|
""";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+493
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||||
|
<BkToCstmrStmt>
|
||||||
|
""";
|
||||||
|
private static final String CAMT_FOOTER = """
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static String stmt(String iban, String entries) {
|
||||||
|
return """
|
||||||
|
<Stmt>
|
||||||
|
<Id>STMT001</Id>
|
||||||
|
<Acct><Id><IBAN>%s</IBAN></Id></Acct>
|
||||||
|
<Bal>
|
||||||
|
<Tp><Cd>OPBD</Cd></Tp>
|
||||||
|
<Amt Ccy="EUR">1000.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Dt><Dt>2026-06-01</Dt></Dt>
|
||||||
|
</Bal>
|
||||||
|
<Bal>
|
||||||
|
<Tp><Cd>CLBD</Cd></Tp>
|
||||||
|
<Amt Ccy="EUR">1050.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<Dt><Dt>2026-06-15</Dt></Dt>
|
||||||
|
</Bal>
|
||||||
|
%s
|
||||||
|
</Stmt>
|
||||||
|
""".formatted(iban, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String entry(String amount, String cdtDbt, String date, String ref, String name) {
|
||||||
|
return """
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">%s</Amt>
|
||||||
|
<CdtDbtInd>%s</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>%s</Dt></BookgDt>
|
||||||
|
<ValDt><Dt>%s</Dt></ValDt>
|
||||||
|
<NtryRef>%s</NtryRef>
|
||||||
|
<NtryDtls><TxDtls>
|
||||||
|
<RmtInf><Ustrd>Mitgliedsbeitrag Juni</Ustrd></RmtInf>
|
||||||
|
<RltdPties><Dbtr><Nm>%s</Nm></Dbtr></RltdPties>
|
||||||
|
</TxDtls></NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
""".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 = "<?xml version=\"1.0\"?><Doc xmlns=\"urn:iso:std:iso:20022:tech:xsd:camt.053.001.08\">".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 + """
|
||||||
|
<Stmt>
|
||||||
|
<Id>EMPTY</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
</Stmt>
|
||||||
|
""" + 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 + """
|
||||||
|
<Stmt>
|
||||||
|
<Id>S1</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">50.00</Amt>
|
||||||
|
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||||
|
<NtryRef>REF_INCOMPLETE</NtryRef>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
""" + 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 + """
|
||||||
|
<Stmt>
|
||||||
|
<Id>S1</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="CHF">100.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||||
|
<NtryRef>CHF_REF</NtryRef>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
""" + 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 + """
|
||||||
|
<Stmt>
|
||||||
|
<Id>S1</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">42.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2026-06-10T14:30:00</Dt></BookgDt>
|
||||||
|
<NtryRef>DATETIME_REF</NtryRef>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
""" + 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 + """
|
||||||
|
<Stmt>
|
||||||
|
<Id>S1</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">15.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||||
|
<NtryRef>UMLAUT</NtryRef>
|
||||||
|
<NtryDtls><TxDtls>
|
||||||
|
<RmtInf><Ustrd>Überweisung für Größe</Ustrd></RmtInf>
|
||||||
|
<RltdPties><Dbtr><Nm>Jürgen Müller-Straße</Nm></Dbtr></RltdPties>
|
||||||
|
</TxDtls></NtryDtls>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
""" + 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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE foo [
|
||||||
|
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
||||||
|
]>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||||
|
<BkToCstmrStmt>
|
||||||
|
<Stmt>
|
||||||
|
<Id>CLEAN</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">10.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||||
|
<NtryRef>SAFE_REF</NtryRef>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>
|
||||||
|
""";
|
||||||
|
|
||||||
|
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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE lolz [
|
||||||
|
<!ENTITY lol "lol">
|
||||||
|
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
|
||||||
|
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
|
||||||
|
]>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||||
|
<BkToCstmrStmt>
|
||||||
|
<Stmt>
|
||||||
|
<Id>SAFE</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>
|
||||||
|
""";
|
||||||
|
|
||||||
|
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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE foo [
|
||||||
|
<!ENTITY xxe SYSTEM "http://evil.com/secret">
|
||||||
|
]>
|
||||||
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||||
|
<BkToCstmrStmt>
|
||||||
|
<Stmt>
|
||||||
|
<Id>SAFE</Id>
|
||||||
|
<Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>
|
||||||
|
<Ntry>
|
||||||
|
<Amt Ccy="EUR">5.00</Amt>
|
||||||
|
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||||
|
<BookgDt><Dt>2026-06-10</Dt></BookgDt>
|
||||||
|
<NtryRef>SAFE</NtryRef>
|
||||||
|
</Ntry>
|
||||||
|
</Stmt>
|
||||||
|
</BkToCstmrStmt>
|
||||||
|
</Document>
|
||||||
|
""";
|
||||||
|
|
||||||
|
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("<Stmt><Id>LARGE</Id><Acct><Id><IBAN>DE89370400440532013000</IBAN></Id></Acct>");
|
||||||
|
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("</Stmt>");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+295
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
<!-- Testcontainers -->
|
<!-- Testcontainers -->
|
||||||
<testcontainers.version>1.20.4</testcontainers.version>
|
<testcontainers.version>1.21.3</testcontainers.version>
|
||||||
<!-- JaCoCo (Sprint 11: pragmatic 80% bundle target, per-package rules below) -->
|
<!-- JaCoCo (Sprint 11: pragmatic 80% bundle target, per-package rules below) -->
|
||||||
<jacoco.version>0.8.13</jacoco.version>
|
<jacoco.version>0.8.13</jacoco.version>
|
||||||
<jacoco.minimum.coverage>0.80</jacoco.minimum.coverage>
|
<jacoco.minimum.coverage>0.80</jacoco.minimum.coverage>
|
||||||
|
|||||||
Reference in New Issue
Block a user