chore: initial commit with CI pipeline, review and tasks docs

This commit is contained in:
Your Name
2025-09-30 16:39:51 +08:00
commit 8a7afc8a00
76 changed files with 5091 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
package com.mosquito.project;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
class SchemaVerificationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void activitiesTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'ACTIVITIES'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'activities' should exist in the database schema.");
}
@Test
void activityRewardsTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'ACTIVITY_REWARDS'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'activity_rewards' should exist in the database schema.");
}
@Test
void multiLevelRewardRulesTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'MULTI_LEVEL_REWARD_RULES'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'multi_level_reward_rules' should exist in the database schema.");
}
@Test
void apiKeysTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'API_KEYS'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'api_keys' should exist in the database schema.");
}
@Test
void dailyActivityStatsTableExists() throws SQLException {
boolean tableExists = jdbcTemplate.query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'DAILY_ACTIVITY_STATS'", (ResultSet rs) -> {
return rs.next();
});
assertTrue(tableExists, "Table 'daily_activity_stats' should exist in the database schema.");
}
}

View File

@@ -0,0 +1,35 @@
package com.mosquito.project.config;
import org.springframework.context.annotation.Configuration;
import redis.embedded.RedisServer;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.net.ServerSocket;
@Configuration
public class EmbeddedRedisConfiguration {
private RedisServer redisServer;
private int redisPort;
@PostConstruct
public void startRedis() throws IOException {
redisPort = getAvailablePort();
redisServer = new RedisServer(redisPort);
redisServer.start();
System.setProperty("spring.redis.port", String.valueOf(redisPort));
}
@PreDestroy
public void stopRedis() {
redisServer.stop();
}
private int getAvailablePort() throws IOException {
try (ServerSocket serverSocket = new ServerSocket(0)) {
return serverSocket.getLocalPort();
}
}
}

View File

@@ -0,0 +1,141 @@
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,83 @@
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.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.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.*;
@WebMvcTest(ApiKeyController.class)
class ApiKeyControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ActivityService activityService;
@Test
void whenCreateApiKey_withValidRequest_thenReturns201() throws Exception {
CreateApiKeyRequest request = new CreateApiKeyRequest();
request.setActivityId(1L);
request.setName("Test Key");
String rawApiKey = UUID.randomUUID().toString();
given(activityService.generateApiKey(any(CreateApiKeyRequest.class))).willReturn(rawApiKey);
mockMvc.perform(post("/api/v1/api-keys")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.apiKey").value(rawApiKey));
}
@Test
void whenCreateApiKey_forNonExistentActivity_thenReturns404() throws Exception {
CreateApiKeyRequest request = new CreateApiKeyRequest();
request.setActivityId(999L);
request.setName("Test Key");
given(activityService.generateApiKey(any(CreateApiKeyRequest.class)))
.willThrow(new ActivityNotFoundException("Activity not found"));
mockMvc.perform(post("/api/v1/api-keys")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound());
}
@Test
void whenRevokeApiKey_withExistingId_thenReturns204() throws Exception {
doNothing().when(activityService).revokeApiKey(1L);
mockMvc.perform(delete("/api/v1/api-keys/1"))
.andExpect(status().isNoContent());
}
@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());
}
}

View File

@@ -0,0 +1,51 @@
package com.mosquito.project.job;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.domain.DailyActivityStats;
import com.mosquito.project.service.ActivityService;
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 java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StatisticsAggregationJobTest {
@Mock
private ActivityService activityService;
@InjectMocks
private StatisticsAggregationJob statisticsAggregationJob;
@Test
void whenAggregateStatsForActivity_thenCreatesStats() {
// Arrange
Activity activity = new Activity();
activity.setId(1L);
activity.setName("Test Activity");
activity.setStartTime(ZonedDateTime.now());
activity.setEndTime(ZonedDateTime.now().plusDays(1));
LocalDate testDate = LocalDate.now();
// Act
DailyActivityStats stats = statisticsAggregationJob.aggregateStatsForActivity(activity, testDate);
// Assert
assertNotNull(stats);
assertEquals(activity.getId(), stats.getActivityId());
assertEquals(testDate, stats.getStatDate());
assertTrue(stats.getViews() >= 1000);
assertTrue(stats.getShares() >= 200);
assertTrue(stats.getNewRegistrations() >= 50);
assertTrue(stats.getConversions() >= 10);
}
}

