test(cache): 修复CacheConfigTest边界值测试

- 修改 shouldVerifyCacheManager_withMaximumIntegerTtl 为 shouldVerifyCacheManager_withMaximumAllowedTtl
- 使用正确的最大TTL值(10080分钟,7天)而不是 Integer.MAX_VALUE
- 新增 shouldThrowException_whenTtlExceedsMaximum 测试验证边界检查
- 所有1266个测试用例通过
- 覆盖率: 指令81.89%, 行88.48%, 分支51.55%

docs: 添加项目状态报告
- 生成 PROJECT_STATUS_REPORT.md 详细记录项目当前状态
- 包含质量指标、已完成功能、待办事项和技术债务
This commit is contained in:
Your Name
2026-03-02 13:31:54 +08:00
parent 32d6449ea4
commit 91a0b77f7a
2272 changed files with 221995 additions and 503 deletions

View File

@@ -0,0 +1,50 @@
package com.mosquito.project.controller;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.service.ActivityService;
import com.mosquito.project.support.TestAuthSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(ActivityController.class)
@Import(com.mosquito.project.config.ControllerTestConfig.class)
@TestPropertySource(properties = {
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," +
"org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," +
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
})
class ActivityControllerContractTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ActivityService activityService;
@Test
void shouldReturnApiResponseEnvelope() throws Exception {
Activity activity = new Activity();
activity.setId(1L);
activity.setName("测试活动");
when(activityService.getActivityById(1L)).thenReturn(activity);
mockMvc.perform(get("/api/v1/activities/1")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.id").value(1));
}
}

View File

