Files
peipei-backend/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchControllerApiTest.java

914 lines
43 KiB
Java

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.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
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.enums.EarningsSourceType;
import com.starry.admin.modules.withdraw.enums.EarningsType;
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.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MvcResult;
/**
* End-to-end contract tests for admin batch deductions (bonus/punishment across clerks).
*
* <p>These tests are expected to FAIL until the batch deduction system is implemented end-to-end.</p>
*/
class AdminEarningsDeductionBatchControllerApiTest 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:deduction:create,withdraw:deduction:read";
private static final String BASE_URL = "/admin/earnings/deductions";
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private IPlayOrderInfoService orderInfoService;
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
@Autowired
private IEarningsService earningsService;
@Autowired
private IEarningsAdjustmentService adjustmentService;
private final List<String> ordersToCleanup = new ArrayList<>();
private final List<String> earningsToCleanup = new ArrayList<>();
private final List<String> batchIdsToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = new ArrayList<>();
@BeforeEach
void setUp() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
ordersToCleanup.clear();
earningsToCleanup.clear();
batchIdsToCleanup.clear();
}
@AfterEach
void tearDown() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
cleanupBatches(batchIdsToCleanup);
if (!earningsToCleanup.isEmpty()) {
earningsService.removeByIds(earningsToCleanup);
}
if (!ordersToCleanup.isEmpty()) {
orderInfoService.removeByIds(ordersToCleanup);
}
if (!clerkIdsToCleanup.isEmpty()) {
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
}
}
@Test
void previewRequiresRequiredFieldsReturns400() throws Exception {
mockMvc.perform(post(BASE_URL + "/preview")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
void previewPercentageUsesOnlyOrderLinesPositiveAmountsAndOrderEndTimeWindow() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String clerkId = ensureTestClerkInDefaultTenant();
// In window: order1 + order2
String order1 = seedOrder(clerkId, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(clerkId, order1, new BigDecimal("100.00"), "withdrawn", EarningsType.ORDER);
seedOrderEarningLine(clerkId, order1, new BigDecimal("-30.00"), "available", EarningsType.ADJUSTMENT);
String order2 = seedOrder(clerkId, now.minusDays(2), new BigDecimal("50.00"));
seedOrderEarningLine(clerkId, order2, new BigDecimal("50.00"), "available", EarningsType.ORDER);
// Out of window: order3 should not contribute
String order3 = seedOrder(clerkId, now.minusDays(30), new BigDecimal("999.00"));
seedOrderEarningLine(clerkId, order3, new BigDecimal("999.00"), "withdrawn", EarningsType.ORDER);
// Adjustment line (order_id=null) should not contribute even if positive
seedAdjustmentEarningLine(clerkId, new BigDecimal("1000.00"), "available");
String payload = "{" +
"\"clerkIds\":[\"" + clerkId + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"PERCENTAGE\"," +
"\"percentage\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"week bonus\"" +
"}";
MvcResult result = mockMvc.perform(post(BASE_URL + "/preview")
.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().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.items").isArray())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode item = root.path("data").path("items").get(0);
assertThat(item.path("clerkId").asText()).isEqualTo(clerkId);
BigDecimal baseAmount = new BigDecimal(item.path("baseAmount").asText("0"));
BigDecimal applyAmount = new BigDecimal(item.path("applyAmount").asText("0"));
assertThat(baseAmount).isEqualByComparingTo("150.00"); // 100 + 50 (negative & non-order excluded)
assertThat(applyAmount).isEqualByComparingTo("15.00"); // 10% bonus
}
@Test
void previewWindowIsInclusiveOnBoundaries() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.minusDays(1);
String clerkId = ensureTestClerkInDefaultTenant();
String orderBegin = seedOrder(clerkId, begin, new BigDecimal("20.00"));
seedOrderEarningLine(clerkId, orderBegin, new BigDecimal("20.00"), "withdrawn", EarningsType.ORDER);
String orderEnd = seedOrder(clerkId, end, new BigDecimal("30.00"));
seedOrderEarningLine(clerkId, orderEnd, new BigDecimal("30.00"), "withdrawn", EarningsType.ORDER);
String payload = "{" +
"\"clerkIds\":[\"" + clerkId + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"PERCENTAGE\"," +
"\"percentage\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"boundary\"" +
"}";
MvcResult result = mockMvc.perform(post(BASE_URL + "/preview")
.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().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode item = root.path("data").path("items").get(0);
BigDecimal baseAmount = new BigDecimal(item.path("baseAmount").asText("0"));
assertThat(baseAmount).isEqualByComparingTo("50.00"); // 20 + 30 (both boundary-included)
}
@Test
void previewRejectsCrossTenantClerkScopeReturns403() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "withdrawn", EarningsType.ORDER);
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"PERCENTAGE\"," +
"\"percentage\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"tenant isolation\"" +
"}";
mockMvc.perform(post(BASE_URL + "/preview")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isForbidden());
}
@Test
void createReturns202AndProvidesPollingHandle() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"PERCENTAGE\"," +
"\"percentage\":\"10.00\"," +
"\"operation\":\"PUNISHMENT\"," +
"\"reasonDescription\":\"week penalty\"" +
"}";
MvcResult result = mockMvc.perform(post(BASE_URL)
.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", BASE_URL + "/idempotency/" + key))
.andExpect(jsonPath("$.code").value(202))
.andExpect(jsonPath("$.data.batchId").isNotEmpty())
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
.andExpect(jsonPath("$.data.status").value("PROCESSING"))
.andReturn();
String batchId = extractBatchId(result);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
}
@Test
void createIsIdempotentWithSameKeyAndSameBody() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"50.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"fixed bonus\"" +
"}";
MvcResult first = mockMvc.perform(post(BASE_URL)
.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();
String batchA = extractBatchId(first);
MvcResult second = mockMvc.perform(post(BASE_URL)
.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();
String batchB = extractBatchId(second);
assertThat(batchB).isEqualTo(batchA);
batchIdsToCleanup.add(batchA);
awaitApplied(key);
}
@Test
void createConcurrentRequestsSameKeyOnlyOneBatchCreated() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"concurrent\"" +
"}";
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<String> call = () -> {
MvcResult result = mockMvc.perform(post(BASE_URL)
.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();
return extractBatchId(result);
};
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);
batchIdsToCleanup.add(a);
awaitApplied(key);
} finally {
pool.shutdownNow();
}
}
@Test
void createSameKeyDifferentBodyReturns409() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payloadA = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"50.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"fixed bonus\"" +
"}";
String payloadB = payloadA.replace("\"50.00\"", "\"60.00\"");
MvcResult first = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(first);
batchIdsToCleanup.add(batchId);
mockMvc.perform(post(BASE_URL)
.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 pollMissingIdempotencyKeyReturns404() throws Exception {
mockMvc.perform(get(BASE_URL + "/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 {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"PUNISHMENT\"," +
"\"reasonDescription\":\"tenant scope\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
mockMvc.perform(get(BASE_URL + "/idempotency/" + key)
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andExpect(status().isNotFound());
}
@Test
void itemsAfterAppliedHaveAdjustmentIdAndNoDuplicateEarningsLines() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"50.00\"," +
"\"operation\":\"PUNISHMENT\"," +
"\"reasonDescription\":\"fixed penalty\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
MvcResult items = mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.param("pageNum", "1")
.param("pageSize", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].adjustmentId").isNotEmpty())
.andReturn();
JsonNode root = objectMapper.readTree(items.getResponse().getContentAsString());
JsonNode first = root.path("data").get(0);
String adjustmentId = first.path("adjustmentId").asText();
assertThat(adjustmentId).isNotBlank();
long count = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
.eq(EarningsLineEntity::getSourceId, adjustmentId)
.count();
assertThat(count).isEqualTo(1);
}
@Test
void itemsPaginationWorks() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String secondClerkId = ensureTestClerkInDefaultTenant();
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String order2 = seedOrder(secondClerkId, now.minusDays(1), new BigDecimal("80.00"));
seedOrderEarningLine(secondClerkId, order2, new BigDecimal("80.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\",\"" + secondClerkId + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"pagination\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.param("pageNum", "1")
.param("pageSize", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.total").value(2));
}
@Test
void logsContainCreatedAndFinalEvents() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"audit log\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
.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").isArray())
.andExpect(jsonPath("$.data[?(@.eventType=='CREATED')]").exists())
.andExpect(jsonPath("$.data[?(@.eventType=='APPLY_STARTED')]").exists())
.andExpect(jsonPath("$.data[?(@.eventType=='BATCH_APPLIED')]").exists());
}
@Test
void itemsAndLogsAreTenantScopedToBatchTenant() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"tenant scope items\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.param("pageNum", "1")
.param("pageSize", "10"))
.andExpect(status().isNotFound());
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andExpect(status().isNotFound());
}
@Test
void logsRecordOperatorInCreatedBy() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"operator audit\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
MvcResult logs = mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
.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(logs.getResponse().getContentAsString());
JsonNode data = root.path("data");
boolean hasOperator = false;
for (JsonNode node : data) {
String createdBy = node.path("createdBy").asText();
if (ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID.equals(createdBy)) {
hasOperator = true;
break;
}
}
assertThat(hasOperator).isTrue();
}
private String extractBatchId(MvcResult result) throws Exception {
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
return root.path("data").path("batchId").asText();
}
private String awaitApplied(String idempotencyKey) throws Exception {
for (int i = 0; i < 120; i++) {
MvcResult poll = mockMvc.perform(get(BASE_URL + "/idempotency/" + idempotencyKey)
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andReturn();
if (poll.getResponse().getStatus() == 200) {
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
String status = root.path("data").path("status").asText();
if ("APPLIED".equals(status)) {
return root.path("data").path("batchId").asText();
}
if ("FAILED".equals(status)) {
throw new AssertionError("batch failed unexpectedly: key=" + idempotencyKey);
}
}
Thread.sleep(50);
}
throw new AssertionError("batch not applied within timeout: key=" + idempotencyKey);
}
private String seedOrder(String clerkId, LocalDateTime endTime, BigDecimal estimatedRevenue) {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
String id = "order-deduct-" + IdUtils.getUuid();
order.setId(id);
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
order.setOrderNo("DED-" + System.currentTimeMillis());
order.setOrderStatus("3");
order.setOrderType("2");
order.setPlaceType("0");
order.setRewardType("0");
order.setAcceptBy(clerkId);
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
order.setOrderMoney(new BigDecimal("120.50"));
order.setFinalAmount(order.getOrderMoney());
order.setEstimatedRevenue(estimatedRevenue);
order.setOrderSettlementState("1");
order.setOrderEndTime(endTime);
order.setOrderSettlementTime(endTime);
Date nowDate = toDate(LocalDateTime.now());
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setCreatedTime(nowDate);
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setUpdatedTime(nowDate);
order.setDeleted(false);
orderInfoService.save(order);
ordersToCleanup.add(id);
return id;
}
private void seedOrderEarningLine(String clerkId, String orderId, BigDecimal amount, String status, EarningsType earningType) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-deduct-" + IdUtils.getUuid();
entity.setId(id);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(clerkId);
entity.setOrderId(orderId);
entity.setSourceType(EarningsSourceType.ORDER);
entity.setSourceId(orderId);
entity.setAmount(amount);
entity.setEarningType(earningType);
entity.setStatus(status);
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
entity.setDeleted(false);
earningsService.save(entity);
earningsToCleanup.add(id);
}
private void seedAdjustmentEarningLine(String clerkId, BigDecimal amount, String status) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-deduct-adj-" + IdUtils.getUuid();
entity.setId(id);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(clerkId);
entity.setOrderId(null);
entity.setSourceType(EarningsSourceType.ADJUSTMENT);
entity.setSourceId("adj-seed-" + IdUtils.getUuid());
entity.setAmount(amount);
entity.setEarningType(EarningsType.ADJUSTMENT);
entity.setStatus(status);
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
entity.setDeleted(false);
earningsService.save(entity);
earningsToCleanup.add(id);
}
private Date toDate(LocalDateTime time) {
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
}
private void cleanupBatches(List<String> batchIds) {
if (batchIds == null || batchIds.isEmpty()) {
return;
}
if (!tableExists("play_earnings_deduction_batch")) {
return;
}
for (String batchId : batchIds) {
cleanupBatch(batchId);
}
batchIds.clear();
}
private void cleanupBatch(String batchId) {
if (batchId == null || batchId.isEmpty()) {
return;
}
List<String> adjustmentIds = new ArrayList<>();
if (tableExists("play_earnings_deduction_item")) {
adjustmentIds = jdbcTemplate.queryForList(
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
String.class,
batchId);
}
if (!adjustmentIds.isEmpty()) {
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
.in(EarningsLineEntity::getSourceId, adjustmentIds)
.remove();
adjustmentService.removeByIds(adjustmentIds);
}
if (tableExists("play_earnings_deduction_batch_log")) {
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
}
if (tableExists("play_earnings_deduction_item")) {
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
}
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
}
private boolean tableExists(String table) {
try {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
Integer.class,
table);
return count != null && count > 0;
} catch (Exception ignored) {
return false;
}
}
private String ensureTestClerkInDefaultTenant() {
String clerkId = "clerk-deduct-" + IdUtils.getUuid();
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
clerk.setId(clerkId);
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
clerk.setOpenid("openid-" + clerkId);
clerk.setNickname("Batch Clerk");
clerk.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
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-" + 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-" + IdUtils.getUuid());
clerkUserInfoService.save(clerk);
clerkIdsToCleanup.add(clerkId);
return clerkId;
}
}