View File

@@ -0,0 +1,52 @@
package com.mosquito.project.service;
import com.mosquito.project.config.EmbeddedRedisConfiguration;
import com.mosquito.project.domain.Activity;
import com.mosquito.project.dto.CreateActivityRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Import;
import java.time.ZonedDateTime;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest
@Import(EmbeddedRedisConfiguration.class)
class ActivityServiceCacheTest {
@Autowired
private ActivityService activityService;
@Autowired
private CacheManager cacheManager;
@AfterEach
void tearDown() {
Objects.requireNonNull(cacheManager.getCache("leaderboards")).clear();
}
@Test
void whenGetLeaderboardIsCalledTwice_thenSecondCallIsFromCache() {
// Arrange
CreateActivityRequest createRequest = new CreateActivityRequest();
createRequest.setName("Cached Activity");
createRequest.setStartTime(ZonedDateTime.now());
createRequest.setEndTime(ZonedDateTime.now().plusDays(1));
Activity activity = activityService.createActivity(createRequest);
Long activityId = activity.getId();
// Act: First call
activityService.getLeaderboard(activityId);
// Assert: Check that the cache contains the entry
assertNotNull(Objects.requireNonNull(cacheManager.getCache("leaderboards")).get(activityId));
// Act: Second call
activityService.getLeaderboard(activityId);
}
}

View File