@@ -1,141 +0,0 @@
package com.mosquito.project.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.dto.CreateActivityRequest;
import com.mosquito.project.dto.UpdateActivityRequest;
import com.mosquito.project.dto.ActivityStatsResponse;
import com.mosquito.project.dto.ActivityGraphResponse;
import com.mosquito.project.exception.ActivityNotFoundException;
import com.mosquito.project.service.ActivityService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.ZonedDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(ActivityController.class)
class ActivityControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ActivityService activityService;
@Test
void whenCreateActivity_withValidInput_thenReturns201() throws Exception {
CreateActivityRequest request = new CreateActivityRequest();
request.setName("Valid Activity");
request.setStartTime(ZonedDateTime.now().plusDays(1));
request.setEndTime(ZonedDateTime.now().plusDays(2));
Activity activity = new Activity();
activity.setId(1L);
activity.setName(request.getName());
given(activityService.createActivity(any(CreateActivityRequest.class))).willReturn(activity);
mockMvc.perform(post("/api/v1/activities")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Valid Activity"));
}
@Test
void whenGetActivity_withExistingId_thenReturns200() throws Exception {
Activity activity = new Activity();
activity.setId(1L);
activity.setName("Test Activity");
given(activityService.getActivityById(1L)).willReturn(activity);
mockMvc.perform(get("/api/v1/activities/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Test Activity"));
}
@Test
void whenGetActivity_withNonExistentId_thenReturns404() throws Exception {
given(activityService.getActivityById(999L)).willThrow(new ActivityNotFoundException("Activity not found"));
mockMvc.perform(get("/api/v1/activities/999"))
.andExpect(status().isNotFound());
}
@Test
void whenUpdateActivity_withValidInput_thenReturns200() throws Exception {
UpdateActivityRequest request = new UpdateActivityRequest();
request.setName("Updated Activity");
request.setStartTime(ZonedDateTime.now().plusDays(1));
request.setEndTime(ZonedDateTime.now().plusDays(2));
Activity activity = new Activity();
activity.setId(1L);
activity.setName(request.getName());
given(activityService.updateActivity(eq(1L), any(UpdateActivityRequest.class))).willReturn(activity);
mockMvc.perform(put("/api/v1/activities/1")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value("Updated Activity"));
}
@Test
void whenGetActivityStats_withExistingId_thenReturns200() throws Exception {
List<ActivityStatsResponse.DailyStats> dailyStats = List.of(
new ActivityStatsResponse.DailyStats("2025-09-28", 100, 50),
new ActivityStatsResponse.DailyStats("2025-09-29", 120, 60)
);
ActivityStatsResponse stats = new ActivityStatsResponse(220, 110, dailyStats);
given(activityService.getActivityStats(1L)).willReturn(stats);
mockMvc.perform(get("/api/v1/activities/1/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalParticipants").value(220))
.andExpect(jsonPath("$.totalShares").value(110));
}
@Test
void whenGetActivityGraph_withExistingId_thenReturns200() throws Exception {
List<ActivityGraphResponse.Node> nodes = List.of(
new ActivityGraphResponse.Node("1", "User A"),
new ActivityGraphResponse.Node("2", "User B"),
new ActivityGraphResponse.Node("3", "User C")
);
List<ActivityGraphResponse.Edge> edges = List.of(
new ActivityGraphResponse.Edge("1", "2"),
new ActivityGraphResponse.Edge("1", "3")
);
ActivityGraphResponse graph = new ActivityGraphResponse(nodes, edges);
given(activityService.getActivityGraph(1L)).willReturn(graph);
mockMvc.perform(get("/api/v1/activities/1/graph"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nodes.length()").value(3))
.andExpect(jsonPath("$.edges.length()").value(2));
}
}

View File

@@ -0,0 +1,108 @@
package com.mosquito.project.controller;
import com.mosquito.project.domain.LeaderboardEntry;
import com.mosquito.project.service.ActivityService;
import com.mosquito.project.support.TestAuthSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@WebMvcTest(ActivityController.class)
@Import(com.mosquito.project.config.ControllerTestConfig.class)
@TestPropertySource(properties = {
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," +
"org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," +
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
})
class ActivityLeaderboardControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ActivityService activityService;
@Test
void shouldReturnLeaderboard_whenActivityExists() throws Exception {
when(activityService.getLeaderboard(1L)).thenReturn(
List.of(
new LeaderboardEntry(1L, "用户A", 1500),
new LeaderboardEntry(2L, "用户B", 1200)
)
);
mockMvc.perform(get("/api/v1/activities/1/leaderboard")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").isArray());
}
@Test
void shouldExportLeaderboardCsv_withAttachmentHeaders() throws Exception {
String csv = "userId,userName,score\n1,用户A,1500\n2,用户B,1200\n";
when(activityService.generateLeaderboardCsv(1L)).thenReturn(csv);
mockMvc.perform(get("/api/v1/activities/1/leaderboard/export")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "text/csv;charset=UTF-8"))
.andExpect(header().string("Content-Disposition", "attachment; filename=\"leaderboard_1.csv\""))
.andExpect(content().string(csv));
}
@Test
void shouldSupportPaginationAndTopN_onLeaderboard() throws Exception {
when(activityService.getLeaderboard(1L)).thenReturn(
List.of(
new LeaderboardEntry(1L, "用户1", 1000),
new LeaderboardEntry(2L, "用户2", 900),
new LeaderboardEntry(3L, "用户3", 800),
new LeaderboardEntry(4L, "用户4", 700),
new LeaderboardEntry(5L, "用户5", 600)
)
);
mockMvc.perform(get("/api/v1/activities/1/leaderboard")
.param("topN", "4")
.param("page", "1")
.param("size", "2")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.meta.pagination.total").value(4))
.andExpect(jsonPath("$.data[0].userId").value(3))
.andExpect(jsonPath("$.data[1].userId").value(4));
}
@Test
void shouldApplyTopN_onCsvExport() throws Exception {
when(activityService.generateLeaderboardCsv(1L, 1)).thenReturn("userId,userName,score\n1,用户1,1000\n");
mockMvc.perform(get("/api/v1/activities/1/leaderboard/export")
.param("topN", "1")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(content().string("userId,userName,score\n1,用户1,1000\n"));
}
}

View File

@@ -0,0 +1,118 @@
package com.mosquito.project.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mosquito.project.dto.ActivityGraphResponse;
import com.mosquito.project.dto.ActivityStatsResponse;
import com.mosquito.project.service.ActivityService;
import com.mosquito.project.support.TestAuthSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(ActivityController.class)
@Import(com.mosquito.project.config.ControllerTestConfig.class)
@TestPropertySource(properties = {
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," +
"org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," +
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
})
class ActivityStatsAndGraphControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ActivityService activityService;
@Test
void shouldReturnStats_whenActivityExists() throws Exception {
ActivityStatsResponse mock = new ActivityStatsResponse(220, 110,
List.of(new ActivityStatsResponse.DailyStats("2025-09-28", 100, 50)));
when(activityService.getActivityStats(1L)).thenReturn(mock);
mockMvc.perform(get("/api/v1/activities/1/stats")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.totalParticipants").value(220))
.andExpect(jsonPath("$.data.totalShares").value(110))
.andExpect(jsonPath("$.data.dailyStats[0].date").value("2025-09-28"));
}
@Test
void shouldReturnGraph_whenActivityExists() throws Exception {
ActivityGraphResponse graph = new ActivityGraphResponse(
List.of(new ActivityGraphResponse.Node("1", "用户1")),
List.of(new ActivityGraphResponse.Edge("1", "2"))
);
when(activityService.getActivityGraph(1L, null, 3, 1000)).thenReturn(graph);
mockMvc.perform(get("/api/v1/activities/1/graph")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.nodes").exists())
.andExpect(jsonPath("$.data.edges").exists());
}
@Test
void shouldRespectRootAndDepthParams() throws Exception {
ActivityGraphResponse graph = new ActivityGraphResponse(
List.of(new ActivityGraphResponse.Node("1", "用户1"), new ActivityGraphResponse.Node("2", "用户2")),
List.of(new ActivityGraphResponse.Edge("1", "2"))
);
when(activityService.getActivityGraph(1L, 1L, 1, 10)).thenReturn(graph);
mockMvc.perform(get("/api/v1/activities/1/graph")
.param("rootUserId", "1")
.param("maxDepth", "1")
.param("limit", "10")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.nodes").isArray())
.andExpect(jsonPath("$.data.edges[0].from").value("1"));
}
@Test
void shouldIncludePaginationMeta_onLeaderboard() throws Exception {
when(activityService.getLeaderboard(1L)).thenReturn(
List.of(
new com.mosquito.project.domain.LeaderboardEntry(1L, "用户1", 1000),
new com.mosquito.project.domain.LeaderboardEntry(2L, "用户2", 900),
new com.mosquito.project.domain.LeaderboardEntry(3L, "用户3", 800)
)
);
mockMvc.perform(get("/api/v1/activities/1/leaderboard")
.param("page", "0")
.param("size", "2")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta.pagination.total").value(3))
.andExpect(jsonPath("$.data[0].userId").value(1));
}
}

View File

@@ -2,26 +2,34 @@ package com.mosquito.project.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mosquito.project.dto.CreateApiKeyRequest;
import com.mosquito.project.exception.ActivityNotFoundException;
import com.mosquito.project.exception.ApiKeyNotFoundException;
import com.mosquito.project.dto.UseApiKeyRequest;
import com.mosquito.project.service.ActivityService;
import com.mosquito.project.support.TestAuthSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(ApiKeyController.class)
@Import(com.mosquito.project.config.ControllerTestConfig.class)
@TestPropertySource(properties = {
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," +
"org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," +
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
})
class ApiKeyControllerTest {
@Autowired
@@ -34,50 +42,74 @@ class ApiKeyControllerTest {
private ActivityService activityService;
@Test
void whenCreateApiKey_withValidRequest_thenReturns201() throws Exception {
void createApiKey_shouldReturn201WithEnvelope() throws Exception {
when(activityService.generateApiKey(any(CreateApiKeyRequest.class))).thenReturn("raw-key");
CreateApiKeyRequest request = new CreateApiKeyRequest();
request.setActivityId(1L);
request.setName("Test Key");
String rawApiKey = UUID.randomUUID().toString();
given(activityService.generateApiKey(any(CreateApiKeyRequest.class))).willReturn(rawApiKey);
request.setName("test");
mockMvc.perform(post("/api/v1/api-keys")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.apiKey").value(rawApiKey));
.content(objectMapper.writeValueAsString(request))
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(201))
.andExpect(jsonPath("$.data.apiKey").value("raw-key"));
}
@Test
void whenCreateApiKey_forNonExistentActivity_thenReturns404() throws Exception {
CreateApiKeyRequest request = new CreateApiKeyRequest();
request.setActivityId(999L);
request.setName("Test Key");
void revealApiKey_shouldReturnMessage() throws Exception {
when(activityService.revealApiKey(1L)).thenReturn("raw-key");
given(activityService.generateApiKey(any(CreateApiKeyRequest.class)))
.willThrow(new ActivityNotFoundException("Activity not found"));
mockMvc.perform(get("/api/v1/api-keys/1/reveal")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.apiKey").value("raw-key"));
}
mockMvc.perform(post("/api/v1/api-keys")
@Test
void revokeApiKey_shouldReturnOk() throws Exception {
mockMvc.perform(delete("/api/v1/api-keys/1")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
verify(activityService).revokeApiKey(1L);
}
@Test
void useApiKey_shouldReturnOk() throws Exception {
UseApiKeyRequest request = new UseApiKeyRequest();
request.setApiKey("raw-key");
mockMvc.perform(post("/api/v1/api-keys/1/use")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound());
.content(objectMapper.writeValueAsString(request))
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
verify(activityService).validateAndMarkApiKeyUsed(1L, "raw-key");
}
@Test
void whenRevokeApiKey_withExistingId_thenReturns204() throws Exception {
doNothing().when(activityService).revokeApiKey(1L);
void validateApiKey_shouldReturnOk() throws Exception {
UseApiKeyRequest request = new UseApiKeyRequest();
request.setApiKey("raw-key");
mockMvc.perform(delete("/api/v1/api-keys/1"))
.andExpect(status().isNoContent());
}
mockMvc.perform(post("/api/v1/api-keys/validate")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
@Test
void whenRevokeApiKey_withNonExistentId_thenReturns404() throws Exception {
doThrow(new ApiKeyNotFoundException("API Key not found")).when(activityService).revokeApiKey(999L);
mockMvc.perform(delete("/api/v1/api-keys/999"))
.andExpect(status().isNotFound());
verify(activityService).validateApiKeyByPrefixAndMarkUsed("raw-key");
}
}

View File

@@ -0,0 +1,76 @@
package com.mosquito.project.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mosquito.project.dto.RegisterCallbackRequest;
import com.mosquito.project.dto.CreateActivityRequest;
import com.mosquito.project.service.ActivityService;
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.web.server.LocalServerPort;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"spring.flyway.enabled=false",
"app.rate-limit.per-minute=2",
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration"
})
class CallbackControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private ActivityService activityService;
private String anyValidApiKey() {
CreateActivityRequest r = new CreateActivityRequest();
r.setName("cb");
r.setStartTime(java.time.ZonedDateTime.now());
r.setEndTime(java.time.ZonedDateTime.now().plusDays(1));
var act = activityService.createActivity(r);
com.mosquito.project.dto.CreateApiKeyRequest k = new com.mosquito.project.dto.CreateApiKeyRequest();
k.setActivityId(act.getId());
k.setName("k");
return activityService.generateApiKey(k);
}
@Test
void shouldBeIdempotent_andRateLimited() throws Exception {
String key = anyValidApiKey();
RegisterCallbackRequest req = new RegisterCallbackRequest();
req.setTrackingId("track-001");
mockMvc.perform(post("/api/v1/callback/register")
.header("X-API-Key", key)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk());
// 2nd same tracking id should still be OK (idempotent)
mockMvc.perform(post("/api/v1/callback/register")
.header("X-API-Key", key)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk());
// exceed rate limit (limit=2 per minute) with a different tracking id
RegisterCallbackRequest req2 = new RegisterCallbackRequest();
req2.setTrackingId("track-002");
mockMvc.perform(post("/api/v1/callback/register")
.header("X-API-Key", key)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req2)))
.andExpect(status().isTooManyRequests());
}
}

View File

@@ -0,0 +1,171 @@
package com.mosquito.project.controller;
import com.mosquito.project.dto.ShareMetricsResponse;
import com.mosquito.project.dto.ShareTrackingResponse;
import com.mosquito.project.service.ShareConfigService;
import com.mosquito.project.service.ShareTrackingService;
import com.mosquito.project.support.TestAuthSupport;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(ShareTrackingController.class)
@Import(com.mosquito.project.config.ControllerTestConfig.class)
@TestPropertySource(properties = {
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," +
"org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," +
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
})
class ShareTrackingControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ShareTrackingService trackingService;
@MockBean
private ShareConfigService shareConfigService;
@Test
void createShareTracking_shouldReturnPayload() throws Exception {
ShareTrackingResponse response = new ShareTrackingResponse("track-1", "abc123", "https://example.com", 1L, 2L);
when(trackingService.createShareTracking(eq(1L), eq(2L), eq("wechat"), any())).thenReturn(response);
mockMvc.perform(post("/api/v1/share/track")
.param("activityId", "1")
.param("inviterUserId", "2")
.param("source", "wechat")
.param("utm", "campaign-a")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.shortCode").value("abc123"));
}
@Test
void getShareMetrics_shouldApplyDefaultTimeRange() throws Exception {
ShareMetricsResponse metrics = new ShareMetricsResponse();
metrics.setActivityId(1L);
when(trackingService.getShareMetrics(eq(1L), any(), any())).thenReturn(metrics);
mockMvc.perform(get("/api/v1/share/metrics")
.param("activityId", "1")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ArgumentCaptor<OffsetDateTime> startCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
ArgumentCaptor<OffsetDateTime> endCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
verify(trackingService).getShareMetrics(eq(1L), startCaptor.capture(), endCaptor.capture());
OffsetDateTime start = startCaptor.getValue();
OffsetDateTime end = endCaptor.getValue();
assertNotNull(start);
assertNotNull(end);
long days = ChronoUnit.DAYS.between(start, end);
assertTrue(days >= 6 && days <= 8);
}
@Test
void getTopShareLinks_shouldReturnList() throws Exception {
when(trackingService.getTopShareLinks(1L, 10)).thenReturn(List.of(Map.of("code", "a1")));
mockMvc.perform(get("/api/v1/share/top-links")
.param("activityId", "1")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[0].code").value("a1"));
}
@Test
void getConversionFunnel_shouldApplyDefaultTimeRange() throws Exception {
when(trackingService.getConversionFunnel(eq(1L), any(), any())).thenReturn(Map.of("share", 10));
mockMvc.perform(get("/api/v1/share/funnel")
.param("activityId", "1")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.share").value(10));
ArgumentCaptor<OffsetDateTime> startCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
ArgumentCaptor<OffsetDateTime> endCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
verify(trackingService).getConversionFunnel(eq(1L), startCaptor.capture(), endCaptor.capture());
OffsetDateTime start = startCaptor.getValue();
OffsetDateTime end = endCaptor.getValue();
assertNotNull(start);
assertNotNull(end);
long days = ChronoUnit.DAYS.between(start, end);
assertTrue(days >= 6 && days <= 8);
}
@Test
void getShareMeta_shouldReturnData() throws Exception {
when(shareConfigService.getShareMeta(1L, 2L, "default"))
.thenReturn(Map.of("title", "分享标题"));
mockMvc.perform(get("/api/v1/share/share-meta")
.param("activityId", "1")
.param("userId", "2")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.title").value("分享标题"));
}
@Test
void registerShareSource_shouldForwardChannelAndParams() throws Exception {
mockMvc.perform(post("/api/v1/share/register-source")
.param("activityId", "1")
.param("userId", "2")
.param("channel", "wechat")
.param("utm", "campaign-a")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
ArgumentCaptor<Map<String, String>> paramsCaptor = ArgumentCaptor.forClass(Map.class);
verify(trackingService).createShareTracking(eq(1L), eq(2L), eq("wechat"), paramsCaptor.capture());
Map<String, String> params = paramsCaptor.getValue();
assertNotNull(params.get("registered_at"));
assertTrue(params.containsKey("channel"));
assertTrue(params.containsKey("utm"));
}
}

View File

@@ -0,0 +1,133 @@
package com.mosquito.project.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mosquito.project.dto.ShortenRequest;
import com.mosquito.project.persistence.entity.ShortLinkEntity;
import com.mosquito.project.service.ShortLinkService;
import com.mosquito.project.persistence.repository.LinkClickRepository;
import com.mosquito.project.persistence.repository.ApiKeyRepository;
import com.mosquito.project.security.IntrospectionResponse;
import com.mosquito.project.security.UserIntrospectionService;
import com.mosquito.project.support.TestAuthSupport;
import com.mosquito.project.web.UrlValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(ShortLinkController.class)
class ShortLinkControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ShortLinkService shortLinkService;
@MockBean
private LinkClickRepository linkClickRepository;
@MockBean
private ApiKeyRepository apiKeyRepository;
@MockBean
private StringRedisTemplate redisTemplate;
@MockBean
private UrlValidator urlValidator;
@MockBean
private UserIntrospectionService userIntrospectionService;
@BeforeEach
void setUpAuthStubs() {
when(apiKeyRepository.findByKeyPrefix(TestAuthSupport.API_KEY_PREFIX))
.thenReturn(Optional.of(TestAuthSupport.buildApiKeyEntity()));
IntrospectionResponse response = new IntrospectionResponse();
response.setActive(true);
when(userIntrospectionService.introspect(any())).thenReturn(response);
}
@Test
void shouldCreateShortLink_andReturn201() throws Exception {
ShortLinkEntity e = new ShortLinkEntity();
e.setCode("abc12345");
e.setOriginalUrl("https://example.com/page");
when(shortLinkService.create(anyString())).thenReturn(e);
ShortenRequest req = new ShortenRequest();
req.setOriginalUrl("https://example.com/page");
mockMvc.perform(post("/api/v1/internal/shorten")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
.header("X-API-Key", TestAuthSupport.RAW_API_KEY))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value("abc12345"))
.andExpect(jsonPath("$.path").value("/r/abc12345"))
.andExpect(jsonPath("$.originalUrl").value("https://example.com/page"));
}
@Test
void shouldRedirect_whenCodeExists() throws Exception {
ShortLinkEntity e = new ShortLinkEntity();
e.setCode("abc12345");
e.setOriginalUrl("https://example.com/page");
when(shortLinkService.findByCode("abc12345")).thenReturn(Optional.of(e));
when(urlValidator.isAllowedUrl("https://example.com/page")).thenReturn(true);
mockMvc.perform(get("/r/abc12345"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "https://example.com/page"));
}
@Test
void redirect_shouldStillReturn302_whenClickSaveFails() throws Exception {
ShortLinkEntity e = new ShortLinkEntity();
e.setCode("fail1234");
e.setOriginalUrl("https://example.com/fail");
when(shortLinkService.findByCode("fail1234")).thenReturn(Optional.of(e));
when(urlValidator.isAllowedUrl("https://example.com/fail")).thenReturn(true);
when(linkClickRepository.save(any())).thenThrow(new RuntimeException("save failed"));
mockMvc.perform(get("/r/fail1234"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "https://example.com/fail"));
}
@Test
void should404_whenCodeNotFound() throws Exception {
when(shortLinkService.findByCode("nope")).thenReturn(Optional.empty());
mockMvc.perform(get("/r/nope"))
.andExpect(status().isNotFound());
}
@Test
void shouldBlockMaliciousUrl() throws Exception {
ShortLinkEntity e = new ShortLinkEntity();
e.setCode("mal12345");
e.setOriginalUrl("http://192.168.1.1/admin");
when(shortLinkService.findByCode("mal12345")).thenReturn(Optional.of(e));
when(urlValidator.isAllowedUrl("http://192.168.1.1/admin")).thenReturn(false);
mockMvc.perform(get("/r/mal12345"))
.andExpect(status().isBadRequest());
}
}

View File

@@ -0,0 +1,177 @@
package com.mosquito.project.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mosquito.project.persistence.entity.ShortLinkEntity;
import com.mosquito.project.persistence.entity.UserInviteEntity;
import com.mosquito.project.persistence.repository.UserInviteRepository;
import com.mosquito.project.service.PosterRenderService;
import com.mosquito.project.service.ShareConfigService;
import com.mosquito.project.service.ShortLinkService;
import com.mosquito.project.support.TestAuthSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserExperienceController.class)
@Import(com.mosquito.project.config.ControllerTestConfig.class)
@TestPropertySource(properties = {
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," +
"org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," +
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
})
class UserExperienceControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ShortLinkService shortLinkService;
@MockBean
private UserInviteRepository userInviteRepository;
@MockBean
private PosterRenderService posterRenderService;
@MockBean
private ShareConfigService shareConfigService;
@MockBean
private com.mosquito.project.persistence.repository.UserRewardRepository userRewardRepository;
@Test
void shouldReturnInvitationInfo_withShortLink() throws Exception {
ShortLinkEntity e = new ShortLinkEntity();
e.setCode("inv12345");
e.setOriginalUrl("https://example.com/landing?activityId=1&inviter=2");
when(shortLinkService.create(anyString())).thenReturn(e);
when(shareConfigService.buildShareUrl(anyLong(), anyLong(), anyString(), any())).thenReturn("https://example.com/landing?activityId=1&inviter=2");
mockMvc.perform(get("/api/v1/me/invitation-info")
.param("activityId", "1")
.param("userId", "2")
.accept(MediaType.APPLICATION_JSON)
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.code").value("inv12345"))
.andExpect(jsonPath("$.data.path").value("/r/inv12345"));
}
@Test
void shouldReturnInvitedFriends_withPagination() throws Exception {
UserInviteEntity a = new UserInviteEntity(); a.setInviteeUserId(10L); a.setStatus("clicked");
UserInviteEntity b = new UserInviteEntity(); b.setInviteeUserId(11L); b.setStatus("registered");
UserInviteEntity c = new UserInviteEntity(); c.setInviteeUserId(12L); c.setStatus("ordered");
when(userInviteRepository.findByActivityIdAndInviterUserId(anyLong(), anyLong())).thenReturn(List.of(a,b,c));
mockMvc.perform(get("/api/v1/me/invited-friends")
.param("activityId", "1")
.param("userId", "2")
.param("page", "1")
.param("size", "1")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[0].status").value("registered"));
}
@Test
void shouldReturnPosterImage() throws Exception {
when(posterRenderService.renderPoster(anyLong(), anyLong(), anyString())).thenReturn("placeholder".getBytes());
mockMvc.perform(get("/api/v1/me/poster/image")
.param("activityId", "1")
.param("userId", "2")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "image/png"));
}
@Test
void posterImage_shouldReturn500_whenRenderFails() throws Exception {
when(posterRenderService.renderPoster(anyLong(), anyLong(), anyString()))
.thenThrow(new RuntimeException("render failed"));
mockMvc.perform(get("/api/v1/me/poster/image")
.param("activityId", "1")
.param("userId", "2")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isInternalServerError());
}
@Test
void shouldReturnPosterConfig() throws Exception {
mockMvc.perform(get("/api/v1/me/poster/config")
.param("template", "default")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.template").value("default"));
}
@Test
void shouldReturnPosterHtml() throws Exception {
when(posterRenderService.renderPosterHtml(anyLong(), anyLong(), anyString())).thenReturn("<html></html>");
mockMvc.perform(get("/api/v1/me/poster/html")
.param("activityId", "1")
.param("userId", "2")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.TEXT_HTML));
}
@Test
void posterHtml_shouldReturn500_whenRenderFails() throws Exception {
when(posterRenderService.renderPosterHtml(anyLong(), anyLong(), anyString()))
.thenThrow(new RuntimeException("render failed"));
mockMvc.perform(get("/api/v1/me/poster/html")
.param("activityId", "1")
.param("userId", "2")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isInternalServerError());
}
@Test
void shouldReturnRewards_withPagination() throws Exception {
var r1 = new com.mosquito.project.persistence.entity.UserRewardEntity(); r1.setType("points"); r1.setPoints(100); r1.setCreatedAt(java.time.OffsetDateTime.now());
var r2 = new com.mosquito.project.persistence.entity.UserRewardEntity(); r2.setType("coupon"); r2.setPoints(0); r2.setCreatedAt(java.time.OffsetDateTime.now().minusDays(1));
when(userRewardRepository.findByActivityIdAndUserIdOrderByCreatedAtDesc(anyLong(), anyLong())).thenReturn(java.util.List.of(r1, r2));
mockMvc.perform(get("/api/v1/me/rewards")
.param("activityId", "1")
.param("userId", "2")
.param("page", "0")
.param("size", "1")
.header("X-API-Key", TestAuthSupport.RAW_API_KEY)
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data[0].type").value("points"));
}
}