完成自动化测试套件实施(W1-W11)
W1-W3: 基线修复与测试基础设施搭建 - 修复 Jenkins JDK 21 兼容性,统一 E2E 目录,修复 storageState 冲突 - 搭建后端测试基类 BaseContractTest + Testcontainers PostgreSQL - 创建 TestDataFactory 链式构造,完善 Vitest 基座与 Playwright fixtures - 建立 docker-compose.test.yml 与测试数据隔离方案 W4-W5: 单元测试补齐(阶段 2) - 补齐 gym-member/gym-groupCourse/gym-checkIn/gym-payment 核心模块单元测试 - 补齐 gym-coach/manage-sys 模块单元测试 - 前端 utils/composables/stores 单元测试,37 文件 502 项测试 - JaCoCo 覆盖率门禁从 30% 调整至 55%,21 模块全部通过 W6-W7: 集成与契约测试(阶段 3) - Repository 集成测试:会员/团课/签到/支付关键表,Testcontainers 100% 通过 - Handler 集成测试:WebTestClient 覆盖正向/异常/权限路径 - 网关集成测试:JWT/RBAC/签名/限流/重试 - Flyway 迁移测试:验证迁移脚本可重复执行 - OpenAPI 契约测试:覆盖 ≥80% P0 接口,202 项契约测试 0 失败 - 跨模块契约测试:会员-支付-团课数据一致性 W8-W9: E2E 与用户旅程测试(阶段 4) - 管理员 Web 核心流程 E2E:用户/角色/菜单/字典/配置 - 小程序会员端核心页面 E2E:购卡/预约/签到 - 5 条 P0 用户旅程全链路自动化,60 条 journey 测试 0 失败 W10: 变异测试与质量门禁(阶段 5) - 后端 PIT 配置:pitest-maven 1.19.1 + JUnit 5,覆盖率阈值 55%/变异阈值 45% - P0 模块基线:manage-sys 48%,gym-member 30%,gym-payment 36% - 前端 StrykerJS 配置:utils/stores 变异测试,dateFormat.ts 70.83% - Jenkins 质量门禁:JaCoCo/PIT/E2E 统一检查,不达标阻断构建 W11: 持续运行与改进(阶段 6) - 测试指标收集脚本 scripts/collect-test-metrics.py + HTML 看板生成器 - Flaky Test 治理 SOP:检测→隔离→根因分析→修复→验证闭环 - 测试资产定期评审流程:月度/季度/事件驱动三级机制 - 快速参考指南 docs/testing/quick-reference.md - 累计 10 份测试文档,7 个里程碑全部达成
This commit was merged in pull request #54.
This commit is contained in:
+550
@@ -0,0 +1,550 @@
|
||||
package cn.novalon.gym.manage.checkin.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository;
|
||||
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
|
||||
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CheckServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private QRCodeConfig qrCodeConfig;
|
||||
|
||||
@Mock
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Mock
|
||||
private MemberCardRecordRepository memberCardRecordRepository;
|
||||
|
||||
@Mock
|
||||
private MemberCardRepository memberCardRepository;
|
||||
|
||||
@Mock
|
||||
private SignInRecordRepository signInRecordRepository;
|
||||
|
||||
@Mock
|
||||
private IGroupCourseBookingService groupCourseBookingService;
|
||||
|
||||
@Mock
|
||||
private IGroupCourseBookingRepository groupCourseBookingRepository;
|
||||
|
||||
@InjectMocks
|
||||
private CheckServiceImpl checkService;
|
||||
|
||||
private static final Long MEMBER_ID = 10001L;
|
||||
private static final String QR_CONTENT = "test-qr-content-abc123";
|
||||
|
||||
// ==================== getQRCode ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("获取二维码 - 成功")
|
||||
void getQRCode_shouldReturnQRCodeVo() {
|
||||
when(qrCodeConfig.getWidth()).thenReturn(300);
|
||||
when(qrCodeConfig.getHeight()).thenReturn(300);
|
||||
when(redisUtil.setWithExpire(anyString(), any(Map.class), anyLong()))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
Mono<QRCodeVo> result = checkService.getQRCode(MEMBER_ID);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(vo -> {
|
||||
assertThat(vo.getQrContent()).isNotNull();
|
||||
assertThat(vo.getQrCodeBase64()).isNotNull();
|
||||
assertThat(vo.getWidth()).isEqualTo(300);
|
||||
assertThat(vo.getHeight()).isEqualTo(300);
|
||||
assertThat(vo.isUsed()).isFalse();
|
||||
assertThat(vo.getCreateTime()).isEqualTo(LocalDate.now());
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ==================== checkIn - 正向场景 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("签到成功 - 正常流程(无预约、无会员卡记录ID)")
|
||||
void checkIn_shouldSucceed() {
|
||||
// 未签到
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
// Redis 中有有效二维码数据
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + MEMBER_ID + LocalDate.now();
|
||||
Map<String, Object> qrData = buildQrData(false);
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
|
||||
|
||||
// 无预约
|
||||
when(groupCourseBookingService.getBookingsByMemberId(MEMBER_ID)).thenReturn(Flux.empty());
|
||||
|
||||
// 保存签到记录
|
||||
when(signInRecordRepository.save(any(SignInRecord.class)))
|
||||
.thenReturn(Mono.just(createMockSignInRecord()));
|
||||
|
||||
// 更新缓存
|
||||
when(redisUtil.set(anyString(), any(Map.class))).thenReturn(Mono.just(true));
|
||||
|
||||
// 清除缓存
|
||||
when(redisUtil.deleteByPattern(anyString())).thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(MEMBER_ID, QR_CONTENT);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.contains("签到成功"))
|
||||
.verifyComplete();
|
||||
|
||||
verify(signInRecordRepository).save(any(SignInRecord.class));
|
||||
verify(redisUtil).set(anyString(), any(Map.class));
|
||||
verify(redisUtil, times(2)).deleteByPattern(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("签到成功 - 有有效团课预约")
|
||||
void checkIn_shouldSucceedWithValidBooking() {
|
||||
// 未签到
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
// Redis 中有有效二维码数据
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + MEMBER_ID + LocalDate.now();
|
||||
Map<String, Object> qrData = buildQrData(false);
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
|
||||
|
||||
// 有有效预约
|
||||
GroupCourseBooking booking = createValidBooking();
|
||||
when(groupCourseBookingService.getBookingsByMemberId(MEMBER_ID)).thenReturn(Flux.just(booking));
|
||||
when(groupCourseBookingRepository.updateStatus(booking.getId(), "2")).thenReturn(Mono.just(1));
|
||||
|
||||
// 保存签到记录
|
||||
when(signInRecordRepository.save(any(SignInRecord.class)))
|
||||
.thenReturn(Mono.just(createMockSignInRecord()));
|
||||
|
||||
// 更新缓存
|
||||
when(redisUtil.set(anyString(), any(Map.class))).thenReturn(Mono.just(true));
|
||||
|
||||
// 清除缓存
|
||||
when(redisUtil.deleteByPattern(anyString())).thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(MEMBER_ID, QR_CONTENT);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.contains("签到成功"))
|
||||
.verifyComplete();
|
||||
|
||||
verify(groupCourseBookingRepository).updateStatus(booking.getId(), "2");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("签到成功 - Redis 中缓存数据为 String 格式(兼容旧格式)")
|
||||
void checkIn_shouldSucceedWithStringCachedData() {
|
||||
// 未签到
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
// Redis 中缓存数据为 JSON 字符串格式
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + MEMBER_ID + LocalDate.now();
|
||||
String jsonData = "{\"qrContent\":\"" + QR_CONTENT + "\",\"isUsed\":false,\"memberId\":" + MEMBER_ID + "}";
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(jsonData));
|
||||
|
||||
// 无预约
|
||||
when(groupCourseBookingService.getBookingsByMemberId(MEMBER_ID)).thenReturn(Flux.empty());
|
||||
|
||||
// 保存签到记录
|
||||
when(signInRecordRepository.save(any(SignInRecord.class)))
|
||||
.thenReturn(Mono.just(createMockSignInRecord()));
|
||||
|
||||
// 更新缓存
|
||||
when(redisUtil.set(anyString(), any(Map.class))).thenReturn(Mono.just(true));
|
||||
|
||||
// 清除缓存
|
||||
when(redisUtil.deleteByPattern(anyString())).thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(MEMBER_ID, QR_CONTENT);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.contains("签到成功"))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ==================== checkIn - 异常场景 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("签到失败 - 当天已签到(数据库查重)")
|
||||
void checkIn_shouldFailWhenAlreadySignedIn() {
|
||||
SignInRecord existingRecord = SignInRecord.builder()
|
||||
.id(1L)
|
||||
.memberId(MEMBER_ID)
|
||||
.signInTime(LocalDateTime.now())
|
||||
.signInType("QR_CODE")
|
||||
.signInStatus("SUCCESS")
|
||||
.build();
|
||||
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(existingRecord));
|
||||
|
||||
Mono<String> result = checkService.checkIn(MEMBER_ID, QR_CONTENT);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectErrorMatches(e -> e instanceof RuntimeException
|
||||
&& e.getMessage().contains("请勿重复签到"))
|
||||
.verify();
|
||||
|
||||
verify(redisUtil, never()).get(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("签到失败 - 二维码已使用(isUsed=true)")
|
||||
void checkIn_shouldFailWhenQRCodeUsed() {
|
||||
// 未签到
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
// Redis 中二维码已被使用
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + MEMBER_ID + LocalDate.now();
|
||||
Map<String, Object> qrData = buildQrData(true);
|
||||
qrData.put("checkInTime", "2026-07-31 10:00:00");
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
|
||||
|
||||
Mono<String> result = checkService.checkIn(MEMBER_ID, QR_CONTENT);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectErrorMatches(e -> e instanceof RuntimeException
|
||||
&& e.getMessage().contains("请勿重复签到"))
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("签到失败 - 二维码内容不匹配")
|
||||
void checkIn_shouldFailWhenQRCodeInvalid() {
|
||||
// 未签到
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
// Redis 中二维码内容不匹配
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + MEMBER_ID + LocalDate.now();
|
||||
Map<String, Object> qrData = buildQrData(false);
|
||||
qrData.put("qrContent", "different-qr-content");
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
|
||||
|
||||
Mono<String> result = checkService.checkIn(MEMBER_ID, QR_CONTENT);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectErrorMatches(e -> e instanceof RuntimeException
|
||||
&& e.getMessage().contains("二维码无效"))
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("签到 - Redis 中无二维码数据时直接完成(无副作用)")
|
||||
void checkIn_shouldCompleteWhenQRCodeNotFound() {
|
||||
// 未签到
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
// Redis 中无数据
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + MEMBER_ID + LocalDate.now();
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(MEMBER_ID, "not-exist");
|
||||
|
||||
// 当 Redis 中无数据时,flatMap 不会执行,Mono 直接 complete
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("签到失败 - 二维码数据格式错误")
|
||||
void checkIn_shouldFailWhenQRCodeDataFormatError() {
|
||||
// 未签到
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
// Redis 返回非 Map 非 String 的数据
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + MEMBER_ID + LocalDate.now();
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(12345));
|
||||
|
||||
Mono<String> result = checkService.checkIn(MEMBER_ID, QR_CONTENT);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectErrorMatches(e -> e instanceof RuntimeException
|
||||
&& e.getMessage().contains("二维码数据格式错误"))
|
||||
.verify();
|
||||
}
|
||||
|
||||
// ==================== getSignInRecords ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("查询签到记录列表 - 成功")
|
||||
void getSignInRecords_shouldReturnRecords() {
|
||||
SignInRecord record = createMockSignInRecord();
|
||||
when(signInRecordRepository.findByMemberIdAndTimeRange(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Flux.just(record));
|
||||
|
||||
Flux<SignInRecordVO> result = checkService.getSignInRecords(
|
||||
MEMBER_ID, LocalDate.now().minusDays(30), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(vo -> {
|
||||
assertThat(vo.getMemberId()).isEqualTo(MEMBER_ID);
|
||||
assertThat(vo.getSignInType()).isEqualTo("QR_CODE");
|
||||
assertThat(vo.getSignInStatus()).isEqualTo("SUCCESS");
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("查询签到记录列表 - 无记录")
|
||||
void getSignInRecords_shouldReturnEmptyWhenNoRecords() {
|
||||
when(signInRecordRepository.findByMemberIdAndTimeRange(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Flux.empty());
|
||||
|
||||
Flux<SignInRecordVO> result = checkService.getSignInRecords(
|
||||
MEMBER_ID, LocalDate.now().minusDays(30), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ==================== getSignInRecordById ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("查询单条签到记录 - 存在")
|
||||
void getSignInRecordById_shouldReturnRecord() {
|
||||
SignInRecord record = createMockSignInRecord();
|
||||
when(signInRecordRepository.findById(1L)).thenReturn(Mono.just(record));
|
||||
|
||||
Mono<SignInRecordVO> result = checkService.getSignInRecordById(1L);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(vo -> vo.getId() == 1L && vo.getMemberId().equals(MEMBER_ID))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("查询单条签到记录 - 不存在")
|
||||
void getSignInRecordById_shouldReturnEmptyWhenNotFound() {
|
||||
when(signInRecordRepository.findById(999L)).thenReturn(Mono.empty());
|
||||
|
||||
Mono<SignInRecordVO> result = checkService.getSignInRecordById(999L);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ==================== getSignInStats ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("获取签到统计 - 成功")
|
||||
void getSignInStats_shouldReturnStats() {
|
||||
when(signInRecordRepository.countByMemberIdAndTimeRange(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(10L));
|
||||
when(signInRecordRepository.countSuccessByMemberIdAndTimeRange(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(8L));
|
||||
when(signInRecordRepository.getFirstSignInTime(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(LocalDateTime.now().minusDays(29)));
|
||||
when(signInRecordRepository.getLastSignInTime(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(LocalDateTime.now()));
|
||||
|
||||
Mono<SignInStatsVO> result = checkService.getSignInStats(
|
||||
MEMBER_ID, LocalDate.now().minusDays(30), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(stats -> {
|
||||
assertThat(stats.getTotalCount()).isEqualTo(10L);
|
||||
assertThat(stats.getSuccessCount()).isEqualTo(8L);
|
||||
assertThat(stats.getSuccessRate()).isEqualTo(80.0);
|
||||
assertThat(stats.getStartDate()).isNotNull();
|
||||
assertThat(stats.getEndDate()).isNotNull();
|
||||
assertThat(stats.getFirstSignInTime()).isNotNull();
|
||||
assertThat(stats.getLastSignInTime()).isNotNull();
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取签到统计 - 无记录时 Mono.zip 中任一源为空则整体为空")
|
||||
void getSignInStats_shouldCompleteWhenNoRecords() {
|
||||
when(signInRecordRepository.countByMemberIdAndTimeRange(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(0L));
|
||||
when(signInRecordRepository.countSuccessByMemberIdAndTimeRange(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(0L));
|
||||
when(signInRecordRepository.getFirstSignInTime(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
when(signInRecordRepository.getLastSignInTime(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<SignInStatsVO> result = checkService.getSignInStats(
|
||||
MEMBER_ID, LocalDate.now().minusDays(30), LocalDate.now());
|
||||
|
||||
// Mono.zip 中任意源为 empty 时,整体结果也为 empty
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ==================== getDailySignInStats ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("获取每日签到统计 - 成功")
|
||||
void getDailySignInStats_shouldReturnStats() {
|
||||
when(signInRecordRepository.countByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(50L));
|
||||
when(signInRecordRepository.countSuccessByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(45L));
|
||||
when(signInRecordRepository.countDistinctMembersByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(30L));
|
||||
|
||||
Mono<SignInStatsVO> result = checkService.getDailySignInStats(LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(stats -> {
|
||||
assertThat(stats.getTotalCount()).isEqualTo(50L);
|
||||
assertThat(stats.getSuccessCount()).isEqualTo(45L);
|
||||
assertThat(stats.getUniqueMemberCount()).isEqualTo(30L);
|
||||
assertThat(stats.getSuccessRate()).isEqualTo(90.0);
|
||||
assertThat(stats.getStartDate()).isEqualTo(LocalDate.now());
|
||||
assertThat(stats.getEndDate()).isEqualTo(LocalDate.now());
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取每日签到统计 - 无记录时成功率应为0")
|
||||
void getDailySignInStats_shouldReturnZeroRateWhenNoRecords() {
|
||||
when(signInRecordRepository.countByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(0L));
|
||||
when(signInRecordRepository.countSuccessByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(0L));
|
||||
when(signInRecordRepository.countDistinctMembersByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(0L));
|
||||
|
||||
Mono<SignInStatsVO> result = checkService.getDailySignInStats(LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(stats -> {
|
||||
assertThat(stats.getTotalCount()).isZero();
|
||||
assertThat(stats.getSuccessCount()).isZero();
|
||||
assertThat(stats.getUniqueMemberCount()).isZero();
|
||||
assertThat(stats.getSuccessRate()).isZero();
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ==================== exportSignInRecords ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("导出签到记录 - 生成CSV格式")
|
||||
void exportSignInRecords_shouldReturnCsvBytes() {
|
||||
SignInRecord record = createMockSignInRecord();
|
||||
when(signInRecordRepository.findByMemberIdAndTimeRange(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Flux.just(record));
|
||||
|
||||
Mono<byte[]> result = checkService.exportSignInRecords(
|
||||
MEMBER_ID, LocalDate.now().minusDays(7), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(bytes -> {
|
||||
String csv = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertThat(csv).contains("签到记录ID,会员ID,会员卡ID,签到时间,签到方式,签到状态,失败原因");
|
||||
assertThat(csv).contains("扫码签到");
|
||||
assertThat(csv).contains("成功");
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("导出签到记录 - 无记录时只返回表头")
|
||||
void exportSignInRecords_shouldReturnHeaderOnlyWhenNoRecords() {
|
||||
when(signInRecordRepository.findByMemberIdAndTimeRange(
|
||||
eq(MEMBER_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Flux.empty());
|
||||
|
||||
Mono<byte[]> result = checkService.exportSignInRecords(
|
||||
MEMBER_ID, LocalDate.now().minusDays(7), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(bytes -> {
|
||||
String csv = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertThat(csv).isEqualTo("签到记录ID,会员ID,会员卡ID,签到时间,签到方式,签到状态,失败原因");
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ==================== helper methods ====================
|
||||
|
||||
private Map<String, Object> buildQrData(boolean isUsed) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("qrContent", QR_CONTENT);
|
||||
map.put("isUsed", isUsed);
|
||||
map.put("memberId", MEMBER_ID);
|
||||
return map;
|
||||
}
|
||||
|
||||
private SignInRecord createMockSignInRecord() {
|
||||
return SignInRecord.builder()
|
||||
.id(1L)
|
||||
.memberId(MEMBER_ID)
|
||||
.memberCardId(1L)
|
||||
.signInTime(LocalDateTime.now())
|
||||
.signInType("QR_CODE")
|
||||
.signInStatus("SUCCESS")
|
||||
.source("MINI_PROGRAM")
|
||||
.isDelete(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
private GroupCourseBooking createValidBooking() {
|
||||
GroupCourseBooking booking = new GroupCourseBooking();
|
||||
booking.setId(100L);
|
||||
booking.setMemberId(MEMBER_ID);
|
||||
booking.setCourseId(200L);
|
||||
booking.setStatus("0");
|
||||
booking.setCourseStartTime(LocalDateTime.now().plusMinutes(5));
|
||||
booking.setCourseEndTime(LocalDateTime.now().plusMinutes(65));
|
||||
return booking;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user