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
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");
|
|
}
|
|
}
|
|
|