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). * *

These tests are expected to FAIL until the batch deduction system is implemented end-to-end.

*/ 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 ordersToCleanup = new ArrayList<>(); private final List earningsToCleanup = new ArrayList<>(); private final List batchIdsToCleanup = new ArrayList<>(); private final List 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 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> 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 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 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; } }