You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

355 lines
19 KiB

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<String, Object> 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<String> 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<String, Object> 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<? extends Throwable> 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<String, Object> 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<String, Object> 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<java.util.Map<String, Object>> notificationsBefore = stableRows("notifications");
java.util.List<java.util.Map<String, Object>> riskEventsBefore = stableRows("scoring_risk_events");
java.util.List<java.util.Map<String, Object>> riskActionsBefore = stableRows("scoring_risk_actions");
java.util.List<java.util.Map<String, Object>> riskRulesBefore = stableRows("scoring_rule_configs");
java.util.List<java.util.Map<String, Object>> 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<String> 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<java.util.Map<String, Object>> stableRows(String table) {
java.util.Set<String> 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");
}
}