package com.mydreamjob.messaging; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditLog; import com.mydreamjob.audit.AuditLogRepository; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.messaging.dto.MessageResponse; import com.mydreamjob.safety.BlockRelationRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.OptimisticLockException; import org.springframework.dao.DataAccessException; import org.springframework.transaction.annotation.Transactional; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.domain.Page; import org.springframework.test.context.ActiveProfiles; import java.time.LocalDateTime; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @ActiveProfiles("integration-test") class MessageRecallPersistenceIntegrationTest { @Autowired private MessagingService messagingService; @Autowired private UserRepository userRepository; @Autowired private ConversationRepository conversationRepository; @Autowired private ConversationMemberRepository memberRepository; @Autowired private MessageRepository messageRepository; @Autowired private MessageReadRepository readRepository; @Autowired private AuditLogRepository auditLogRepository; @Autowired private BlockRelationRepository blockRelationRepository; @Autowired private JdbcTemplate jdbcTemplate; @Autowired private EntityManager entityManager; @Autowired private EntityManagerFactory entityManagerFactory; @Autowired private ObjectMapper objectMapper; @MockBean private RedisTemplate redisTemplate; private Long sender; private Long receiver; private Long conversation; private Long message; private Long candidateScoreStudent; @AfterEach void cleanup() { if (candidateScoreStudent != null) { jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id=?", candidateScoreStudent); candidateScoreStudent = null; } if (message != null) readRepository.deleteAll(); messageRepository.deleteAll(); memberRepository.deleteAll(); blockRelationRepository.deleteAll(); conversationRepository.deleteAll(); auditLogRepository.deleteAll(); if (sender != null || receiver != null) { if (sender != null) { jdbcTemplate.update("DELETE FROM scoring_risk_actions WHERE operator_user_id=?", sender); jdbcTemplate.update("DELETE FROM scoring_risk_events WHERE subject_user_id=?", sender); jdbcTemplate.update("DELETE FROM notifications WHERE user_id=?", sender); } if (receiver != null) { jdbcTemplate.update("DELETE FROM scoring_risk_actions WHERE operator_user_id=?", receiver); jdbcTemplate.update("DELETE FROM scoring_risk_events WHERE subject_user_id=?", receiver); jdbcTemplate.update("DELETE FROM notifications WHERE user_id=?", receiver); } } userRepository.deleteAll(); } private void fixture(LocalDateTime createdAt) { sender = userRepository.save(user("sender")).getId(); receiver = userRepository.save(user("receiver")).getId(); conversation = conversationRepository.save(Conversation.builder().type("DIRECT").createdBy(sender).build()).getId(); memberRepository.save(ConversationMember.builder().conversationId(conversation).userId(sender).build()); memberRepository.save(ConversationMember.builder().conversationId(conversation).userId(receiver).build()); Message saved = messageRepository.saveAndFlush(Message.builder().conversationId(conversation).senderUserId(sender) .content("secret-body").status("SENT").version(0L).createdAt(createdAt).build()); message = saved.getId(); // Auditing may refresh @CreatedDate on update; force the deterministic boundary in the fixture only. jdbcTemplate.update("UPDATE messages SET created_at=? WHERE id=?", createdAt, message); entityManager.clear(); } private User user(String name) { return User.builder().phone("139" + Math.abs(System.nanoTime() % 1_000_000_000L)) .nickname(name).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) .active(true).verified(true).build(); } @Test void migrationFieldsAndConditionalUpdatePersistRecall() { LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); LocalDateTime recalledAt = created.plusMinutes(2); fixture(created); MessageResponse response = messagingService.recallMessage(sender, conversation, message, null, recalledAt); Message persisted = messageRepository.findById(message).orElseThrow(); assertEquals("RECALLED", persisted.getStatus()); assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, persisted.getContent()); assertEquals(recalledAt, persisted.getRecalledAt()); assertEquals(1L, persisted.getVersion()); assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, response.getContent()); assertEquals(1, auditLogRepository.findAll().stream() .filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())).count()); assertFalse(auditLogRepository.findAll().stream() .filter(a -> "MESSAGE_RECALLED".equals(a.getAction())) .anyMatch(a -> a.getDetails() != null && a.getDetails().contains("secret-body"))); } @Test void messageRecallAuditStoresRequestIdButNotIpOrUserAgent() throws Exception { LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); fixture(created); AuditContext context = AuditContext.builder() .requestId("recall-audit-1") .ipAddress("127.0.0.66") .userAgent("N07-secret-agent") .build(); messagingService.recallMessage(sender, conversation, message, context, created.plusMinutes(1)); AuditLog audit = auditLogRepository.findAll().stream() .filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())) .findFirst().orElseThrow(); assertEquals("recall-audit-1", audit.getRequestId()); assertNull(audit.getIpAddress()); assertNull(audit.getUserAgent()); JsonNode details = objectMapper.readTree(audit.getDetails()); java.util.Set fields = new java.util.HashSet<>(); details.fieldNames().forEachRemaining(fields::add); assertEquals(java.util.Set.of("messageId", "conversationId", "conversationType", "status", "requestId"), fields); assertEquals(message.longValue(), details.get("messageId").longValue()); assertEquals(conversation.longValue(), details.get("conversationId").longValue()); assertEquals("DIRECT", details.get("conversationType").textValue()); assertEquals("RECALLED", details.get("status").textValue()); assertEquals("recall-audit-1", details.get("requestId").textValue()); assertFalse(audit.getDetails().contains("secret-body")); assertFalse(audit.getDetails().contains("127.0.0.66")); assertFalse(audit.getDetails().contains("N07-secret-agent")); } @Test void v41DefaultsAndStatusCheckAreEnforced() { fixture(LocalDateTime.of(2026, 9, 10, 10, 0)); jdbcTemplate.update( "INSERT INTO messages(conversation_id, sender_user_id, content, created_at) VALUES (?, ?, ?, ?)", conversation, sender, "legacy-body", LocalDateTime.of(2026, 9, 10, 10, 0)); Long legacyMessage = jdbcTemplate.queryForObject("SELECT MAX(id) FROM messages WHERE conversation_id=?", Long.class, conversation); java.util.Map row = jdbcTemplate.queryForMap( "SELECT status, recalled_at, version FROM messages WHERE id=?", legacyMessage); assertEquals("SENT", row.get("STATUS")); assertNull(row.get("RECALLED_AT")); assertEquals(0L, ((Number) row.get("VERSION")).longValue()); Long finalLegacyMessage = legacyMessage; assertThrows(DataAccessException.class, () -> jdbcTemplate.update( "UPDATE messages SET status='DELETED' WHERE id=?", finalLegacyMessage)); } @Test void optimisticVersionConflictCannotSilentlyOverwrite() { fixture(LocalDateTime.of(2026, 9, 10, 10, 0)); EntityManager first = entityManagerFactory.createEntityManager(); EntityManager second = entityManagerFactory.createEntityManager(); jakarta.persistence.EntityTransaction tx1 = first.getTransaction(); jakarta.persistence.EntityTransaction tx2 = second.getTransaction(); try { Message firstMessage = first.find(Message.class, message); Message secondMessage = second.find(Message.class, message); tx1.begin(); firstMessage.setContent("first-update"); tx1.commit(); tx2.begin(); secondMessage.setContent("stale-update"); RuntimeException conflict = assertThrows(RuntimeException.class, tx2::commit); assertTrue(hasCause(conflict, OptimisticLockException.class), "stale update must be reported as an optimistic lock conflict"); if (tx2.isActive()) tx2.rollback(); assertEquals("first-update", jdbcTemplate.queryForObject( "SELECT content FROM messages WHERE id=?", String.class, message)); } finally { if (tx1.isActive()) tx1.rollback(); if (tx2.isActive()) tx2.rollback(); first.close(); second.close(); } } @Test @Transactional void conditionalRecallRejectsWrongOwnerConversationVersionAndStatus() { fixture(LocalDateTime.of(2026, 9, 10, 10, 0)); LocalDateTime recalledAt = LocalDateTime.of(2026, 9, 10, 10, 1); assertEquals(0, messageRepository.recallIfCurrent(message, conversation, 999L, 0L, MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); assertEquals(0, messageRepository.recallIfCurrent(message, 999L, sender, 0L, MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); assertEquals(0, messageRepository.recallIfCurrent(message, conversation, sender, 1L, MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); assertEquals(1, messageRepository.recallIfCurrent(message, conversation, sender, 0L, MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); assertEquals(0, messageRepository.recallIfCurrent(message, conversation, sender, 0L, MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); } private boolean hasCause(Throwable error, Class type) { Throwable current = error; while (current != null) { if (type.isInstance(current)) return true; current = current.getCause(); } return false; } @Test void existingMessageReadRowsRemainAfterRecall() { LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); fixture(created); readRepository.saveAndFlush(MessageRead.builder().messageId(message).readerUserId(receiver).build()); messagingService.recallMessage(sender, conversation, message, null, created.plusMinutes(1)); assertEquals(1, readRepository.findByMessageIdInAndReaderUserId(java.util.List.of(message), receiver).size()); messagingService.markRead(conversation, receiver); assertEquals(1, readRepository.findByMessageIdInAndReaderUserId(java.util.List.of(message), receiver).size()); assertEquals(1, messagingService.listMessages(conversation, receiver, 0, 20) .getItems().stream().filter(item -> Boolean.TRUE.equals(item.getIsRead())).count()); } @Test void expiredRecallDoesNotMutateDatabase() { LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); fixture(created); assertEquals(created, messageRepository.findById(message).orElseThrow().getCreatedAt()); BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.recallMessage(sender, conversation, message, null, created.plusMinutes(2).plusNanos(1))); assertEquals(ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED.getCode(), ex.getErrorCodeInt()); Message persisted = messageRepository.findById(message).orElseThrow(); assertEquals("SENT", persisted.getStatus()); assertEquals("secret-body", persisted.getContent()); assertEquals(0L, persisted.getVersion()); assertEquals(0, auditLogRepository.findAll().stream() .filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())).count()); } @Test void recalledMessagesAreNotUnreadOrMarkedRead() { LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); fixture(created); messagingService.recallMessage(sender, conversation, message, null, created.plusMinutes(1)); var messages = messagingService.listMessages(conversation, receiver, 0, 20); assertEquals(1, messages.getTotal()); assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, messages.getItems().get(0).getContent()); assertEquals("RECALLED", messages.getItems().get(0).getStatus()); messagingService.markRead(conversation, receiver); assertEquals(0, readRepository.findByMessageIdInAndReaderUserId(java.util.List.of(message), receiver).size()); } @Test void messageRecallDoesNotModifyCandidateDimensionScores() { LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); fixture(created); candidateScoreStudent = receiver; jdbcTemplate.update("INSERT INTO candidate_dimension_scores " + "(student_user_id, dimension, period, score, status, weight, rules_version, calculated_at) " + "VALUES (?, 'ACTIVITY', '2026-09', 73.25, 'OK', 12.50, 'candidate-scoring-v0.2', ?)", candidateScoreStudent, created); java.util.Map before = jdbcTemplate.queryForMap( "SELECT id, student_user_id, dimension, score, status, weight, rules_version, calculated_at, period, version " + "FROM candidate_dimension_scores WHERE student_user_id=? AND dimension='ACTIVITY'", candidateScoreStudent); messagingService.recallMessage(sender, conversation, message, null, created.plusMinutes(1)); java.util.Map after = jdbcTemplate.queryForMap( "SELECT id, student_user_id, dimension, score, status, weight, rules_version, calculated_at, period, version " + "FROM candidate_dimension_scores WHERE student_user_id=? AND dimension='ACTIVITY'", candidateScoreStudent); assertEquals(before, after, "message recall must not modify candidate_dimension_scores"); } @Test void messageRecallDoesNotModifyNotificationsOrRiskGovernanceData() throws Exception { LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); fixture(created); jdbcTemplate.update("INSERT INTO notifications(user_id, type, title, content, is_read, created_at) " + "VALUES (?, 'SYSTEM', 'N07 baseline', 'unchanged', 0, ?)", receiver, created); Long ruleId = jdbcTemplate.queryForObject( "SELECT id FROM scoring_rule_configs ORDER BY id LIMIT 1", Long.class); jdbcTemplate.update("INSERT INTO scoring_risk_events(rule_config_id, subject_user_id, source_audit_log_id, " + "source_action, severity, window_started_at, window_ended_at, occurrence_count, status, " + "detected_at, version, created_at, updated_at) " + "VALUES (?, ?, 987654321, 'REPORT_RATE_LIMITED', 'LOW', ?, ?, 1, 'OPEN', ?, 0, ?, ?)", ruleId, sender, created.minusMinutes(1), created, created, created, created); Long riskEventId = jdbcTemplate.queryForObject( "SELECT id FROM scoring_risk_events WHERE subject_user_id=?", Long.class, sender); jdbcTemplate.update("INSERT INTO scoring_risk_actions(risk_event_id, operator_user_id, action, reason_code, created_at) " + "VALUES (?, ?, 'ACKNOWLEDGE', 'MONITORING', ?)", riskEventId, sender, created); java.util.List> notificationsBefore = stableRows("notifications"); java.util.List> riskEventsBefore = stableRows("scoring_risk_events"); java.util.List> riskActionsBefore = stableRows("scoring_risk_actions"); java.util.List> riskRulesBefore = stableRows("scoring_rule_configs"); java.util.List> checkpointsBefore = stableRows("scoring_risk_scan_checkpoints"); long auditsBefore = auditLogRepository.count(); messagingService.recallMessage(sender, conversation, message, AuditContext.builder().requestId("n07-side-effect-check").build(), created.plusMinutes(1)); assertEquals(notificationsBefore, stableRows("notifications")); assertEquals(riskEventsBefore, stableRows("scoring_risk_events")); assertEquals(riskActionsBefore, stableRows("scoring_risk_actions")); assertEquals(riskRulesBefore, stableRows("scoring_rule_configs")); assertEquals(checkpointsBefore, stableRows("scoring_risk_scan_checkpoints")); assertEquals(auditsBefore + 1, auditLogRepository.count()); AuditLog recallAudit = auditLogRepository.findAll().stream() .filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())) .findFirst().orElseThrow(); JsonNode details = objectMapper.readTree(recallAudit.getDetails()); java.util.Set fields = new java.util.HashSet<>(); details.fieldNames().forEachRemaining(fields::add); assertEquals(java.util.Set.of("messageId", "conversationId", "conversationType", "status", "requestId"), fields); assertNull(recallAudit.getIpAddress()); assertNull(recallAudit.getUserAgent()); } private java.util.List> stableRows(String table) { java.util.Set allowed = java.util.Set.of( "notifications", "scoring_risk_events", "scoring_risk_actions", "scoring_rule_configs", "scoring_risk_scan_checkpoints"); if (!allowed.contains(table)) throw new IllegalArgumentException("unsupported snapshot table"); return jdbcTemplate.queryForList("SELECT * FROM " + table + " ORDER BY id"); } }