From 581c67b5ef38d0803777165d7b54055abf81c7f6 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Fri, 24 Apr 2026 23:38:49 -0500 Subject: [PATCH 1/2] feat(tests): add SessionServiceTest - add methods tests create, validation, revoke sessions --- .../auth/service/SessionServiceTest.java | 469 ++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java diff --git a/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java b/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java new file mode 100644 index 0000000..26c137c --- /dev/null +++ b/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java @@ -0,0 +1,469 @@ +package com.wallet.secure.auth.service; + +import com.wallet.secure.auth.dto.SessionResponse; +import com.wallet.secure.auth.entity.Session; +import com.wallet.secure.auth.repository.SessionRepository; +import com.wallet.secure.auth.security.JwtService; +import com.wallet.secure.common.exception.InvalidCredentialsException; +import com.wallet.secure.common.exception.ResourceNotFoundException; +import com.wallet.secure.common.response.ApiResponse; +import com.wallet.secure.user.entity.User; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for SessionService. + * + * WHAT we test: + * 1. createSession() — builds entity with correct fields and hashes the token + * 2. validateSession() — rejects unknown, revoked and expired sessions + * 3. revokeByToken() — marks session as revoked, no-op when not found + * 4. revokeAllSessions() — delegates bulk update to repository + * 5. revokeSessionById() — ownership check (OWASP A01), already-revoked guard + * 6. getActiveSessions() — flags current session, handles null token + * 7. hashToken() — deterministic, 64-char hex output (SHA-256) + * + * WHAT we do NOT test: + * → @Transactional behavior — requires Spring context + * → Real SHA-256 correctness — guaranteed by the JVM spec + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("SessionService") +class SessionServiceTest { + + @Mock private SessionRepository sessionRepository; + @Mock private JwtService jwtService; + + @InjectMocks + private SessionService sessionService; + + // ─── Shared test data + + private User testUser; + private UUID userId; + private UUID sessionId; + + private static final String RAW_TOKEN = "test.refresh.token.device.A"; + private static final String OTHER_TOKEN = "test.refresh.token.device.B"; + private static final String TEST_IP = "192.168.1.100"; + private static final String TEST_UA = "Mozilla/5.0 Test"; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + sessionId = UUID.randomUUID(); + + testUser = User.builder() + .id(userId) + .email("angel@test.com") + .passwordHash("$2a$12$hashedpassword") + .build(); + } + + // ─── Helper — builds a minimal valid Session + + private Session buildActiveSession(String rawToken) { + String hash = sessionService.hashToken(rawToken); + return Session.builder() + .user(testUser) + .tokenHash(hash) + .ipAddress(TEST_IP) + .userAgent(TEST_UA) + .expiresAt(Instant.now().plusSeconds(3600)) // expires in 1 hour + .build(); + } + + private Session buildRevokedSession(String rawToken) { + Session session = buildActiveSession(rawToken); + session.revokeNow(); + return session; + } + + private Session buildExpiredSession(String rawToken) { + String hash = sessionService.hashToken(rawToken); + return Session.builder() + .user(testUser) + .tokenHash(hash) + .ipAddress(TEST_IP) + .userAgent(TEST_UA) + .expiresAt(Instant.now().minusSeconds(60)) // expired 1 minute ago + .build(); + } + + // ─── createSession() + + @Nested + @DisplayName("createSession()") + class CreateSessionTests { + + @Test + @DisplayName("saves session with hashed token — raw token never stored in DB") + void createSession_savesHashedToken() { + // GIVEN + lenient().when(jwtService.getRefreshExpirationMs()).thenReturn(86_400_000L); // 1 day + when(sessionRepository.save(any(Session.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + // WHEN + sessionService.createSession(testUser, RAW_TOKEN, TEST_IP, TEST_UA); + + // THEN — capture what was saved + ArgumentCaptor captor = ArgumentCaptor.forClass(Session.class); + verify(sessionRepository).save(captor.capture()); + + Session saved = captor.getValue(); + // OWASP A02: raw token is NEVER stored — only the SHA-256 hash + assertThat(saved.getTokenHash()).isNotEqualTo(RAW_TOKEN); + assertThat(saved.getTokenHash()).isEqualTo(sessionService.hashToken(RAW_TOKEN)); + assertThat(saved.getTokenHash()).hasSize(64); // SHA-256 in hex = 64 chars + } + + @Test + @DisplayName("saves session with correct user, ip and userAgent") + void createSession_savesCorrectContext() { + // GIVEN + lenient().when(jwtService.getRefreshExpirationMs()).thenReturn(86_400_000L); + when(sessionRepository.save(any(Session.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + // WHEN + sessionService.createSession(testUser, RAW_TOKEN, TEST_IP, TEST_UA); + + // THEN + ArgumentCaptor captor = ArgumentCaptor.forClass(Session.class); + verify(sessionRepository).save(captor.capture()); + + Session saved = captor.getValue(); + assertThat(saved.getUser()).isEqualTo(testUser); + assertThat(saved.getIpAddress()).isEqualTo(TEST_IP); + assertThat(saved.getUserAgent()).isEqualTo(TEST_UA); + assertThat(saved.getExpiresAt()).isAfter(Instant.now()); + } + } + + // ─── validateSession() + + @Nested + @DisplayName("validateSession()") + class ValidateSessionTests { + + @Test + @DisplayName("returns session when token is valid and not expired") + void validateSession_validToken_returnsSession() { + // GIVEN + Session activeSession = buildActiveSession(RAW_TOKEN); + when(sessionRepository.findByTokenHash(sessionService.hashToken(RAW_TOKEN))) + .thenReturn(Optional.of(activeSession)); + + // WHEN + Session result = sessionService.validateSession(RAW_TOKEN); + + // THEN + assertThat(result).isEqualTo(activeSession); + } + + @Test + @DisplayName("throws when session is not found — possible token reuse after logout") + void validateSession_sessionNotFound_throwsException() { + // GIVEN — token was never issued or was deleted + when(sessionRepository.findByTokenHash(any())).thenReturn(Optional.empty()); + + // WHEN / THEN + // OWASP A07: unknown token = definitive rejection + assertThatThrownBy(() -> sessionService.validateSession(RAW_TOKEN)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("revoked"); + } + + @Test + @DisplayName("throws when session is revoked — user already logged out") + void validateSession_revokedSession_throwsException() { + // GIVEN — user called logout → session was marked revoked + Session revokedSession = buildRevokedSession(RAW_TOKEN); + when(sessionRepository.findByTokenHash(any())) + .thenReturn(Optional.of(revokedSession)); + + // WHEN / THEN + // OWASP A07: revoked token cannot refresh — even with valid JWT signature + assertThatThrownBy(() -> sessionService.validateSession(RAW_TOKEN)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("revoked or expired"); + } + + @Test + @DisplayName("throws when session is expired — natural expiration") + void validateSession_expiredSession_throwsException() { + // GIVEN — session was issued long ago and expiresAt < now + Session expiredSession = buildExpiredSession(RAW_TOKEN); + when(sessionRepository.findByTokenHash(any())) + .thenReturn(Optional.of(expiredSession)); + + // WHEN / THEN + assertThatThrownBy(() -> sessionService.validateSession(RAW_TOKEN)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("revoked or expired"); + } + } + + // ─── revokeByToken() + + @Nested + @DisplayName("revokeByToken()") + class RevokeByTokenTests { + + @Test + @DisplayName("marks session as revoked when found") + void revokeByToken_sessionFound_revokesSession() { + // GIVEN + Session activeSession = buildActiveSession(RAW_TOKEN); + when(sessionRepository.findByTokenHash(sessionService.hashToken(RAW_TOKEN))) + .thenReturn(Optional.of(activeSession)); + when(sessionRepository.save(any(Session.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + // WHEN + sessionService.revokeByToken(RAW_TOKEN); + + // THEN + assertThat(activeSession.isRevoked()).isTrue(); + assertThat(activeSession.getRevokedAt()).isNotNull(); + verify(sessionRepository).save(activeSession); + } + + @Test + @DisplayName("does nothing (no-op) when session is not found — safe to call twice") + void revokeByToken_sessionNotFound_doesNotThrow() { + // GIVEN — token may have already expired and been cleaned up + when(sessionRepository.findByTokenHash(any())).thenReturn(Optional.empty()); + + // WHEN / THEN — must not throw; idempotent revocation + assertThatNoException().isThrownBy(() -> sessionService.revokeByToken(RAW_TOKEN)); + verify(sessionRepository, never()).save(any()); + } + } + + // ─── revokeAllSessions() + + @Nested + @DisplayName("revokeAllSessions()") + class RevokeAllSessionsTests { + + @Test + @DisplayName("delegates bulk revocation to repository with userId and current time") + void revokeAllSessions_delegatesToRepository() { + // GIVEN — no return value needed for @Modifying query + doNothing().when(sessionRepository) + .revokeAllActiveSessionsForUser(any(UUID.class), any(Instant.class)); + + // WHEN + sessionService.revokeAllSessions(userId); + + // THEN — bulk update is issued once with the correct userId + ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); + verify(sessionRepository).revokeAllActiveSessionsForUser(eq(userId), timeCaptor.capture()); + + // The Instant passed must be "now" — allow 5 seconds of tolerance + Instant revokedAt = timeCaptor.getValue(); + assertThat(revokedAt).isBefore(Instant.now().plusSeconds(1)); + assertThat(revokedAt).isAfter(Instant.now().minusSeconds(5)); + } + } + + // ─── revokeSessionById() + + @Nested + @DisplayName("revokeSessionById()") + class RevokeSessionByIdTests { + + @Test + @DisplayName("revokes session when it belongs to the requesting user — OWASP A01") + void revokeSessionById_owner_revokesSuccessfully() { + // GIVEN + Session activeSession = buildActiveSession(RAW_TOKEN); + when(sessionRepository.findById(sessionId)) + .thenReturn(Optional.of(activeSession)); + when(sessionRepository.save(any(Session.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + // Inject a UUID field into the session for the test + // Session.id is set by the DB — we need to simulate it here + // We test ownership via User.id comparison, not Session.id + + // WHEN + ApiResponse response = sessionService.revokeSessionById(sessionId, userId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(activeSession.isRevoked()).isTrue(); + } + + @Test + @DisplayName("throws 404 when session does not exist") + void revokeSessionById_sessionNotFound_throwsNotFoundException() { + // GIVEN + when(sessionRepository.findById(sessionId)).thenReturn(Optional.empty()); + + // WHEN / THEN + assertThatThrownBy(() -> sessionService.revokeSessionById(sessionId, userId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Session not found"); + } + + @Test + @DisplayName("throws 401 when session belongs to a different user — OWASP A01") + void revokeSessionById_differentUser_throwsUnauthorized() { + // GIVEN — session belongs to testUser but attacker uses their own userId + UUID attackerId = UUID.randomUUID(); + Session victimSession = buildActiveSession(RAW_TOKEN); + + when(sessionRepository.findById(sessionId)) + .thenReturn(Optional.of(victimSession)); + + // WHEN / THEN + // OWASP A01: user cannot revoke another user's session + assertThatThrownBy(() -> sessionService.revokeSessionById(sessionId, attackerId)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("Not authorized"); + + verify(sessionRepository, never()).save(any()); + } + + @Test + @DisplayName("returns success without re-revoking when session is already revoked") + void revokeSessionById_alreadyRevoked_returnsSuccessIdempotent() { + // GIVEN — session already revoked (user called this endpoint twice) + Session revokedSession = buildRevokedSession(RAW_TOKEN); + when(sessionRepository.findById(sessionId)) + .thenReturn(Optional.of(revokedSession)); + + // WHEN + ApiResponse response = sessionService.revokeSessionById(sessionId, userId); + + // THEN — idempotent: success, but no extra save() + assertThat(response.isSuccess()).isTrue(); + verify(sessionRepository, never()).save(any()); + } + } + + // ─── getActiveSessions() + + @Nested + @DisplayName("getActiveSessions()") + class GetActiveSessionsTests { + + @Test + @DisplayName("flags the session matching currentRefreshToken as current=true") + void getActiveSessions_withMatchingToken_flagsCurrentSession() { + // GIVEN — two active sessions; first one belongs to the current request + Session currentSession = buildActiveSession(RAW_TOKEN); + Session otherSession = buildActiveSession(OTHER_TOKEN); + + when(sessionRepository.findActiveSessionsByUserId(eq(userId), any(Instant.class))) + .thenReturn(List.of(currentSession, otherSession)); + + // WHEN + ApiResponse> response = + sessionService.getActiveSessions(userId, RAW_TOKEN); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).hasSize(2); + + // The session that matches the current token must be flagged + SessionResponse current = response.getData().get(0); + SessionResponse other = response.getData().get(1); + + assertThat(current.isCurrent()).isTrue(); + assertThat(other.isCurrent()).isFalse(); + } + + @Test + @DisplayName("returns current=false for all sessions when currentRefreshToken is null") + void getActiveSessions_nullToken_noSessionFlagged() { + // GIVEN — caller did not provide a current token (e.g. admin lookup) + Session session = buildActiveSession(RAW_TOKEN); + when(sessionRepository.findActiveSessionsByUserId(eq(userId), any(Instant.class))) + .thenReturn(List.of(session)); + + // WHEN + ApiResponse> response = + sessionService.getActiveSessions(userId, null); + + // THEN — null token → no session can be flagged as current + assertThat(response.getData().get(0).isCurrent()).isFalse(); + } + + @Test + @DisplayName("returns empty list when user has no active sessions") + void getActiveSessions_noSessions_returnsEmptyList() { + // GIVEN + when(sessionRepository.findActiveSessionsByUserId(eq(userId), any(Instant.class))) + .thenReturn(List.of()); + + // WHEN + ApiResponse> response = + sessionService.getActiveSessions(userId, null); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEmpty(); + } + } + + // ─── hashToken() + + @Nested + @DisplayName("hashToken()") + class HashTokenTests { + + @Test + @DisplayName("returns 64-character lowercase hex string — SHA-256 output") + void hashToken_returnsCorrectFormat() { + // WHEN + String hash = sessionService.hashToken(RAW_TOKEN); + + // THEN — SHA-256 produces 32 bytes = 64 hex chars + assertThat(hash).hasSize(64); + assertThat(hash).matches("[0-9a-f]{64}"); + } + + @Test + @DisplayName("is deterministic — same input always produces the same hash") + void hashToken_deterministic_sameInputSameOutput() { + // WHEN + String hash1 = sessionService.hashToken(RAW_TOKEN); + String hash2 = sessionService.hashToken(RAW_TOKEN); + + // THEN — SHA-256 is deterministic by definition + // OWASP A02: lookup by hash only works if hash is reproducible + assertThat(hash1).isEqualTo(hash2); + } + + @Test + @DisplayName("different tokens produce different hashes — no collisions") + void hashToken_differentInputs_differentHashes() { + // WHEN + String hash1 = sessionService.hashToken(RAW_TOKEN); + String hash2 = sessionService.hashToken(OTHER_TOKEN); + + // THEN — collision resistance: distinct tokens must not share a hash + assertThat(hash1).isNotEqualTo(hash2); + } + } +} From 6f1ee79e4c10f0744caa36b987a6e4a822cd0573 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Sat, 25 Apr 2026 00:07:33 -0500 Subject: [PATCH 2/2] feat(tests): add TransactionHistoryServiceTest with methods logic service and delete WalletApplicationTests --- .../wallet/secure/WalletApplicationTests.java | 13 - .../TransactionHistoryServiceTest.java | 359 ++++++++++++++++++ 2 files changed, 359 insertions(+), 13 deletions(-) delete mode 100644 src/test/java/com/wallet/secure/WalletApplicationTests.java create mode 100644 src/test/java/com/wallet/secure/transaction/service/TransactionHistoryServiceTest.java diff --git a/src/test/java/com/wallet/secure/WalletApplicationTests.java b/src/test/java/com/wallet/secure/WalletApplicationTests.java deleted file mode 100644 index d828ef4..0000000 --- a/src/test/java/com/wallet/secure/WalletApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.wallet.secure; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class WalletApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/wallet/secure/transaction/service/TransactionHistoryServiceTest.java b/src/test/java/com/wallet/secure/transaction/service/TransactionHistoryServiceTest.java new file mode 100644 index 0000000..bf666bc --- /dev/null +++ b/src/test/java/com/wallet/secure/transaction/service/TransactionHistoryServiceTest.java @@ -0,0 +1,359 @@ +package com.wallet.secure.transaction.service; + +import com.wallet.secure.common.enums.TransactionStatus; +import com.wallet.secure.common.exception.ResourceNotFoundException; +import com.wallet.secure.common.response.ApiResponse; +import com.wallet.secure.transaction.dto.TransactionHistoryResponse; +import com.wallet.secure.transaction.entity.Transaction; +import com.wallet.secure.transaction.entity.TransactionHistory; +import com.wallet.secure.transaction.repository.TransactionHistoryRepository; +import com.wallet.secure.transaction.repository.TransactionRepository; +import com.wallet.secure.user.entity.User; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for TransactionHistoryService. + * + * WHAT we test: + * 1. record() — persists a system entry with correct fields + * 2. recordManual() — persists a human entry with changedBy + reason + * 3. getTransactionTimeline() — ownership check (OWASP A01), not found, returns list + * 4. getTransactionTimelineAdmin() — no ownership check, not found, returns list + * 5. getWalletHistory() — delegates to repository, maps to response list + * + * WHAT we do NOT test: + * → @Transactional behavior — requires Spring context + * → fromEntity() mapping detail — covered implicitly via response assertions + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("TransactionHistoryService") +class TransactionHistoryServiceTest { + + @Mock private TransactionHistoryRepository historyRepository; + @Mock private TransactionRepository transactionRepository; + + @InjectMocks + private TransactionHistoryService historyService; + + // ─── Shared test data + + private UUID userId; + private UUID transactionId; + private UUID walletId; + private Transaction testTransaction; + private User testUser; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + transactionId = UUID.randomUUID(); + walletId = UUID.randomUUID(); + + testUser = User.builder() + .id(userId) + .email("angel@test.com") + .passwordHash("$2a$12$hash") + .build(); + + testTransaction = Transaction.builder() + .id(transactionId) + .build(); + + // save() returns what is passed — standard repository stub + lenient().when(historyRepository.save(any(TransactionHistory.class))) + .thenAnswer(inv -> inv.getArgument(0)); + } + + // ─── Helper — builds a minimal TransactionHistory entity + + private TransactionHistory buildSystemEntry(TransactionStatus oldStatus, + TransactionStatus newStatus) { + return TransactionHistory.system(testTransaction, oldStatus, newStatus); + } + + private TransactionHistory buildManualEntry(TransactionStatus oldStatus, + TransactionStatus newStatus) { + return TransactionHistory.manual( + testTransaction, oldStatus, newStatus, testUser, "Reversed by admin"); + } + + // ─── record() + + @Nested + @DisplayName("record()") + class RecordTests { + + @Test + @DisplayName("saves a system entry with correct transaction, oldStatus and newStatus") + void record_savesSystemEntry() { + // WHEN + historyService.record(testTransaction, null, TransactionStatus.PENDING); + + // THEN — capture what was passed to save() + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionHistory.class); + verify(historyRepository).save(captor.capture()); + + TransactionHistory saved = captor.getValue(); + assertThat(saved.getTransaction()).isEqualTo(testTransaction); + assertThat(saved.getOldStatus()).isNull(); // initial entry + assertThat(saved.getNewStatus()).isEqualTo(TransactionStatus.PENDING); + assertThat(saved.getChangedBy()).isNull(); // system = no actor + assertThat(saved.getReason()).isNull(); // system = no reason + } + + @Test + @DisplayName("saves each lifecycle step — PENDING→PROCESSING→COMPLETED trail") + void record_savesEachLifecycleStep() { + // WHEN — three steps of a normal transaction lifecycle + historyService.record(testTransaction, null, TransactionStatus.PENDING); + historyService.record(testTransaction, TransactionStatus.PENDING, TransactionStatus.PROCESSING); + historyService.record(testTransaction, TransactionStatus.PROCESSING, TransactionStatus.COMPLETED); + + // THEN — one save() call per step + verify(historyRepository, times(3)).save(any(TransactionHistory.class)); + } + + @Test + @DisplayName("does not throw when called fire-and-forget — OWASP A09 resilience") + void record_doesNotThrow() { + // WHEN / THEN — history recording must never crash the business operation + assertThatNoException().isThrownBy(() -> + historyService.record(testTransaction, + TransactionStatus.PENDING, TransactionStatus.FAILED)); + } + } + + // ─── recordManual() + + @Nested + @DisplayName("recordManual()") + class RecordManualTests { + + @Test + @DisplayName("saves a manual entry with changedBy user and reason") + void recordManual_savesHumanEntry() { + // WHEN + historyService.recordManual( + testTransaction, + TransactionStatus.COMPLETED, + TransactionStatus.FAILED, + testUser, + "Reversed by admin due to fraud"); + + // THEN + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionHistory.class); + verify(historyRepository).save(captor.capture()); + + TransactionHistory saved = captor.getValue(); + assertThat(saved.getOldStatus()).isEqualTo(TransactionStatus.COMPLETED); + assertThat(saved.getNewStatus()).isEqualTo(TransactionStatus.FAILED); + assertThat(saved.getChangedBy()).isEqualTo(testUser); + assertThat(saved.getReason()).isEqualTo("Reversed by admin due to fraud"); + } + + @Test + @DisplayName("system() entry has changedBy=null; manual() entry has changedBy set") + void record_vs_recordManual_actorDifference() { + // WHEN + historyService.record(testTransaction, null, TransactionStatus.PENDING); + historyService.recordManual( + testTransaction, + TransactionStatus.PENDING, TransactionStatus.FAILED, + testUser, "Cancelled by user request"); + + // THEN + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionHistory.class); + verify(historyRepository, times(2)).save(captor.capture()); + + List allSaved = captor.getAllValues(); + assertThat(allSaved.get(0).getChangedBy()).isNull(); // system + assertThat(allSaved.get(1).getChangedBy()).isEqualTo(testUser); // human + } + } + + // ─── getTransactionTimeline() + + @Nested + @DisplayName("getTransactionTimeline()") + class GetTransactionTimelineTests { + + @Test + @DisplayName("returns ordered timeline when user owns the transaction — OWASP A01") + void getTransactionTimeline_owner_returnsTimeline() { + // GIVEN — ownership check passes + when(transactionRepository.findByIdAndUserId(transactionId, userId)) + .thenReturn(Optional.of(testTransaction)); + + List entries = List.of( + buildSystemEntry(null, TransactionStatus.PENDING), + buildSystemEntry(TransactionStatus.PENDING, TransactionStatus.COMPLETED) + ); + when(historyRepository.findByTransactionIdOrderByCreatedAtAsc(transactionId)) + .thenReturn(entries); + + // WHEN + ApiResponse> response = + historyService.getTransactionTimeline(transactionId, userId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).hasSize(2); + assertThat(response.getData().get(0).getNewStatus()) + .isEqualTo(TransactionStatus.PENDING); + assertThat(response.getData().get(1).getNewStatus()) + .isEqualTo(TransactionStatus.COMPLETED); + } + + @Test + @DisplayName("throws 404 when transaction does not belong to the requesting user — OWASP A01") + void getTransactionTimeline_differentUser_throws404() { + // GIVEN — findByIdAndUserId returns empty → ownership failed + // Returns 404 (not 403) to prevent resource enumeration + UUID attackerId = UUID.randomUUID(); + when(transactionRepository.findByIdAndUserId(transactionId, attackerId)) + .thenReturn(Optional.empty()); + + // WHEN / THEN + // OWASP A01: attacker learns nothing — same response as "not found" + assertThatThrownBy(() -> + historyService.getTransactionTimeline(transactionId, attackerId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Transaction not found"); + + // History repository is never queried — ownership failed early + verify(historyRepository, never()).findByTransactionIdOrderByCreatedAtAsc(any()); + } + + @Test + @DisplayName("returns empty list when transaction exists but has no history entries yet") + void getTransactionTimeline_noEntries_returnsEmptyList() { + // GIVEN — valid owner, but history table is empty for this transaction + when(transactionRepository.findByIdAndUserId(transactionId, userId)) + .thenReturn(Optional.of(testTransaction)); + when(historyRepository.findByTransactionIdOrderByCreatedAtAsc(transactionId)) + .thenReturn(List.of()); + + // WHEN + ApiResponse> response = + historyService.getTransactionTimeline(transactionId, userId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEmpty(); + } + } + + // ─── getTransactionTimelineAdmin() + + @Nested + @DisplayName("getTransactionTimelineAdmin()") + class GetTransactionTimelineAdminTests { + + @Test + @DisplayName("returns timeline for any transaction — no ownership check for admin") + void getTransactionTimelineAdmin_anyTransaction_returnsTimeline() { + // GIVEN — admin can query any transaction, no userId involved + when(transactionRepository.findById(transactionId)) + .thenReturn(Optional.of(testTransaction)); + + List entries = List.of( + buildSystemEntry(null, TransactionStatus.PENDING), + buildManualEntry(TransactionStatus.COMPLETED, TransactionStatus.FAILED) + ); + when(historyRepository.findByTransactionIdOrderByCreatedAtAsc(transactionId)) + .thenReturn(entries); + + // WHEN + ApiResponse> response = + historyService.getTransactionTimelineAdmin(transactionId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).hasSize(2); + + // First entry = system (automatic=true), second = manual (automatic=false) + assertThat(response.getData().get(0).isAutomatic()).isTrue(); + assertThat(response.getData().get(1).isAutomatic()).isFalse(); + assertThat(response.getData().get(1).getChangedById()).isEqualTo(userId); + assertThat(response.getData().get(1).getChangedByEmail()).isEqualTo("angel@test.com"); + } + + @Test + @DisplayName("throws 404 when transaction does not exist") + void getTransactionTimelineAdmin_transactionNotFound_throwsException() { + // GIVEN + when(transactionRepository.findById(transactionId)).thenReturn(Optional.empty()); + + // WHEN / THEN + assertThatThrownBy(() -> + historyService.getTransactionTimelineAdmin(transactionId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Transaction not found"); + + verify(historyRepository, never()).findByTransactionIdOrderByCreatedAtAsc(any()); + } + } + + // ─── getWalletHistory() + + @Nested + @DisplayName("getWalletHistory()") + class GetWalletHistoryTests { + + @Test + @DisplayName("returns all history entries for a wallet — admin only") + void getWalletHistory_returnsAllEntries() { + // GIVEN + List entries = List.of( + buildSystemEntry(null, TransactionStatus.PENDING), + buildSystemEntry(TransactionStatus.PENDING, TransactionStatus.COMPLETED), + buildSystemEntry(null, TransactionStatus.PENDING), + buildSystemEntry(TransactionStatus.PENDING, TransactionStatus.FAILED) + ); + when(historyRepository.findByWalletId(walletId)).thenReturn(entries); + + // WHEN + ApiResponse> response = + historyService.getWalletHistory(walletId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).hasSize(4); + verify(historyRepository).findByWalletId(walletId); + } + + @Test + @DisplayName("returns empty list when wallet has no transaction history") + void getWalletHistory_noEntries_returnsEmptyList() { + // GIVEN + when(historyRepository.findByWalletId(walletId)).thenReturn(List.of()); + + // WHEN + ApiResponse> response = + historyService.getWalletHistory(walletId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEmpty(); + } + } +}