Add earnings adjustments, withdrawal reject, and auth guard
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
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.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* Authorization contract tests for admin earnings adjustments.
|
||||
*
|
||||
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
|
||||
*/
|
||||
class AdminEarningsAdjustmentAuthorizationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
private static final String PERMISSION_ADJUSTMENT_CREATE = "withdraw:adjustment:create";
|
||||
private static final String PERMISSION_ADJUSTMENT_READ = "withdraw:adjustment:read";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService groupInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
private final List<String> idempotencyKeysToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> groupIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
idempotencyKeysToCleanup.clear();
|
||||
clerkIdsToCleanup.clear();
|
||||
groupIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupAdjustmentsByIdempotencyKeys();
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
if (!groupIdsToCleanup.isEmpty()) {
|
||||
groupInfoService.removeByIds(groupIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithoutPermissionReturns403() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "20.00")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createLeaderWithPermissionCannotManageOtherGroupClerkReturns403() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(otherClerkId, "20.00")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSuperAdminBypassesPermissionAndScopeReturns202() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid())
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(SUPER_ADMIN_HEADER, "true")
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(otherClerkId, "20.00")))
|
||||
.andExpect(status().isAccepted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollWithoutReadPermissionReturns403() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "-10.00")))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossTenantPollReturns404EvenWithPermission() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "1.00")))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String createPayload(String clerkId, String amount) {
|
||||
return "{" +
|
||||
"\"clerkId\":\"" + clerkId + "\"," +
|
||||
"\"amount\":\"" + amount + "\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"auth-test\"" +
|
||||
"}";
|
||||
}
|
||||
|
||||
private String seedOtherGroupClerk() {
|
||||
String groupId = "group-auth-" + IdUtils.getUuid();
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId("leader-auth-" + IdUtils.getUuid());
|
||||
group.setSysUserCode("leader-auth");
|
||||
group.setGroupName("Auth Test Group");
|
||||
group.setLeaderName("Leader Auth");
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-auth-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-auth-" + clerkId);
|
||||
clerk.setNickname("Auth Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-auth-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
|
||||
return clerkId;
|
||||
}
|
||||
|
||||
private void cleanupAdjustmentsByIdempotencyKeys() {
|
||||
for (String key : idempotencyKeysToCleanup) {
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.getByIdempotencyKey(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID, key);
|
||||
if (adjustment != null) {
|
||||
cleanupAdjustment(adjustment.getId());
|
||||
}
|
||||
}
|
||||
idempotencyKeysToCleanup.clear();
|
||||
}
|
||||
|
||||
private void cleanupAdjustment(String adjustmentId) {
|
||||
if (adjustmentId == null) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.eq(EarningsLineEntity::getSourceId, adjustmentId)
|
||||
.remove();
|
||||
adjustmentService.removeById(adjustmentId);
|
||||
}
|
||||
|
||||
private void awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
if ("APPLIED".equals(root.path("data").path("status").asText())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD contract tests for earnings adjustments (reward/punishment/unified adjustment model).
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the adjustment system is implemented end-to-end.</p>
|
||||
*/
|
||||
class AdminEarningsAdjustmentControllerApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> createdAdjustmentIds = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
deleteAdjustmentsAndLines(createdAdjustmentIds);
|
||||
createdAdjustmentIds.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAdjustmentReturns202AndProvidesPollingHandle() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"20.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"manual reward for testing\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(header().string("Location", "/admin/earnings/adjustments/idempotency/" + key))
|
||||
.andExpect(jsonPath("$.code").value(202))
|
||||
.andExpect(jsonPath("$.data").exists())
|
||||
.andExpect(jsonPath("$.data.adjustmentId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.status").value("PROCESSING"))
|
||||
.andReturn();
|
||||
|
||||
createdAdjustmentIds.add(extractAdjustmentId(result));
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollByIdempotencyKeyReturnsProcessingThenApplied() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"-10.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"manual punishment for testing\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
|
||||
// Immediately polling should return a stable representation (at least PROCESSING).
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.adjustmentId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.status").value("PROCESSING"));
|
||||
|
||||
// After implementation, the system should eventually transition to APPLIED.
|
||||
// Poll with a bounded wait to keep the test deterministic.
|
||||
boolean applied = false;
|
||||
for (int i = 0; i < 40; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
applied = true;
|
||||
break;
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
assertThat(applied).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeySameBodyIsIdempotent() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"30.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"idempotent create\"" +
|
||||
"}";
|
||||
|
||||
MvcResult first = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
MvcResult second = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
JsonNode firstRoot = objectMapper.readTree(first.getResponse().getContentAsString());
|
||||
JsonNode secondRoot = objectMapper.readTree(second.getResponse().getContentAsString());
|
||||
String firstId = firstRoot.path("data").path("adjustmentId").asText();
|
||||
String secondId = secondRoot.path("data").path("adjustmentId").asText();
|
||||
assertThat(firstId).isNotBlank();
|
||||
assertThat(secondId).isEqualTo(firstId);
|
||||
createdAdjustmentIds.add(firstId);
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyDifferentBodyReturns409() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payloadA = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"30.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"first payload\"" +
|
||||
"}";
|
||||
String payloadB = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"31.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"different payload\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadA))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadB))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingIdempotencyKeyReturns400() throws Exception {
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"20.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"missing idempotency\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsZeroAmount() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"0.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"zero amount\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingReasonType() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"reasonDescription\":\"missing reason type\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankReasonDescription() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\" \"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentCreatesWithSameKeyReturnSameAdjustmentIdAndDoNotDuplicate() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"9.99\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"concurrent\"" +
|
||||
"}";
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<String> call = () -> {
|
||||
MvcResult result = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
};
|
||||
List<Future<String>> futures = new ArrayList<>();
|
||||
futures.add(pool.submit(call));
|
||||
futures.add(pool.submit(call));
|
||||
|
||||
String a = futures.get(0).get();
|
||||
String b = futures.get(1).get();
|
||||
assertThat(a).isNotBlank();
|
||||
assertThat(b).isEqualTo(a);
|
||||
createdAdjustmentIds.add(a);
|
||||
awaitApplied(key);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollMissingIdempotencyKeyReturns404() throws Exception {
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + UUID.randomUUID())
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyIsTenantScoped() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"1.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"tenant scope\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String extractAdjustmentId(MvcResult result) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private void deleteAdjustmentsAndLines(Collection<String> adjustmentIds) {
|
||||
if (adjustmentIds == null || adjustmentIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD tests: withdrawal audit must tolerate adjustment lines (orderId=null) and still return a stable payload.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until earnings lines support orderId=null + sourceType/sourceId,
|
||||
* and audit serialization handles mixed sources.</p>
|
||||
*/
|
||||
class AdminWithdrawalAuditWithAdjustmentsApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String withdrawalId;
|
||||
private String orderId;
|
||||
private String orderLineId;
|
||||
private String adjustmentLineId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
List<String> toDelete = new ArrayList<>();
|
||||
if (orderLineId != null) {
|
||||
toDelete.add(orderLineId);
|
||||
}
|
||||
if (adjustmentLineId != null) {
|
||||
toDelete.add(adjustmentLineId);
|
||||
}
|
||||
if (!toDelete.isEmpty()) {
|
||||
earningsService.removeByIds(toDelete);
|
||||
}
|
||||
if (withdrawalId != null) {
|
||||
withdrawalService.removeById(withdrawalId);
|
||||
}
|
||||
if (orderId != null) {
|
||||
orderInfoService.removeById(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditReturnsOrderDetailsForOrderLinesAndLeavesOrderFieldsEmptyForAdjustments() throws Exception {
|
||||
seedOrderWithdrawalAndLines();
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/admin/withdraw/requests/" + withdrawalId + "/audit")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andReturn();
|
||||
|
||||
JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data");
|
||||
JsonNode first = data.get(0);
|
||||
JsonNode second = data.get(1);
|
||||
|
||||
// First entry is ORDER: should have orderNo present.
|
||||
boolean firstIsOrder = "ORDER".equals(first.path("earningType").asText());
|
||||
JsonNode orderNode = firstIsOrder ? first : second;
|
||||
JsonNode adjustmentNode = firstIsOrder ? second : first;
|
||||
|
||||
assertThat(orderNode.path("earningType").asText()).isEqualTo("ORDER");
|
||||
assertThat(orderNode.path("orderNo").asText()).isNotBlank();
|
||||
|
||||
assertThat(adjustmentNode.path("earningType").asText()).isEqualTo("ADJUSTMENT");
|
||||
assertThat(adjustmentNode.path("orderId").isMissingNode() || adjustmentNode.path("orderId").isNull()).isTrue();
|
||||
assertThat(adjustmentNode.path("orderNo").isMissingNode() || adjustmentNode.path("orderNo").isNull()).isTrue();
|
||||
}
|
||||
|
||||
private void seedOrderWithdrawalAndLines() {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
|
||||
// order
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
orderId = "order-audit-adj-" + IdUtils.getUuid();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
order.setOrderNo("audit-adj-" + IdUtils.getUuid());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderEndTime(now.minusHours(1));
|
||||
order.setFinalAmount(new BigDecimal("120.00"));
|
||||
order.setEstimatedRevenue(new BigDecimal("60.00"));
|
||||
order.setDeleted(false);
|
||||
Date dt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(dt);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(dt);
|
||||
orderInfoService.save(order);
|
||||
|
||||
// withdrawal request
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-audit-adj-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("88.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:audit-adj@test.com");
|
||||
req.setStatus("processing");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"审计专用\"}");
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(dt);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(dt);
|
||||
withdrawalService.save(req);
|
||||
|
||||
// order line
|
||||
orderLineId = seedEarningLine(withdrawalId, orderId, new BigDecimal("60.00"), "withdrawn", EarningsType.ORDER, now.minusMinutes(30));
|
||||
|
||||
// adjustment line (intended future: orderId=null + sourceType/sourceId)
|
||||
adjustmentLineId = seedEarningLine(withdrawalId, null, new BigDecimal("-10.00"), "withdrawing", EarningsType.ADJUSTMENT, now.minusMinutes(10));
|
||||
}
|
||||
|
||||
private String seedEarningLine(String withdrawalId, String orderIdOrNull, BigDecimal amount, String status, EarningsType type, LocalDateTime createdAt) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-audit-adj-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId(orderIdOrNull);
|
||||
entity.setAmount(amount);
|
||||
entity.setStatus(status);
|
||||
entity.setEarningType(type);
|
||||
entity.setUnlockTime(createdAt.minusHours(1));
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
Date createdDate = Date.from(createdAt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(createdDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(createdDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
/**
|
||||
* TDD contract tests for rejecting/canceling a withdrawal request (release reserved earnings lines).
|
||||
*
|
||||
* <p>These tests are expected to FAIL until withdrawal reject is implemented.</p>
|
||||
*/
|
||||
class AdminWithdrawalRejectApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
private String withdrawalId;
|
||||
private String lineA;
|
||||
private String lineB;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
withdrawalId = null;
|
||||
lineA = null;
|
||||
lineB = null;
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (withdrawalId != null) {
|
||||
withdrawalService.removeById(withdrawalId);
|
||||
}
|
||||
List<String> toDelete = new ArrayList<>();
|
||||
if (lineA != null) {
|
||||
toDelete.add(lineA);
|
||||
}
|
||||
if (lineB != null) {
|
||||
toDelete.add(lineB);
|
||||
}
|
||||
if (!toDelete.isEmpty()) {
|
||||
earningsService.removeByIds(toDelete);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectPendingWithdrawalReleasesWithdrawingLines() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
|
||||
String payload = "{\"reason\":\"bank account mismatch\"}";
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
EarningsLineEntity afterA = earningsService.getById(lineA);
|
||||
EarningsLineEntity afterB = earningsService.getById(lineB);
|
||||
assertThat(afterA.getWithdrawalId()).isNull();
|
||||
assertThat(afterB.getWithdrawalId()).isNull();
|
||||
assertThat(afterA.getStatus()).isIn("available", "frozen");
|
||||
assertThat(afterB.getStatus()).isIn("available", "frozen");
|
||||
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(withdrawalId);
|
||||
assertThat(req.getStatus()).isIn("canceled", "rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectIsIdempotent() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
|
||||
String payload = "{\"reason\":\"duplicate\"}";
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectRestoresFrozenWhenUnlockTimeInFuture() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-reject-frozen-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("10.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
|
||||
lineA = seedLineWithUnlock("reject-future", new BigDecimal("10.00"), LocalDateTime.now().plusDays(1));
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"future unlock\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
EarningsLineEntity after = earningsService.getById(lineA);
|
||||
assertThat(after.getStatus()).isEqualTo("frozen");
|
||||
assertThat(after.getWithdrawalId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectSuccessWithdrawalIsRejected() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(withdrawalId);
|
||||
req.setStatus("success");
|
||||
withdrawalService.updateById(req);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"cannot reject success\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
private void seedWithdrawingWithdrawalAndLines() {
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-reject-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("80.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
|
||||
lineA = seedLine("reject-a", new BigDecimal("50.00"));
|
||||
lineB = seedLine("reject-b", new BigDecimal("30.00"));
|
||||
}
|
||||
|
||||
private String seedLine(String suffix, BigDecimal amount) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId("order-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusDays(1));
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
|
||||
private String seedLineWithUnlock(String suffix, BigDecimal amount, LocalDateTime unlockAt) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId("order-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(unlockAt);
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
/**
|
||||
* Authorization contract tests for rejecting withdrawals.
|
||||
*
|
||||
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
|
||||
*/
|
||||
class AdminWithdrawalRejectAuthorizationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService groupInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
private final List<String> withdrawalIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> earningLineIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> groupIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
withdrawalIdsToCleanup.clear();
|
||||
earningLineIdsToCleanup.clear();
|
||||
clerkIdsToCleanup.clear();
|
||||
groupIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!withdrawalIdsToCleanup.isEmpty()) {
|
||||
withdrawalService.removeByIds(withdrawalIdsToCleanup);
|
||||
}
|
||||
if (!earningLineIdsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningLineIdsToCleanup);
|
||||
}
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
if (!groupIdsToCleanup.isEmpty()) {
|
||||
groupInfoService.removeByIds(groupIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectWithoutPermissionReturns403() throws Exception {
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectLeaderWithPermissionCannotManageOtherGroupReturns403() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectSuperAdminBypassesPermissionAndScopeReturns200() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid())
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(SUPER_ADMIN_HEADER, "true")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossTenantRejectReturns404EvenWithPermission() throws Exception {
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String seedWithdrawingWithdrawalAndLines(String clerkId) {
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
String withdrawalId = "withdraw-auth-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(clerkId);
|
||||
req.setAmount(new BigDecimal("80.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
withdrawalIdsToCleanup.add(withdrawalId);
|
||||
|
||||
earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "a", new BigDecimal("50.00")));
|
||||
earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "b", new BigDecimal("30.00")));
|
||||
|
||||
return withdrawalId;
|
||||
}
|
||||
|
||||
private String seedLine(String withdrawalId, String clerkId, String suffix, BigDecimal amount) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-auth-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId("order-auth-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusDays(1));
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
|
||||
private String seedOtherGroupClerk() {
|
||||
String groupId = "group-auth-withdraw-" + IdUtils.getUuid();
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId("leader-auth-" + IdUtils.getUuid());
|
||||
group.setSysUserCode("leader-auth");
|
||||
group.setGroupName("Auth Test Group");
|
||||
group.setLeaderName("Leader Auth");
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-auth-withdraw-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-auth-" + clerkId);
|
||||
clerk.setNickname("Auth Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("138" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-auth-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
|
||||
return clerkId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.common.domain.LoginUser;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD contract tests for statistics toggle: include/exclude earnings adjustments.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the stats endpoint supports includeAdjustments.</p>
|
||||
*/
|
||||
class StatisticsPerformanceOverviewIncludeAdjustmentsApiTest extends AbstractApiTest {
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final LocalDateTime BASE_TIME = LocalDateTime.of(2011, 1, 1, 12, 0, 0);
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private EarningsLineMapper earningsLineMapper;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String orderId;
|
||||
private final List<String> earningLineIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
orderId = null;
|
||||
earningLineIdsToCleanup.clear();
|
||||
setAuthentication();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!earningLineIdsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningLineIdsToCleanup);
|
||||
}
|
||||
if (orderId != null) {
|
||||
orderInfoService.removeById(orderId);
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewExcludesAdjustmentsByDefaultAndIncludesWhenToggledOn() throws Exception {
|
||||
seedOneOrderAndLines();
|
||||
|
||||
LocalDateTime start = BASE_TIME.minusMinutes(5);
|
||||
LocalDateTime end = BASE_TIME.plusMinutes(5);
|
||||
|
||||
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
ApiTestDataSeeder.DEFAULT_CLERK_ID,
|
||||
start.format(DATE_TIME_FORMATTER),
|
||||
end.format(DATE_TIME_FORMATTER));
|
||||
assertThat(adjustmentSum.setScale(2, RoundingMode.HALF_UP)).isEqualByComparingTo(new BigDecimal("-20.00"));
|
||||
|
||||
String payloadDefault = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult defaultResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadDefault))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode defaultJson = objectMapper.readTree(defaultResult.getResponse().getContentAsString());
|
||||
BigDecimal defaultRevenue = new BigDecimal(defaultJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(defaultRevenue).isEqualByComparingTo(new BigDecimal("100.00"));
|
||||
|
||||
String payloadInclude = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"includeAdjustments\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult includeResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadInclude))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode includeJson = objectMapper.readTree(includeResult.getResponse().getContentAsString());
|
||||
BigDecimal includeRevenue = new BigDecimal(includeJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(includeRevenue).isEqualByComparingTo(new BigDecimal("80.00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void includeAdjustmentsRespectsTimeWindow() throws Exception {
|
||||
seedOneOrderAndLines();
|
||||
|
||||
// Seed another adjustment intended to be outside the window.
|
||||
// Intended future behavior: adjustment is filtered by its effectiveTime/createdTime within the same window.
|
||||
seedOutOfWindowAdjustmentLine();
|
||||
|
||||
LocalDateTime start = BASE_TIME.minusMinutes(5);
|
||||
LocalDateTime end = BASE_TIME.plusMinutes(5);
|
||||
|
||||
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
ApiTestDataSeeder.DEFAULT_CLERK_ID,
|
||||
start.format(DATE_TIME_FORMATTER),
|
||||
end.format(DATE_TIME_FORMATTER));
|
||||
assertThat(adjustmentSum.setScale(2, RoundingMode.HALF_UP)).isEqualByComparingTo(new BigDecimal("-20.00"));
|
||||
|
||||
String payloadInclude = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"includeAdjustments\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult includeResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadInclude))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode includeJson = objectMapper.readTree(includeResult.getResponse().getContentAsString());
|
||||
BigDecimal includeRevenue = new BigDecimal(includeJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
// Base is 100.00 order revenue, -20.00 in-window adjustment; the out-of-window adjustment should NOT be counted.
|
||||
assertThat(includeRevenue).isEqualByComparingTo(new BigDecimal("80.00"));
|
||||
}
|
||||
|
||||
private void seedOneOrderAndLines() {
|
||||
LocalDateTime now = BASE_TIME.withNano(0);
|
||||
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
orderId = "order-stats-" + IdUtils.getUuid();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
order.setOrderNo("stats-" + IdUtils.getUuid());
|
||||
order.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode());
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setPurchaserTime(now.minusMinutes(1));
|
||||
order.setPlaceType(OrderConstant.PlaceType.REWARD.getCode());
|
||||
order.setRefundType(OrderConstant.OrderRefundFlag.NOT_REFUNDED.getCode());
|
||||
order.setOrdersExpiredState(OrderConstant.OrdersExpiredState.NOT_EXPIRED.getCode());
|
||||
order.setOrderRelationType(OrderConstant.OrderRelationType.FIRST);
|
||||
order.setFinalAmount(new BigDecimal("200.00"));
|
||||
order.setEstimatedRevenue(new BigDecimal("100.00"));
|
||||
order.setOrderEndTime(now);
|
||||
order.setDeleted(false);
|
||||
Date created = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(created);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(created);
|
||||
orderInfoService.save(order);
|
||||
|
||||
seedEarningsLine("earn-order", orderId, new BigDecimal("100.00"), EarningsType.ORDER, now.minusMinutes(1));
|
||||
|
||||
// Intended future behavior: adjustment lines are not ORDER-sourced and should only affect stats when includeAdjustments=true.
|
||||
// This will FAIL until play_earnings_line supports orderId=null + sourceType/sourceId, and stats toggle is implemented.
|
||||
seedEarningsLine("earn-adjustment", null, new BigDecimal("-20.00"), EarningsType.ADJUSTMENT, now.minusMinutes(1));
|
||||
}
|
||||
|
||||
private String seedEarningsLine(
|
||||
String prefix, String orderIdOrNull, BigDecimal amount, EarningsType type, LocalDateTime unlockTime) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = prefix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId(orderIdOrNull);
|
||||
if (orderIdOrNull == null) {
|
||||
entity.setSourceType(EarningsSourceType.ADJUSTMENT);
|
||||
entity.setSourceId("adj-stats-" + IdUtils.getUuid());
|
||||
} else {
|
||||
entity.setSourceType(EarningsSourceType.ORDER);
|
||||
entity.setSourceId(orderIdOrNull);
|
||||
}
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(type);
|
||||
entity.setStatus("available");
|
||||
entity.setUnlockTime(unlockTime);
|
||||
Date created = Date.from(BASE_TIME.atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(created);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(created);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningLineIdsToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void seedOutOfWindowAdjustmentLine() {
|
||||
seedEarningsLine(
|
||||
"earn-adjustment-outside",
|
||||
null,
|
||||
new BigDecimal("999.00"),
|
||||
EarningsType.ADJUSTMENT,
|
||||
BASE_TIME.minusDays(30));
|
||||
}
|
||||
|
||||
private void setAuthentication() {
|
||||
SysUserEntity user = new SysUserEntity();
|
||||
user.setUserId(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
user.setUserCode(ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME);
|
||||
user.setPassWord("apitest");
|
||||
user.setStatus(0);
|
||||
user.setSuperAdmin(true);
|
||||
user.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUserId(user.getUserId());
|
||||
loginUser.setUserName(user.getUserCode());
|
||||
loginUser.setUser(user);
|
||||
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, java.util.Collections.emptyList()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* End-to-end Web/API tests that prove adjustments affect clerk withdraw balance and eligibility.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until adjustments are implemented.</p>
|
||||
*/
|
||||
class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@MockBean
|
||||
private IClerkPayeeProfileService clerkPayeeProfileService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String clerkToken;
|
||||
private ClerkPayeeProfileEntity payeeProfile;
|
||||
private final List<String> createdAdjustmentIds = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ensureTenantContext();
|
||||
|
||||
payeeProfile = new ClerkPayeeProfileEntity();
|
||||
payeeProfile.setId("payee-" + IdUtils.getUuid());
|
||||
payeeProfile.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
payeeProfile.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
payeeProfile.setChannel("ALIPAY_QR");
|
||||
payeeProfile.setQrCodeUrl("https://example.com/test-payee.png");
|
||||
payeeProfile.setDisplayName("API测试收款码");
|
||||
payeeProfile.setLastConfirmedAt(LocalDateTime.now());
|
||||
|
||||
Mockito.when(clerkPayeeProfileService.getByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID, ApiTestDataSeeder.DEFAULT_CLERK_ID))
|
||||
.thenAnswer(invocation -> payeeProfile);
|
||||
Mockito.when(clerkPayeeProfileService.updateById(Mockito.any(ClerkPayeeProfileEntity.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
payeeProfile = invocation.getArgument(0);
|
||||
return true;
|
||||
});
|
||||
|
||||
clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ensureTenantContext();
|
||||
Mockito.reset(clerkPayeeProfileService);
|
||||
cleanupAdjustments();
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedPositiveAdjustmentIncreasesWithdrawableBalance() throws Exception {
|
||||
ensureTenantContext();
|
||||
BigDecimal before = fetchAvailableBalance();
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"bonus\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
BigDecimal after = fetchAvailableBalance();
|
||||
BigDecimal delta = after.subtract(before).setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(delta).isEqualByComparingTo("50.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedNegativeAdjustmentCanBlockWithdrawal() throws Exception {
|
||||
ensureTenantContext();
|
||||
String key = UUID.randomUUID().toString();
|
||||
BigDecimal before = fetchAvailableBalance();
|
||||
BigDecimal amount = before.add(new BigDecimal("20.00")).negate().setScale(2, RoundingMode.HALF_UP);
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"" + amount + "\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"penalty\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
// Attempting to withdraw any positive amount should fail when net balance is negative.
|
||||
mockMvc.perform(post("/wx/withdraw/requests")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":10}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void earningsListIncludesAdjustmentLineAfterApplied() throws Exception {
|
||||
ensureTenantContext();
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"12.34\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"show in list\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
MvcResult earnings = mockMvc.perform(get("/wx/withdraw/earnings")
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "10")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(earnings.getResponse().getContentAsString());
|
||||
JsonNode rows = root.path("data");
|
||||
boolean found = false;
|
||||
if (rows.isArray()) {
|
||||
for (JsonNode row : rows) {
|
||||
if ("ADJUSTMENT".equals(row.path("earningType").asText())
|
||||
&& "12.34".equals(row.path("amount").asText())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(found).isTrue();
|
||||
}
|
||||
|
||||
private BigDecimal fetchAvailableBalance() throws Exception {
|
||||
MvcResult balance = mockMvc.perform(get("/wx/withdraw/balance")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(balance.getResponse().getContentAsString());
|
||||
return root.path("data").path("available").decimalValue().setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private void ensureTenantContext() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
private void cleanupAdjustments() {
|
||||
if (createdAdjustmentIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, createdAdjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(createdAdjustmentIds);
|
||||
createdAdjustmentIds.clear();
|
||||
|
||||
withdrawalService.lambdaUpdate()
|
||||
.eq(WithdrawalRequestEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(WithdrawalRequestEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.remove();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -295,6 +299,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
.andExpect(jsonPath("$.data[1]").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentWithdrawRequestsCompeteForSameEarningsLines() throws Exception {
|
||||
ensureTenantContext();
|
||||
String firstLine = insertEarningsLine(
|
||||
"concurrent-one",
|
||||
new BigDecimal("50.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
LocalDateTime.now().minusDays(1));
|
||||
String secondLine = insertEarningsLine(
|
||||
"concurrent-two",
|
||||
new BigDecimal("30.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
LocalDateTime.now().minusHours(2));
|
||||
earningsToCleanup.add(firstLine);
|
||||
earningsToCleanup.add(secondLine);
|
||||
|
||||
refreshPayeeConfirmation();
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<Integer> create = () -> {
|
||||
MvcResult result = mockMvc.perform(post("/wx/withdraw/requests")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":80}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("code").asInt();
|
||||
};
|
||||
|
||||
Future<Integer> a = pool.submit(create);
|
||||
Future<Integer> b = pool.submit(create);
|
||||
|
||||
int codeA = a.get();
|
||||
int codeB = b.get();
|
||||
assertThat(codeA == 200 || codeA == 500).isTrue();
|
||||
assertThat(codeB == 200 || codeB == 500).isTrue();
|
||||
assertThat(codeA + codeB).isEqualTo(700);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private String insertEarningsLine(
|
||||
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
|
||||
return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.starry.admin.db;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.starry.admin.api.AbstractApiTest;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* Database-level contract tests for the adjustment system schema.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until migrations add the new table/columns/indexes.</p>
|
||||
*/
|
||||
class EarningsAdjustmentsDatabaseSchemaApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
void earningsLineHasSourceColumnsAndOrderIdIsNullable() {
|
||||
assertThat(columnExists("play_earnings_line", "source_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line", "source_id")).isTrue();
|
||||
|
||||
Map<String, Object> orderIdMeta = jdbcTemplate.queryForMap(
|
||||
"select is_nullable as nullable " +
|
||||
"from information_schema.columns " +
|
||||
"where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
|
||||
"play_earnings_line",
|
||||
"order_id");
|
||||
String nullable = String.valueOf(orderIdMeta.get("nullable"));
|
||||
assertThat(nullable).isIn("YES", "yes", "Y", "y", "1", "TRUE", "true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentTableExistsAndHasIdempotencyFields() {
|
||||
assertThat(tableExists("play_earnings_line_adjustment")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "idempotency_key")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "request_hash")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "status")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "reason_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "reason_description")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "effective_time")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueIndexExistsForTenantIdempotencyKey() {
|
||||
// Lock the index name so future migrations are deterministic.
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) " +
|
||||
"from information_schema.statistics " +
|
||||
"where lower(table_name)=lower(?) and lower(index_name)=lower(?)",
|
||||
Integer.class,
|
||||
"play_earnings_line_adjustment",
|
||||
"uk_tenant_idempotency");
|
||||
assertThat(count).isNotNull();
|
||||
assertThat(count).isGreaterThan(0);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private boolean columnExists(String table, String column) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.columns where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
|
||||
Integer.class,
|
||||
table,
|
||||
column);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.starry.admin.modules.withdraw.contract;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import java.lang.reflect.Field;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit-level contract tests that lock the intended public schema/API surface.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the adjustment system is implemented.</p>
|
||||
*/
|
||||
class EarningsAdjustmentSchemaContractTest {
|
||||
|
||||
@Test
|
||||
void earningsLineEntityExposesSourceTypeAndSourceId() throws Exception {
|
||||
assertHasField(EarningsLineEntity.class, "sourceType");
|
||||
assertHasField(EarningsLineEntity.class, "sourceId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentEntityAndEnumExist() {
|
||||
assertDoesNotThrow(() -> {
|
||||
Class<?> entity = Class.forName("com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity");
|
||||
assertHasField(entity, "id");
|
||||
assertHasField(entity, "tenantId");
|
||||
assertHasField(entity, "clerkId");
|
||||
assertHasField(entity, "amount");
|
||||
assertHasField(entity, "reasonType");
|
||||
assertHasField(entity, "reasonDescription");
|
||||
assertHasField(entity, "idempotencyKey");
|
||||
assertHasField(entity, "requestHash");
|
||||
assertHasField(entity, "status");
|
||||
assertHasField(entity, "effectiveTime");
|
||||
assertHasField(entity, "appliedTime");
|
||||
});
|
||||
assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType"));
|
||||
assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus"));
|
||||
}
|
||||
|
||||
private void assertHasField(Class<?> clazz, String fieldName) throws Exception {
|
||||
Field field = clazz.getDeclaredField(fieldName);
|
||||
assertThat(field).isNotNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user