package com.mydreamjob.common.r01e;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.messaging.MessagingService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* R01-E:**真实 MySQL 8** 上的 E2 跨库一致性与执行计划证据(Testcontainers)。
*
*
不做生产改动(E1/E2 已由 R01-D2/D3 交付)。本类证明:
*
* - 未读分组 SQL 在 MySQL 上与 H2 上的**逐值一致**;
* - 1 / 20 / 100 会话下 {@code listConversations} 的 SQL 数恒定;
* - {@code EXPLAIN FORMAT=JSON} 的索引使用、access type、temporary / filesort 情况。
*
*
* EXPLAIN 独立复刻 {@code MessageUnreadQueriesImpl} 的 MySQL 8 窗口分支;
* 筛选、排序和窗口规则一致,使用 JDBC 占位符与同一批绑定参数。
*/
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
@ActiveProfiles("mysql-it")
class R01EMySqlQueryCountAndExplainTest {
private static final ObjectMapper JSON = new ObjectMapper();
@Container
static final MySQLContainer> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("mydreamjob_r01e")
.withUsername("r01e")
.withPassword("r01e");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
}
/** 独立复刻 MySQL 8 原生查询的筛选、排序与窗口规则。 */
private static final String UNREAD_SQL = """
SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount
FROM (
SELECT m.id, m.conversation_id, m.status,
ROW_NUMBER() OVER (
PARTITION BY m.conversation_id
ORDER BY m.created_at DESC, m.id DESC
) AS row_no
FROM messages m
WHERE m.conversation_id IN (%s)
) ranked
LEFT JOIN message_reads mr
ON mr.message_id = ranked.id
AND mr.reader_user_id = ?
WHERE ranked.row_no <= 500
AND (ranked.status IS NULL OR ranked.status <> 'RECALLED')
AND mr.id IS NULL
GROUP BY ranked.conversation_id
""";
@Autowired private MessagingService messagingService;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate redisTemplate;
private Long me;
private Long other;
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM message_reads");
jdbcTemplate.update("DELETE FROM messages");
jdbcTemplate.update("DELETE FROM conversation_members");
jdbcTemplate.update("DELETE FROM conversations");
jdbcTemplate.update("DELETE FROM user_roles");
jdbcTemplate.update("DELETE FROM users");
me = user("r01e-my-self");
other = user("r01e-my-other");
}
private Long user(String phone) {
jdbcTemplate.update("INSERT INTO users (phone, nickname, role, is_active, is_verified) "
+ "VALUES (?, ?, 'STUDENT', 1, 1)", phone, phone);
Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone);
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'STUDENT')", id);
return id;
}
private Long conversation() {
jdbcTemplate.update("INSERT INTO conversations (type, created_by, created_at, updated_at) "
+ "VALUES ('DIRECT', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", me);
Long cid = jdbcTemplate.queryForObject("SELECT MAX(id) FROM conversations", Long.class);
jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, me);
jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, other);
return cid;
}
private void message(Long cid, Long sender, int i, String status) {
jdbcTemplate.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) "
+ "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)", cid, sender, "m" + i, status);
}
private String inClause(int n) {
return String.join(",", java.util.Collections.nCopies(n, "?"));
}
@Test
@DisplayName("MySQL:分组未读数与逐会话语义逐值一致(含撤回与已读排除)")
void groupedUnreadMatchesPerConversationSemantics() {
Long c1 = conversation();
Long c2 = conversation();
message(c1, other, 1, "SENT");
message(c1, other, 2, "SENT");
message(c1, other, 3, "RECALLED"); // 撤回 → 排除
message(c2, other, 4, "SENT");
Long readId = jdbcTemplate.queryForObject(
"SELECT MIN(id) FROM messages WHERE conversation_id = ? AND status = 'SENT'", Long.class, c1);
jdbcTemplate.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) "
+ "VALUES (?, ?, CURRENT_TIMESTAMP)", readId, me); // 已读 1 条 → 排除
List