@@ -0,0 +1,178 @@
package com.mosquito.project.service;
import com.mosquito.project.domain.*;
import com.mosquito.project.dto.CreateActivityRequest;
import com.mosquito.project.dto.CreateApiKeyRequest;
import com.mosquito.project.dto.UpdateActivityRequest;
import com.mosquito.project.exception.ActivityNotFoundException;
import com.mosquito.project.exception.ApiKeyNotFoundException;
import com.mosquito.project.exception.FileUploadException;
import com.mosquito.project.exception.InvalidActivityDataException;
import com.mosquito.project.exception.UserNotAuthorizedForActivityException;
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.mock.web.MockMultipartFile;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
class ActivityServiceTest {
private static final long THIRTY_MEGABYTES = 30 * 1024 * 1024;
@Autowired
private ActivityService activityService;
@Test
@DisplayName("当使用有效的请求创建活动时,应成功")
void whenCreateActivity_withValidRequest_thenSucceeds() {
CreateActivityRequest request = new CreateActivityRequest();
request.setName("新活动");
ZonedDateTime startTime = ZonedDateTime.now().plusDays(1);
ZonedDateTime endTime = ZonedDateTime.now().plusDays(2);
request.setStartTime(startTime);
request.setEndTime(endTime);
Activity createdActivity = activityService.createActivity(request);
assertNotNull(createdActivity);
assertEquals("新活动", createdActivity.getName());
assertEquals(startTime, createdActivity.getStartTime());
assertEquals(endTime, createdActivity.getEndTime());
}
@Test
@DisplayName("创建活动时,如果结束时间早于开始时间,应抛出异常")
void whenCreateActivity_withEndTimeBeforeStartTime_thenThrowException() {
CreateActivityRequest request = new CreateActivityRequest();
request.setName("无效活动");
ZonedDateTime startTime = ZonedDateTime.now();
ZonedDateTime endTime = startTime.minusDays(1);
request.setStartTime(startTime);
request.setEndTime(endTime);
InvalidActivityDataException exception = assertThrows(
InvalidActivityDataException.class,
() -> activityService.createActivity(request)
);
assertEquals("活动结束时间不能早于开始时间。", exception.getMessage());
}
@Test
@DisplayName("当更新一个不存在的活动时应抛出ActivityNotFoundException")
void whenUpdateActivity_withNonExistentId_thenThrowsActivityNotFoundException() {
UpdateActivityRequest updateRequest = new UpdateActivityRequest();
updateRequest.setName("更新请求");
updateRequest.setStartTime(ZonedDateTime.now().plusDays(1));
updateRequest.setEndTime(ZonedDateTime.now().plusDays(2));
Long nonExistentId = 999L;
assertThrows(ActivityNotFoundException.class, () -> {
activityService.updateActivity(nonExistentId, updateRequest);
});
}
@Test
@DisplayName("当通过存在的ID获取活动时应返回活动")
void whenGetActivityById_withExistingId_thenReturnsActivity() {
CreateActivityRequest createRequest = new CreateActivityRequest();
createRequest.setName("测试活动");
createRequest.setStartTime(ZonedDateTime.now().plusDays(1));
createRequest.setEndTime(ZonedDateTime.now().plusDays(2));
Activity createdActivity = activityService.createActivity(createRequest);
Activity foundActivity = activityService.getActivityById(createdActivity.getId());
assertNotNull(foundActivity);
assertEquals(createdActivity.getId(), foundActivity.getId());
assertEquals("测试活动", foundActivity.getName());
}
@Test
@DisplayName("当通过不存在的ID获取活动时应抛出ActivityNotFoundException")
void whenGetActivityById_withNonExistentId_thenThrowsActivityNotFoundException() {
Long nonExistentId = 999L;
assertThrows(ActivityNotFoundException.class, () -> {
activityService.getActivityById(nonExistentId);
});
}
@Test
@DisplayName("当为存在的活动生成API密钥时应成功")
void whenGenerateApiKey_withValidRequest_thenReturnsKeyAndStoresHashedVersion() {
CreateActivityRequest createActivityRequest = new CreateActivityRequest();
createActivityRequest.setName("活动");
createActivityRequest.setStartTime(ZonedDateTime.now().plusDays(1));
createActivityRequest.setEndTime(ZonedDateTime.now().plusDays(2));
Activity activity = activityService.createActivity(createActivityRequest);
CreateApiKeyRequest apiKeyRequest = new CreateApiKeyRequest();
apiKeyRequest.setActivityId(activity.getId());
apiKeyRequest.setName("测试密钥");
String rawApiKey = activityService.generateApiKey(apiKeyRequest);
assertNotNull(rawApiKey);
assertDoesNotThrow(() -> UUID.fromString(rawApiKey));
}
@Test
@DisplayName("当为不存在的活动生成API密钥时应抛出异常")
void whenGenerateApiKey_forNonExistentActivity_thenThrowsException() {
CreateApiKeyRequest apiKeyRequest = new CreateApiKeyRequest();
apiKeyRequest.setActivityId(999L); // Non-existent
apiKeyRequest.setName("测试密钥");
assertThrows(ActivityNotFoundException.class, () -> {
activityService.generateApiKey(apiKeyRequest);
});
}
@Test
@DisplayName("当吊销一个存在的API密钥时应成功")
void whenRevokeApiKey_withExistingId_thenSucceeds() {
// Arrange
CreateActivityRequest createActivityRequest = new CreateActivityRequest();
createActivityRequest.setName("活动");
createActivityRequest.setStartTime(ZonedDateTime.now().plusDays(1));
createActivityRequest.setEndTime(ZonedDateTime.now().plusDays(2));
Activity activity = activityService.createActivity(createActivityRequest);
CreateApiKeyRequest apiKeyRequest = new CreateApiKeyRequest();
apiKeyRequest.setActivityId(activity.getId());
apiKeyRequest.setName("测试密钥");
activityService.generateApiKey(apiKeyRequest);
// Act & Assert
assertDoesNotThrow(() -> {
activityService.revokeApiKey(1L);
});
}
@Test
@DisplayName("当吊销一个不存在的API密钥时应抛出ApiKeyNotFoundException")
void whenRevokeApiKey_withNonExistentId_thenThrowsApiKeyNotFoundException() {
// Arrange
Long nonExistentId = 999L;
// Act & Assert
assertThrows(ApiKeyNotFoundException.class, () -> {
activityService.revokeApiKey(nonExistentId);
});
}
// Other tests remain the same...
}