feat: Sprint 11 test coverage — +166 unit tests, schema drift fix (V34), Testcontainers 1.21.3
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:
Patrick Plate
2026-06-17 21:38:32 +02:00
parent f1959eb3d2
commit fa567c1c3f
22 changed files with 4472 additions and 4 deletions
@@ -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());
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}