fix(activity-mutual): close Phase 16 KEEP-VERIFY/EXTEND — 7 defects + 1 hidden compile defect (reopenEpisode) + 1 test-fidelity; unify dual-impl to confirmActivityInterest; INV-006 fix; +7 tests (481 green)
This commit is contained in:
+210
@@ -0,0 +1,210 @@
|
||||
package com.danyang.match.platform.activity_mutual;
|
||||
|
||||
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
|
||||
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
|
||||
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
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.StringRedisTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 16:活动互选二阶段同意 —— 真实运行时路径测试。
|
||||
*
|
||||
* <p><b>测试保真度</b>:走真实 Service + 真实落库;测试库 {@code schema-h2.sql} 已补齐
|
||||
* {@code activity_mutual_connection_consents} 表(含 deleted_at / expires_at / match_id 与
|
||||
* chk_consent_status),此前该表完全缺失 → 互选核心路径零覆盖(只能写假测试)。
|
||||
*
|
||||
* <p>覆盖 Gate:
|
||||
* <ol>
|
||||
* <li>双确认后必须形成 Match + 开启 Conversation(此前双确认只打日志,根本不建 Match,Gate 失败);</li>
|
||||
* <li>单向意向保密(INV-006):A 已确认而 B 未确认时,B 不得探知 A 的意向;</li>
|
||||
* <li>decline 不建 Match;幂等重确认不重复建 Match;超时 expires_at 后确认被拒;</li>
|
||||
* <li>软删过滤:已软删的同意记录不参与判断;</li>
|
||||
* <li>复用已有 active Match(AGENTS.md #19):双方确认后不创建新 Match,引导已有聊天。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class ActivityMutualConsentServiceTest {
|
||||
|
||||
@Autowired
|
||||
private ActivityMutualConsentService consentService;
|
||||
@Autowired
|
||||
private ActivityMutualConsentMapper consentMapper;
|
||||
@Autowired
|
||||
private MatchMapper matchMapper;
|
||||
@Autowired
|
||||
private ConversationMapper conversationMapper;
|
||||
|
||||
@MockBean
|
||||
private StringRedisTemplate redisTemplate;
|
||||
|
||||
private String activityId;
|
||||
private String userA;
|
||||
private String userB;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
activityId = UUID.randomUUID().toString();
|
||||
userA = UUID.randomUUID().toString();
|
||||
userB = UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 双确认后形成 Match 并开启 Conversation(此前只打日志,Gate 失败)")
|
||||
void doubleConfirmCreatesMatchAndConversation() {
|
||||
// 第一阶段:建立互选同意记录(二阶段同意)
|
||||
consentService.createMutualConsent(activityId, userA, userB);
|
||||
|
||||
// A 单方确认:不应形成 Match
|
||||
Map<String, Object> r1 = consentService.confirmConnection(userA, activityId, userB);
|
||||
assertFalse((Boolean) r1.get("bothConfirmed"));
|
||||
assertFalse((Boolean) r1.get("matchCreated"));
|
||||
assertTrue(matchMapper.findActiveMatch(userA, userB).isEmpty(),
|
||||
"单方确认不应产生 Match");
|
||||
|
||||
// B 确认:双方都已确认 → 必须形成 Match + 开启会话
|
||||
Map<String, Object> r2 = consentService.confirmConnection(userB, activityId, userA);
|
||||
assertTrue((Boolean) r2.get("bothConfirmed"));
|
||||
assertTrue((Boolean) r2.get("matchCreated"));
|
||||
|
||||
Match match = matchMapper.findActiveMatch(userA, userB)
|
||||
.orElseThrow(() -> new AssertionError("双确认后必须存在 active Match"));
|
||||
assertEquals(Constants.MATCH_STATUS_ACTIVE, match.getStatus());
|
||||
assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, match.getSource());
|
||||
|
||||
// 会话必须已开启(Gate:互选确认后双方可沟通)
|
||||
assertTrue(conversationMapper.findByMatchId(match.getId()).isPresent(),
|
||||
"双确认后必须已开启 Conversation");
|
||||
|
||||
// 溯源字段:consent 记录应绑定 match_id
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userA, userB).orElseThrow();
|
||||
assertNotNull(consent.getMatchId(), "consent 记录应绑定 match_id 用于溯源");
|
||||
assertEquals(match.getId(), consent.getMatchId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-006 单向意向保密:A 已确认而 B 未确认时,B 不得探知 A 的意向")
|
||||
void singleSideIntentionNotExposed() {
|
||||
consentService.createMutualConsent(activityId, userA, userB);
|
||||
// 仅 A 确认
|
||||
consentService.confirmConnection(userA, activityId, userB);
|
||||
|
||||
// A 视角:自己已确认,对方(B)未确认
|
||||
Map<String, Object> aStatus = consentService.getMyConsentStatus(userA, activityId, userB);
|
||||
assertEquals("accepted", aStatus.get("myStatus"));
|
||||
assertEquals("pending", aStatus.get("partnerStatus"));
|
||||
|
||||
// B 视角:B 尚未确认,绝不能看到 A 已确认(INV-006 核心)—— 修复前此处会泄露为 accepted
|
||||
Map<String, Object> bStatus = consentService.getMyConsentStatus(userB, activityId, userA);
|
||||
assertEquals("pending", bStatus.get("myStatus"));
|
||||
assertEquals("pending", bStatus.get("partnerStatus"),
|
||||
"INV-006 泄漏:B 未确认却探知了 A 的确认状态");
|
||||
assertFalse((Boolean) bStatus.get("bothConfirmed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decline 不形成 Match:一方拒绝后双方确认流程终止")
|
||||
void declinePreventsMatch() {
|
||||
consentService.createMutualConsent(activityId, userA, userB);
|
||||
// A 确认、B 拒绝
|
||||
consentService.confirmConnection(userA, activityId, userB);
|
||||
Map<String, Object> decline = consentService.declineConnection(userB, activityId, userA);
|
||||
assertEquals("declined", decline.get("myStatus"));
|
||||
|
||||
// 此后即便 A 再确认(已是 accepted 不会再变),也不应产生 Match
|
||||
assertTrue(matchMapper.findActiveMatch(userA, userB).isEmpty(),
|
||||
"一方拒绝后不应形成 Match");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("幂等:双方已确认后再重复确认不重复创建 Match")
|
||||
void idempotentReconfirmDoesNotDuplicateMatch() {
|
||||
consentService.createMutualConsent(activityId, userA, userB);
|
||||
consentService.confirmConnection(userA, activityId, userB);
|
||||
consentService.confirmConnection(userB, activityId, userA);
|
||||
|
||||
// 重复确认
|
||||
consentService.confirmConnection(userA, activityId, userB);
|
||||
consentService.confirmConnection(userB, activityId, userA);
|
||||
|
||||
// active Match 仅一条
|
||||
assertEquals(1, matchMapper.findActiveMatchesByUser(userA).size(),
|
||||
"重复确认不应产生多条 Match");
|
||||
assertTrue(matchMapper.findActiveMatch(userA, userB).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("超时:expires_at 过期后确认被拒,both_confirmed_at 不落定")
|
||||
void expiredConsentRejectsConfirm() {
|
||||
ActivityMutualConsent consent = consentService.createMutualConsent(activityId, userA, userB);
|
||||
// 设为已过期
|
||||
consent.setExpiresAt(Instant.now().minusSeconds(3600));
|
||||
consentMapper.updateById(consent);
|
||||
|
||||
// A 确认:因已过期,confirmSideIfPending 命中 WHERE 条件失败 → 本方不被置为 accepted
|
||||
Map<String, Object> r = consentService.confirmConnection(userA, activityId, userB);
|
||||
assertEquals("pending", r.get("myStatus"));
|
||||
assertFalse((Boolean) r.get("bothConfirmed"));
|
||||
assertTrue(matchMapper.findActiveMatch(userA, userB).isEmpty(),
|
||||
"过期互选不应形成 Match");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("软删过滤:已软删的同意记录不参与判断,confirmConnection 视为不存在")
|
||||
void softDeletedConsentIsFiltered() {
|
||||
ActivityMutualConsent consent = consentService.createMutualConsent(activityId, userA, userB);
|
||||
// 软删(BaseEntity 无 @TableLogic,物理 UPDATE deleted_at)
|
||||
consent.setDeletedAt(Instant.now());
|
||||
consentMapper.updateById(consent);
|
||||
|
||||
// 查询应过滤掉
|
||||
assertTrue(consentMapper.findByActivityPair(activityId, userA, userB).isEmpty(),
|
||||
"findByActivityPair 必须过滤软删记录");
|
||||
// 确认视为记录不存在
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> consentService.confirmConnection(userA, activityId, userB));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("复用已有 active Match(AGENTS.md #19):双方确认后不创建新 Match,引导已有聊天")
|
||||
void reuseExistingActiveMatch() {
|
||||
// 预先存在一条 active Match(如来自 like)
|
||||
Match prior = new Match();
|
||||
prior.setId(UUID.randomUUID().toString());
|
||||
prior.setUserAId(userA.compareTo(userB) < 0 ? userA : userB);
|
||||
prior.setUserBId(userA.compareTo(userB) < 0 ? userB : userA);
|
||||
prior.setStatus(Constants.MATCH_STATUS_ACTIVE);
|
||||
prior.setSource(Constants.MATCH_SOURCE_USER_LIKE);
|
||||
prior.setEpisode(1);
|
||||
prior.setMatchedAt(Instant.now());
|
||||
prior.setLastActivityAt(Instant.now());
|
||||
matchMapper.insert(prior);
|
||||
|
||||
// 互选双确认
|
||||
consentService.createMutualConsent(activityId, userA, userB);
|
||||
consentService.confirmConnection(userA, activityId, userB);
|
||||
consentService.confirmConnection(userB, activityId, userA);
|
||||
|
||||
// 仍只有一条 active Match,且复用的是 prior(不新建、不重复)
|
||||
Match active = matchMapper.findActiveMatch(userA, userB).orElseThrow();
|
||||
assertEquals(prior.getId(), active.getId(), "应复用已有 active Match,而非新建");
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -341,7 +341,11 @@ class ChatPhase09IntegrationTest {
|
||||
assertTrue(body.contains(activityMatch.getId()));
|
||||
|
||||
Conversation activityConv = conversationMapper.findByMatchId(activityMatch.getId()).orElseThrow();
|
||||
assertNotEquals(conv.getId(), activityConv.getId(), "新 Match 应有独立会话");
|
||||
// AGENTS.md #19/#20:同对用户此前 Unmatch 后由活动互选重连,复用同一 Match 行(episode 递增),
|
||||
// 会话由 match_id 唯一绑定,因此随之复用。本测试的核心不变量是"活动来源的会话走同一套发送/推送管线",
|
||||
// 而非"每轮活动互选都新建独立会话"。故此处断言复用而非不等。
|
||||
assertEquals(conv.getId(), activityConv.getId(),
|
||||
"AGENTS.md #20:同对用户 Unmatch 后重连复用同一 Match 行与会话");
|
||||
|
||||
// 3. 同一套发送 + 推送逻辑对活动来源同样生效
|
||||
chatService.sendText(activityConv.getId(), userA, "活动认识,你好", "cmid-activity");
|
||||
|
||||
+4
-2
@@ -53,7 +53,6 @@ class EntityMigrationParityTest {
|
||||
*/
|
||||
private static final Set<String> TEST_SCHEMA_OPTIONAL_TABLES = Set.of(
|
||||
"activity_interests",
|
||||
"activity_mutual_connection_consents",
|
||||
"appeals",
|
||||
"consent_records",
|
||||
"content_categories",
|
||||
@@ -114,7 +113,10 @@ class EntityMigrationParityTest {
|
||||
// 「取消后重报名」的必崩路径零覆盖;活动工作人员指派表完全不存在 ——
|
||||
// 签到数据范围无人可测。二者均已补入 schema-h2.sql。
|
||||
"activity_registrations",
|
||||
"activity_staff_assignments"
|
||||
"activity_staff_assignments",
|
||||
// Phase 16:活动互选二阶段同意表此前在可选白名单(= 无人测),
|
||||
// 导致双确认→建Match+会话的核心路径零覆盖。已补入 schema-h2.sql。
|
||||
"activity_mutual_connection_consents"
|
||||
);
|
||||
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
|
||||
private static final Set<String> BASE_ENTITY_COLUMNS =
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class MigrationValidationTest {
|
||||
|
||||
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
|
||||
// Phase 15:V040 活动工作人员指派(activity_staff_assignments,签到数据范围)
|
||||
private static final int LATEST_MIGRATION = 40;
|
||||
// Phase 16:V041 活动互选二阶段同意补全(expires_at / match_id 溯源字段)
|
||||
private static final int LATEST_MIGRATION = 41;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
@@ -33,6 +33,29 @@ CREATE TABLE matches (
|
||||
-- 测试库若缺此约束,形如 source='liked' 的非法取值会被静默放过,只在 PostgreSQL 上炸。
|
||||
CONSTRAINT chk_match_source CHECK (source IN ('user_like', 'activity_mutual', 'matchmaker'))
|
||||
);
|
||||
|
||||
-- Phase 16:活动互选二阶段同意表(与 V024 + V037 + V041 生产 DDL 对齐)。
|
||||
-- 此前完全缺失 → 互选相关路径零覆盖(只能写假测试)。
|
||||
-- 注意 deleted_at 必须为可空 TIMESTAMP(V037 已为生产表补该列;BaseEntity 逻辑删除依赖它)。
|
||||
CREATE TABLE activity_mutual_connection_consents (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
activity_id VARCHAR(36) NOT NULL,
|
||||
user_a_id VARCHAR(36) NOT NULL,
|
||||
user_b_id VARCHAR(36) NOT NULL,
|
||||
user_a_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
user_b_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
both_confirmed_at TIMESTAMP WITH TIME ZONE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
match_id VARCHAR(36),
|
||||
created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE (activity_id, user_a_id, user_b_id),
|
||||
CONSTRAINT chk_consent_status CHECK (
|
||||
user_a_status IN ('pending', 'accepted', 'declined')
|
||||
AND user_b_status IN ('pending', 'accepted', 'declined')
|
||||
)
|
||||
);
|
||||
CREATE TABLE relationship_success_requests (
|
||||
id VARCHAR(36) PRIMARY KEY, match_id VARCHAR(36), initiated_by VARCHAR(36), status VARCHAR(32), confirmed_by VARCHAR(36),
|
||||
resolved_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
Reference in New Issue
Block a user