优化
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportProviderClientTest {
|
||||
@Test void routesTheRequestToTheAllowlistedServiceSelectedByTheDefinition() {
|
||||
IamExportProviderClient iam=mock(IamExportProviderClient.class);
|
||||
OrderExportProviderClient order=mock(OrderExportProviderClient.class);
|
||||
WarehouseExportProviderClient warehouse=mock(WarehouseExportProviderClient.class);
|
||||
FirstlegExportProviderClient firstleg=mock(FirstlegExportProviderClient.class);
|
||||
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
|
||||
when(order.batch(anyString(),anyString(),any())).thenReturn(response);
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("order-service");
|
||||
ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500);
|
||||
|
||||
var actual=new ExportProviderClient(iam,order,warehouse,firstleg,new SimpleMeterRegistry())
|
||||
.batch("token","ORDER_LIST",definition,request);
|
||||
|
||||
assertSame(response,actual);
|
||||
verify(order).batch("token","ORDER_LIST",request);
|
||||
verifyNoInteractions(iam,warehouse,firstleg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import feign.Request;
|
||||
import feign.Response;
|
||||
import feign.codec.Decoder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ExportProviderFeignConfigurationTest {
|
||||
@Test void rejectsAnOversizedProviderResponseBeforeJsonDeserialization() {
|
||||
ExportProviderFeignConfiguration configuration=new ExportProviderFeignConfiguration();
|
||||
Decoder decoder=configuration.exportProviderDecoder(HttpMessageConverters::new);
|
||||
byte[] body=new byte[ExportProviderFeignConfiguration.MAX_RESPONSE_BYTES+1];
|
||||
Response response=Response.builder().status(200).reason("OK")
|
||||
.request(Request.create(Request.HttpMethod.POST,"http://iam-service/internal/exports/v1/CUSTOMER_LIST/batches",Map.of(),null, StandardCharsets.UTF_8,null))
|
||||
.body(body).build();
|
||||
|
||||
assertThrows(ExportProviderResponseTooLargeException.class,() -> decoder.decode(response,Object.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ExportDefinitionRegistryTest {
|
||||
@Test void acceptsTheControlledCustomerProvider() {
|
||||
ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches")));
|
||||
assertDoesNotThrow(registry::validate);
|
||||
}
|
||||
@Test void rejectsSsrfAndPathTypeMismatch() {
|
||||
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
||||
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches"))));
|
||||
}
|
||||
@Test void rejectedRefreshKeepsTheLastValidDefinitions() {
|
||||
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
||||
var valid=definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches");
|
||||
registry.setDefinitions(Map.of("CUSTOMER_LIST", valid));
|
||||
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/CUSTOMER_LIST/batches"))));
|
||||
assertDoesNotThrow(() -> registry.require("CUSTOMER_LIST"));
|
||||
org.junit.jupiter.api.Assertions.assertSame(valid, registry.require("CUSTOMER_LIST"));
|
||||
}
|
||||
private ExportDefinitionRegistry.Definition definition(String service,String path){
|
||||
var d=new ExportDefinitionRegistry.Definition();d.setProviderService(service);d.setProviderPath(path);d.setRequiredPermission("platform:customer:export");d.setAppScope("PLATFORM");d.setMaxRows(200000);d.setMaxAttempts(3);d.setBatchSize(500);return d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ExportInfrastructureContractTest {
|
||||
@Test void deploymentPinsConcurrencyFourAndStartsAllSchedulersDisabled() throws Exception {
|
||||
String compose=read("..","deploy","docker-compose.yml");
|
||||
assertTrue(compose.contains("FILE_EXPORT_CONSUMER_CONCURRENCY: ${FILE_EXPORT_CONSUMER_CONCURRENCY:-4}"));
|
||||
assertTrue(compose.contains("FILE_EXPORT_CONSUMER_ENABLED: ${FILE_EXPORT_CONSUMER_ENABLED:-false}"));
|
||||
assertTrue(compose.contains("FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false}"));
|
||||
assertTrue(compose.contains("FILE_EXPORT_RECONCILER_ENABLED: ${FILE_EXPORT_RECONCILER_ENABLED:-false}"));
|
||||
assertTrue(compose.contains("-Xms1g -Xmx2g"));
|
||||
assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?"));
|
||||
assertTrue(!compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-}"));
|
||||
}
|
||||
|
||||
@Test void rocketMqBootstrapCreatesBothGroupsAndAReadableWorkerDlq() throws Exception {
|
||||
String compose=read("..","deploy","docker-compose.rocketmq.yml");
|
||||
assertTrue(compose.contains("baiyue-file-export-command-v1\" -r 4 -w 4"));
|
||||
assertTrue(compose.contains("baiyue-file-export-worker-v1\""));
|
||||
assertTrue(compose.contains("baiyue-file-export-dlq-handler-v1\""));
|
||||
assertTrue(compose.contains("%DLQ%$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1\" -r 1 -w 1 -p 6"));
|
||||
assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?"));
|
||||
assertTrue(compose.contains("sh -eu -c 'until sh mqadmin clusterList"));
|
||||
assertTrue(compose.contains("| grep -q \"BaiyueCluster\""));
|
||||
assertTrue(compose.contains("restart: on-failure"));
|
||||
String environment=read("..","deploy","env",".env.infra-test.example");
|
||||
assertTrue(environment.contains("ROCKETMQ_BROKER_IP=rocketmq-broker"));
|
||||
assertTrue(environment.contains("MQ_TOPIC_PREFIX=test-"));
|
||||
assertTrue(read("..","deploy","rocketmq","broker.conf.template").contains("autoCreateSubscriptionGroup=false"));
|
||||
}
|
||||
|
||||
@Test void brokerRendererRejectsUnreachableAndUnexpandedAddresses() throws Exception {
|
||||
String script=read("..","deploy","rocketmq","render-broker-config.sh");
|
||||
assertTrue(script.contains("localhost|127.0.0.1"));
|
||||
assertTrue(script.contains("*'${'*"));
|
||||
assertTrue(script.contains("broker.conf still contains an unresolved placeholder"));
|
||||
}
|
||||
|
||||
@Test void databaseBaselineContainsTaskOutboxAndPreseededCreationGuard() throws Exception {
|
||||
String sql=read("src","main","resources","db","file.sql");
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_task ("));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_outbox ("));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_task_guard ("));
|
||||
assertTrue(sql.contains("VALUES ('GLOBAL_CREATE')"));
|
||||
}
|
||||
|
||||
private String read(String first,String... more) throws Exception {
|
||||
return Files.readString(Path.of(first,more), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,44 @@ class QualificationFileContractTest {
|
||||
assertTrue(requestCaptor.getValue().replace());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantUploadRejectsMissingOrUnsupportedTypesBeforeWritingTheFile() {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var iamClient = mock(IamQualificationClient.class);
|
||||
var controller = merchantController(storage, iamClient);
|
||||
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.upload(merchantUser(), file, null, null, null, false));
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.upload(merchantUser(), file, "UNKNOWN", null, null, false));
|
||||
|
||||
verify(storage, never()).store(any(Long.class), any());
|
||||
verify(iamClient, never()).submit(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRequiredQualificationTypeIsAcceptedAndForwarded() {
|
||||
for (String qualificationType : Set.of(
|
||||
"BUSINESS_LICENSE", "LEGAL_PERSON_ID",
|
||||
"FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var iamClient = mock(IamQualificationClient.class);
|
||||
var controller = merchantController(storage, iamClient);
|
||||
var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
when(storage.store(10001L, file)).thenReturn(storedFile());
|
||||
when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", null)));
|
||||
|
||||
controller.upload(merchantUser(), file, qualificationType, null, null, false);
|
||||
|
||||
var requestCaptor = org.mockito.ArgumentCaptor.forClass(
|
||||
IamQualificationClient.QualificationRequest.class);
|
||||
verify(iamClient).submit(eq("internal-token"), requestCaptor.capture());
|
||||
assertEquals(qualificationType, requestCaptor.getValue().qualificationType());
|
||||
}
|
||||
}
|
||||
|
||||
private static PlatformQualificationFileController platformController(
|
||||
LocalQualificationStorage storage, IamQualificationClient iamClient) {
|
||||
var controller = new PlatformQualificationFileController();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.file.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ExportMapperContractTest {
|
||||
@Test void finalSuccessCommitIsGuardedByVersionLeaseAndDatabaseDeadline() throws Exception {
|
||||
String sql=update(FileExportTaskMapper.class,"succeed");
|
||||
assertTrue(sql.contains("dispatch_version=#{dispatchVersion}"));
|
||||
assertTrue(sql.contains("lease_owner=#{owner}"));
|
||||
assertTrue(sql.contains("execution_deadline_at>CURRENT_TIMESTAMP(3)"));
|
||||
}
|
||||
|
||||
@Test void sentRedispatchKeepsTheSameVersionAndExcludesQuarantinedRows() throws Exception {
|
||||
String sql=update(FileExportOutboxMapper.class,"redispatchSent");
|
||||
assertTrue(sql.contains("o.status='SENT'"));
|
||||
assertTrue(sql.contains("t.dispatch_version=o.dispatch_version"));
|
||||
assertFalse(sql.contains("dispatch_version=dispatch_version+1"));
|
||||
assertFalse(sql.contains("QUARANTINED"));
|
||||
}
|
||||
|
||||
@Test void missingOutboxDetectionUsesDatabaseTimeAndLetsAnyExistingStatusBlockDuplication() throws Exception {
|
||||
String sql=select(FileExportTaskMapper.class,"missingOutbox");
|
||||
assertTrue(sql.contains("CURRENT_TIMESTAMP(3)"));
|
||||
assertTrue(sql.contains("NOT EXISTS"));
|
||||
assertTrue(sql.contains("o.dispatch_version=file_export_task.dispatch_version"));
|
||||
assertFalse(sql.contains("o.status"));
|
||||
}
|
||||
|
||||
@Test void retentionCleanupCanOnlyDeleteOldSentRows() throws Exception {
|
||||
Method target=java.util.Arrays.stream(FileExportOutboxMapper.class.getDeclaredMethods()).filter(candidate -> candidate.getName().equals("deleteRetainedSent")).findFirst().orElseThrow();
|
||||
String sql=String.join(" ",target.getAnnotation(Delete.class).value()).replaceAll("\\s+"," ");
|
||||
assertTrue(sql.contains("status='SENT'"));
|
||||
assertTrue(sql.contains("CURRENT_TIMESTAMP(3)"));
|
||||
assertFalse(sql.contains("PENDING"));
|
||||
assertFalse(sql.contains("CLAIMED"));
|
||||
}
|
||||
|
||||
private String update(Class<?> type,String method) throws Exception {
|
||||
Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow();
|
||||
return String.join(" ",target.getAnnotation(Update.class).value()).replaceAll("\\s+"," ");
|
||||
}
|
||||
private String select(Class<?> type,String method) throws Exception {
|
||||
Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow();
|
||||
return String.join(" ",target.getAnnotation(Select.class).value()).replaceAll("\\s+"," ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.file.service.ExportTaskExecutor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
|
||||
import org.apache.rocketmq.common.message.MessageExt;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class ExportCommandConsumerTest {
|
||||
@Test void appliesConfiguredConcurrencyAndDispatchesAValidCommand() {
|
||||
ExportTaskExecutor executor=mock(ExportTaskExecutor.class);
|
||||
ExportCommandConsumer consumer=new ExportCommandConsumer(new ObjectMapper(),executor,"file-1",4,90,2);
|
||||
DefaultMQPushConsumer pushConsumer=new DefaultMQPushConsumer();
|
||||
consumer.prepareStart(pushConsumer);
|
||||
assertEquals(4,pushConsumer.getConsumeThreadMin());
|
||||
assertEquals(4,pushConsumer.getConsumeThreadMax());
|
||||
assertEquals(90,pushConsumer.getConsumeTimeout());
|
||||
assertEquals(2,pushConsumer.getMaxReconsumeTimes());
|
||||
|
||||
MessageExt message=new MessageExt();
|
||||
message.setBody("{\"schemaVersion\":1,\"eventId\":\"event-1\",\"taskId\":\"9\",\"exportType\":\"CUSTOMER_LIST\",\"dispatchVersion\":3}".getBytes(StandardCharsets.UTF_8));
|
||||
consumer.onMessage(message);
|
||||
verify(executor).execute(9L,3,"file-1");
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedMessagesSoRocketMqCanRedeliverThem() {
|
||||
ExportCommandConsumer consumer=new ExportCommandConsumer(new ObjectMapper(),mock(ExportTaskExecutor.class),"file-1",4,90,2);
|
||||
MessageExt message=new MessageExt();message.setBody("not-json".getBytes(StandardCharsets.UTF_8));
|
||||
assertThrows(IllegalArgumentException.class,() -> consumer.onMessage(message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.file.service.ExportStateService;
|
||||
import org.apache.rocketmq.common.message.MessageExt;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportDlqHandlerTest {
|
||||
@Test void databaseFailuresCannotFallIntoAnUnconsumedSecondaryDlq() {
|
||||
RocketMQMessageListener listener=ExportDlqHandler.class.getAnnotation(RocketMQMessageListener.class);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(Integer.MAX_VALUE,listener.maxReconsumeTimes());
|
||||
}
|
||||
@Test void quarantinesUsingUserPropertiesWithoutParsingTheBody() {
|
||||
ExportStateService states=mock(ExportStateService.class); MessageExt message=new MessageExt();
|
||||
SimpleMeterRegistry meters=new SimpleMeterRegistry();
|
||||
message.putUserProperty("eventId","event-1");message.putUserProperty("taskId","9");message.putUserProperty("dispatchVersion","3");message.putUserProperty("schemaVersion","1");
|
||||
new ExportDlqHandler(states,meters).onMessage(message);
|
||||
verify(states).quarantine(eq("event-1"),eq(9L),eq(3),contains("自动隔离"));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1,meters.counter("export_dlq_messages").count());
|
||||
}
|
||||
@Test void malformedPropertiesAreAcknowledgedWithoutTouchingTasks() {
|
||||
ExportStateService states=mock(ExportStateService.class); SimpleMeterRegistry meters=new SimpleMeterRegistry();
|
||||
new ExportDlqHandler(states,meters).onMessage(new MessageExt());
|
||||
verifyNoInteractions(states); org.junit.jupiter.api.Assertions.assertEquals(1,meters.counter("export_dlq_messages").count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.file.entity.FileExportOutbox;
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.messaging.Message;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportOutboxPublisherTest {
|
||||
@Test void publishesAllDlqLocatorFieldsAsRocketMqUserProperties() {
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
RocketMQTemplate rocket=mock(RocketMQTemplate.class);
|
||||
FileExportOutbox row=row();
|
||||
when(outbox.publishableIds(50)).thenReturn(List.of(1L));when(outbox.claim(1L,"file-1",60)).thenReturn(true);when(outbox.find(1L)).thenReturn(row);
|
||||
|
||||
new ExportOutboxPublisher(outbox,rocket,"file-1",50,60).publish();
|
||||
|
||||
@SuppressWarnings("rawtypes") ArgumentCaptor<Message> message=ArgumentCaptor.forClass(Message.class);
|
||||
verify(rocket).syncSend(eq("test-export:EXPORT_TASK"),message.capture(),eq(3000L));
|
||||
assertEquals("event-1",message.getValue().getHeaders().get("eventId"));
|
||||
assertEquals("9",message.getValue().getHeaders().get("taskId"));
|
||||
assertEquals("3",message.getValue().getHeaders().get("dispatchVersion"));
|
||||
assertEquals("1",message.getValue().getHeaders().get("schemaVersion"));
|
||||
verify(outbox).sent(1L,"file-1");
|
||||
}
|
||||
|
||||
@Test void returnsFailedSendsToPendingWithTheFirstBackoffInsteadOfGivingUp() {
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
RocketMQTemplate rocket=mock(RocketMQTemplate.class);
|
||||
FileExportOutbox row=row();row.setAttemptCount(1);
|
||||
when(outbox.publishableIds(50)).thenReturn(List.of(1L));when(outbox.claim(1L,"file-1",60)).thenReturn(true);when(outbox.find(1L)).thenReturn(row);
|
||||
doThrow(new RuntimeException("broker down")).when(rocket).syncSend(anyString(),any(Message.class),anyLong());
|
||||
|
||||
new ExportOutboxPublisher(outbox,rocket,"file-1",50,60).publish();
|
||||
|
||||
verify(outbox).sendFailed(1L,"file-1",5,"broker down");
|
||||
verify(outbox,never()).sent(anyLong(),anyString());
|
||||
}
|
||||
|
||||
@Test void stillRetriesAfterThirtyConsecutiveBrokerFailuresWithoutADeadState() {
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
RocketMQTemplate rocket=mock(RocketMQTemplate.class);
|
||||
FileExportOutbox row=row();row.setAttemptCount(30);
|
||||
when(outbox.publishableIds(50)).thenReturn(List.of(1L));
|
||||
when(outbox.claim(1L,"file-1",60)).thenReturn(true);
|
||||
when(outbox.find(1L)).thenReturn(row);
|
||||
doThrow(new RuntimeException("broker down")).when(rocket).syncSend(anyString(),any(Message.class),anyLong());
|
||||
ExportOutboxPublisher publisher=new ExportOutboxPublisher(outbox,rocket,"file-1",50,60);
|
||||
|
||||
for(int i=0;i<30;i++) publisher.publish();
|
||||
|
||||
verify(outbox,times(30)).sendFailed(1L,"file-1",900,"broker down");
|
||||
verify(outbox,never()).sent(anyLong(),anyString());
|
||||
}
|
||||
|
||||
private FileExportOutbox row(){
|
||||
FileExportOutbox row=new FileExportOutbox();row.setId(1L);row.setEventId("event-1");row.setTaskId(9L);row.setDispatchVersion(3);
|
||||
row.setTopic("test-export");row.setTag("EXPORT_TASK");row.setMessageKey("9");row.setPayloadJson("{}");row.setAttemptCount(1);return row;
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.file.client.IamExportClient;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CustomerExportTaskHandlerTest {
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Test
|
||||
void generatesARealXlsxFromIamBatchesAndReportsProgress() throws Exception {
|
||||
IamExportClient client = mock(IamExportClient.class);
|
||||
LocalExportStorage storage = new LocalExportStorage(tempDir.toString());
|
||||
storage.initialize();
|
||||
var row = new IamExportClient.CustomerExportRow(
|
||||
"101", "TEN101", "柏悦物流", "柏悦物流有限公司", "91440300MA5ABCDE12",
|
||||
"有限责任公司", null, null, "CNY", "深圳市", "张三", "13800138000",
|
||||
"ops@example.com", "baiyue", "李四", "基础版", 30, "ACTIVE", null, null);
|
||||
when(client.customers("internal-token", null, 500, "柏悦", "ACTIVE"))
|
||||
.thenReturn(ApiResponse.success(new IamExportClient.CustomerExportBatch(List.of(row), 101L, false)));
|
||||
CustomerExportTaskHandler handler = new CustomerExportTaskHandler(
|
||||
client, storage, new ObjectMapper().findAndRegisterModules(), "internal-token");
|
||||
FileExportTask task = new FileExportTask();
|
||||
task.setId(9L);
|
||||
task.setParametersJson("{\"keyword\":\"柏悦\",\"status\":\"ACTIVE\"}");
|
||||
AtomicLong progress = new AtomicLong();
|
||||
|
||||
var result = handler.export(task, progress::set);
|
||||
|
||||
assertEquals(1L, result.rows());
|
||||
assertEquals(1L, progress.get());
|
||||
assertTrue(result.fileName().endsWith(".xlsx"));
|
||||
Path workbookPath = tempDir.resolve(result.objectKey());
|
||||
assertTrue(Files.isRegularFile(workbookPath));
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(Files.newInputStream(workbookPath))) {
|
||||
assertEquals("客户ID", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("柏悦物流", workbook.getSheetAt(0).getRow(1).getCell(2).getStringCellValue());
|
||||
assertEquals("13800138000", workbook.getSheetAt(0).getRow(1).getCell(11).getStringCellValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportReconcilerTest {
|
||||
@Test void performsEveryRecoveryResponsibilityWithoutExecutingBusinessExports() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
ExportStorage storage=mock(ExportStorage.class);
|
||||
FileExportTask missing=task(1L,"PENDING"), stale=task(2L,"RUNNING"), expired=task(3L,"SUCCEEDED");expired.setObjectKey("exports/3/file.xlsx");
|
||||
when(outbox.releaseExpiredClaims()).thenReturn(2);when(outbox.redispatchSent(600,60)).thenReturn(3);
|
||||
when(tasks.missingOutbox(60)).thenReturn(List.of(missing));when(states.ensureOutbox(missing)).thenReturn(true);
|
||||
when(tasks.staleRunning()).thenReturn(List.of(stale));when(states.recoverStale(stale)).thenReturn(true);
|
||||
when(tasks.expiredArtifactsDb()).thenReturn(List.of(expired));when(storage.delete(expired.getObjectKey())).thenReturn(true);
|
||||
when(storage.cleanupOrphans(eq(Duration.ofHours(24)),any())).thenReturn(1);
|
||||
ExportReconciler reconciler=new ExportReconciler(tasks,outbox,states,storage,new SimpleMeterRegistry(),60,600,7);
|
||||
|
||||
reconciler.reconcile();
|
||||
|
||||
verify(outbox).releaseExpiredClaims();verify(outbox).redispatchSent(600,60);
|
||||
verify(states).ensureOutbox(missing);verify(states).recoverStale(stale);verify(tasks).markExpired(3L);
|
||||
verify(storage).cleanupOrphans(eq(Duration.ofHours(24)),any());
|
||||
}
|
||||
|
||||
@Test void deletesOnlyTheConfiguredDailyBatchOfRetainedSentOutboxRows() {
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
ExportReconciler reconciler=new ExportReconciler(mock(ExportTaskRepository.class),outbox,mock(ExportStateService.class),mock(ExportStorage.class),new SimpleMeterRegistry(),60,600,7);
|
||||
reconciler.cleanupRetainedOutbox();
|
||||
verify(outbox).deleteRetainedSent(7,5000);
|
||||
}
|
||||
|
||||
private FileExportTask task(long id,String status){FileExportTask task=new FileExportTask();task.setId(id);task.setStatus(status);return task;}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportStateServiceTest {
|
||||
@Test void creationLimitsAndDuplicateDetectionRunUnderTheDatabaseGuard() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
ExportCommandFactory commands=mock(ExportCommandFactory.class);
|
||||
FileExportTask task=task(9L,1,"PENDING");
|
||||
task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setExportType("CUSTOMER_LIST");task.setParametersJson("{}");
|
||||
when(commands.create(task,1)).thenReturn(mock(com.baiyue.file.entity.FileExportOutbox.class));
|
||||
when(outbox.insert(any())).thenReturn(true);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,commands,mock(ExportMetrics.class));
|
||||
|
||||
ExportStateService.CreationResult result=service.createConstrained(task);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertTrue(result.created());
|
||||
InOrder order=inOrder(tasks,outbox);
|
||||
order.verify(tasks).lockCreationGuard();
|
||||
order.verify(tasks).recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}");
|
||||
order.verify(tasks).activeForUser(0L,"PLATFORM",7L);
|
||||
order.verify(tasks).activeForType("CUSTOMER_LIST");
|
||||
order.verify(tasks).insert(task);
|
||||
order.verify(tasks).initializeDispatchTime(9L);
|
||||
order.verify(outbox).insert(any());
|
||||
}
|
||||
|
||||
@Test void duplicateCreationReturnsTheExistingTaskWithoutWritingAnotherOutbox() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
FileExportTask request=task(9L,1,"PENDING"), existing=task(8L,1,"PENDING");
|
||||
request.setTenantId(0L);request.setAppCode("PLATFORM");request.setRequestedBy(7L);request.setExportType("CUSTOMER_LIST");request.setParametersJson("{}");
|
||||
when(tasks.recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}")).thenReturn(existing);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
ExportStateService.CreationResult result=service.createConstrained(request);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertFalse(result.created());
|
||||
org.junit.jupiter.api.Assertions.assertSame(existing,result.task());
|
||||
verify(tasks).lockCreationGuard();
|
||||
verify(tasks,never()).insert(any());
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
@Test void activeTaskLimitsAreEnforcedAfterTakingTheCreationGuard() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
FileExportTask request=task(9L,1,"PENDING");
|
||||
request.setTenantId(0L);request.setAppCode("PLATFORM");request.setRequestedBy(7L);request.setExportType("CUSTOMER_LIST");request.setParametersJson("{}");
|
||||
when(tasks.activeForUser(0L,"PLATFORM",7L)).thenReturn(3L);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
assertThrows(com.baiyue.common.core.exception.BizException.class,() -> service.createConstrained(request));
|
||||
|
||||
InOrder order=inOrder(tasks);
|
||||
order.verify(tasks).lockCreationGuard();
|
||||
order.verify(tasks).recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}");
|
||||
order.verify(tasks).activeForUser(0L,"PLATFORM",7L);
|
||||
verify(tasks,never()).insert(any());
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
@Test void quarantineRequiresOutboxAndTaskToChangeAtomically() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
when(tasks.find(9L)).thenReturn(task(9L,3,"PENDING"));
|
||||
when(outbox.quarantine("event-1",9L,3,"broken")).thenReturn(true);
|
||||
when(tasks.failQuarantined(9L,3,"broken")).thenReturn(false);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
assertThrows(IllegalStateException.class,() -> service.quarantine("event-1",9L,3,"broken"));
|
||||
verify(tasks).failQuarantined(9L,3,"broken");
|
||||
}
|
||||
|
||||
@Test void oldDlqMessageThatMatchesNoOutboxDoesNotTouchTheCurrentTask() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
when(tasks.find(9L)).thenReturn(task(9L,3,"PENDING"));
|
||||
when(outbox.quarantine(anyString(),anyLong(),anyInt(),anyString())).thenReturn(false);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
assertDoesNotThrow(() -> service.quarantine("old-event",9L,2,"broken"));
|
||||
verify(tasks).find(9L);
|
||||
verifyNoMoreInteractions(tasks);
|
||||
}
|
||||
|
||||
@Test void matchingDlqMessageDoesNotDamageAnAlreadyRunningTask() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
when(tasks.find(9L)).thenReturn(task(9L,3,"RUNNING"));
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
assertDoesNotThrow(() -> service.quarantine("event-1",9L,3,"broken"));
|
||||
verifyNoInteractions(outbox);
|
||||
verify(tasks,never()).failQuarantined(anyLong(),anyInt(),anyString());
|
||||
}
|
||||
|
||||
private FileExportTask task(long id,int version,String status){
|
||||
FileExportTask task=new FileExportTask();task.setId(id);task.setDispatchVersion(version);task.setStatus(status);return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportTaskExecutorTest {
|
||||
@Test void aCommandThatCannotClaimTheTaskIsAcknowledgedWithoutExecutingOrChangingState() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class);
|
||||
GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(false);
|
||||
ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,mock(ExportStorage.class),new SimpleMeterRegistry(),300,60,30,7);
|
||||
|
||||
executor.execute(9L,1,"file-1");
|
||||
|
||||
verifyNoInteractions(definitions,runner,states);
|
||||
executor.shutdownWatchdogs();
|
||||
}
|
||||
|
||||
@Test void watchdogRenewsTheLeaseWhileTheRunnerIsStillWriting() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class);
|
||||
GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
FileExportTask running=task(1,"file-1");
|
||||
when(tasks.claim(9L,1,"file-1",5,30)).thenReturn(true);when(tasks.find(9L)).thenReturn(running);
|
||||
when(tasks.heartbeat(9L,1,"file-1",5)).thenReturn(true);when(tasks.succeed(anyLong(),anyInt(),anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong(),anyInt())).thenReturn(true);
|
||||
when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition());
|
||||
when(runner.run(eq(running),any(),any(AtomicBoolean.class),any())).thenAnswer(invocation -> {
|
||||
Thread.sleep(1200);
|
||||
return new GenericXlsxExportRunner.Result("file.xlsx","exports/9/test.xlsx","application/xlsx",100,1);
|
||||
});
|
||||
ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,mock(ExportStorage.class),new SimpleMeterRegistry(),5,1,30,7);
|
||||
|
||||
executor.execute(9L,1,"file-1");
|
||||
|
||||
verify(tasks,atLeastOnce()).heartbeat(9L,1,"file-1",5);
|
||||
executor.shutdownWatchdogs();
|
||||
}
|
||||
|
||||
@Test void aLateFailureFromAnOldExecutionCannotRetryOrFailANewerVersionOwnedByTheSameInstance() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class);
|
||||
GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
ExportStorage storage=mock(ExportStorage.class);
|
||||
when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(true);
|
||||
FileExportTask first=task(1,"file-1"), newer=task(2,"file-1");
|
||||
when(tasks.find(9L)).thenReturn(first,newer);
|
||||
when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition());
|
||||
when(runner.run(eq(first),any(),any(),any())).thenThrow(new RuntimeException("late failure"));
|
||||
ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,storage,new SimpleMeterRegistry(),300,60,30,7);
|
||||
|
||||
executor.execute(9L,1,"file-1");
|
||||
|
||||
verifyNoInteractions(states);
|
||||
executor.shutdownWatchdogs();
|
||||
}
|
||||
|
||||
@Test void deletesTheGeneratedFileWhenTheFinalDatabaseCommitThrows() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class);
|
||||
GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
ExportStorage storage=mock(ExportStorage.class);
|
||||
FileExportTask running=task(1,"file-1");
|
||||
when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(true);
|
||||
when(tasks.find(9L)).thenReturn(running);
|
||||
when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition());
|
||||
when(runner.run(eq(running),any(),any(),any())).thenReturn(
|
||||
new GenericXlsxExportRunner.Result("file.xlsx","exports/9/result.xlsx","application/xlsx",100,1));
|
||||
when(tasks.succeed(anyLong(),anyInt(),anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong(),anyInt()))
|
||||
.thenThrow(new RuntimeException("database unavailable"));
|
||||
when(states.retryAfterFailure(eq(running),eq("file-1"),intThat(value -> value >= 48 && value <= 72),anyString())).thenReturn(true);
|
||||
ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,storage,new SimpleMeterRegistry(),300,60,30,7);
|
||||
|
||||
executor.execute(9L,1,"file-1");
|
||||
|
||||
verify(storage).delete("exports/9/result.xlsx");
|
||||
verify(states).retryAfterFailure(eq(running),eq("file-1"),intThat(value -> value >= 48 && value <= 72),eq("database unavailable"));
|
||||
executor.shutdownWatchdogs();
|
||||
}
|
||||
|
||||
@Test void retryBackoffAlwaysStaysWithinTheDocumentedTwentyPercentJitter() {
|
||||
for (int i=0;i<100;i++) {
|
||||
int first=ExportTaskExecutor.jitteredBackoffSeconds(60);
|
||||
int second=ExportTaskExecutor.jitteredBackoffSeconds(900);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(first>=48 && first<=72);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(second>=720 && second<=1080);
|
||||
}
|
||||
}
|
||||
|
||||
private FileExportTask task(int version,String owner){
|
||||
FileExportTask task=new FileExportTask();task.setId(9L);task.setExportType("CUSTOMER_LIST");task.setStatus("RUNNING");
|
||||
task.setDispatchVersion(version);task.setLeaseOwner(owner);task.setAttemptCount(1);task.setMaxAttempts(3);return task;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -25,19 +26,18 @@ class ExportTaskServiceTest {
|
||||
@Test
|
||||
void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() {
|
||||
ExportTaskRepository repository = mock(ExportTaskRepository.class);
|
||||
ExportTaskHandler handler = mock(ExportTaskHandler.class);
|
||||
when(handler.exportType()).thenReturn("CUSTOMER_LIST");
|
||||
when(handler.requiredPermission()).thenReturn("platform:customer:export");
|
||||
ExportTaskService service = new ExportTaskService(repository, mock(LocalExportStorage.class),
|
||||
new ObjectMapper(), List.of(handler));
|
||||
doAnswer(invocation -> null).when(repository).insert(any());
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
|
||||
new ExportStateService.CreationResult(invocation.getArgument(0), true));
|
||||
ExportTaskService service = new ExportTaskService(repository, mock(ExportStorage.class),
|
||||
new ObjectMapper(), registry(), states, mock(ExportMetrics.class));
|
||||
|
||||
var response = service.create(user(1L, Set.of("platform:customer:export")),
|
||||
new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of("status", "ACTIVE")));
|
||||
|
||||
assertEquals("PENDING", response.status());
|
||||
assertEquals("CUSTOMER_LIST", response.exportType());
|
||||
verify(repository).insert(any(FileExportTask.class));
|
||||
verify(states).createConstrained(any(FileExportTask.class));
|
||||
BizException denied = assertThrows(BizException.class, () -> service.create(user(1L, Set.of()),
|
||||
new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of())));
|
||||
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
|
||||
@@ -46,10 +46,8 @@ class ExportTaskServiceTest {
|
||||
@Test
|
||||
void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() {
|
||||
ExportTaskRepository repository = mock(ExportTaskRepository.class);
|
||||
ExportTaskHandler handler = mock(ExportTaskHandler.class);
|
||||
when(handler.exportType()).thenReturn("CUSTOMER_LIST");
|
||||
ExportTaskService service = new ExportTaskService(repository, mock(LocalExportStorage.class),
|
||||
new ObjectMapper(), List.of(handler));
|
||||
ExportTaskService service = new ExportTaskService(repository, mock(ExportStorage.class),
|
||||
new ObjectMapper(), registry(), mock(ExportStateService.class), mock(ExportMetrics.class));
|
||||
FileExportTask task = new FileExportTask();
|
||||
task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L);
|
||||
when(repository.find(9L)).thenReturn(task);
|
||||
@@ -59,7 +57,22 @@ class ExportTaskServiceTest {
|
||||
assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsParametersDeeperThanTwoLevels() {
|
||||
ExportTaskService service = new ExportTaskService(mock(ExportTaskRepository.class), mock(ExportStorage.class),
|
||||
new ObjectMapper(), registry(), mock(ExportStateService.class), mock(ExportMetrics.class));
|
||||
var request = new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表",
|
||||
Map.of("level1", Map.of("level2", List.of(Map.of("level3", "value")))));
|
||||
|
||||
BizException error=assertThrows(BizException.class,() -> service.create(user(1L,Set.of("platform:customer:export")),request));
|
||||
assertEquals(ResultCode.VALIDATION_FAILED,error.getResultCode());
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(long id, Set<String> permissions) {
|
||||
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
|
||||
}
|
||||
private ExportDefinitionRegistry registry(){
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("iam-service");definition.setProviderPath("/internal/exports/v1/CUSTOMER_LIST/batches");definition.setRequiredPermission("platform:customer:export");definition.setAppScope("PLATFORM");definition.setMaxRows(200000);definition.setMaxAttempts(3);definition.setBatchSize(500);
|
||||
ExportDefinitionRegistry registry=mock(ExportDefinitionRegistry.class);when(registry.require("CUSTOMER_LIST")).thenReturn(definition);return registry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ExportTaskWorkerTest {
|
||||
@Test
|
||||
void onlyExecutesATaskAfterTheAtomicClaimSucceeds() {
|
||||
ExportTaskRepository repository = mock(ExportTaskRepository.class);
|
||||
ExportTaskService service = mock(ExportTaskService.class);
|
||||
LocalExportStorage storage = mock(LocalExportStorage.class);
|
||||
ExportTaskHandler handler = mock(ExportTaskHandler.class);
|
||||
FileExportTask task = new FileExportTask();
|
||||
task.setId(9L); task.setExportType("CUSTOMER_LIST");
|
||||
when(repository.firstPendingId()).thenReturn(9L);
|
||||
when(repository.claim(eq(9L), any(LocalDateTime.class))).thenReturn(true);
|
||||
when(repository.find(9L)).thenReturn(task);
|
||||
when(service.handler("CUSTOMER_LIST")).thenReturn(handler);
|
||||
when(handler.export(eq(task), any())).thenAnswer(invocation -> {
|
||||
ExportTaskHandler.ProgressReporter progress = invocation.getArgument(1);
|
||||
progress.update(12L);
|
||||
return new ExportTaskHandler.ExportResult("客户列表.xlsx", "exports/9/file.xlsx",
|
||||
CustomerExportTaskHandler.CONTENT_TYPE, 1024L, 12L);
|
||||
});
|
||||
when(repository.succeed(eq(9L), eq("客户列表.xlsx"), eq("exports/9/file.xlsx"),
|
||||
eq(CustomerExportTaskHandler.CONTENT_TYPE), eq(1024L), eq(12L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
ExportTaskWorker worker = new ExportTaskWorker(repository, service, storage, 7, 30);
|
||||
|
||||
worker.processNext();
|
||||
|
||||
verify(repository).progress(9L, 12L);
|
||||
verify(repository).succeed(eq(9L), eq("客户列表.xlsx"), eq("exports/9/file.xlsx"),
|
||||
eq(CustomerExportTaskHandler.CONTENT_TYPE), eq(1024L), eq(12L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsAContendedTaskThatAnotherWorkerClaimed() {
|
||||
ExportTaskRepository repository = mock(ExportTaskRepository.class);
|
||||
when(repository.firstPendingId()).thenReturn(9L);
|
||||
when(repository.claim(eq(9L), any(LocalDateTime.class))).thenReturn(false);
|
||||
ExportTaskWorker worker = new ExportTaskWorker(repository, mock(ExportTaskService.class),
|
||||
mock(LocalExportStorage.class), 7, 30);
|
||||
|
||||
worker.processNext();
|
||||
|
||||
verify(repository, never()).find(9L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.*;
|
||||
import com.baiyue.file.client.ExportProviderClient;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class GenericXlsxExportRunnerTest {
|
||||
@TempDir Path temp;
|
||||
@Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception {
|
||||
ExportProviderClient provider=mock(ExportProviderClient.class); LocalExportStorage storage=new LocalExportStorage(temp.toString());storage.initialize();
|
||||
List<ExportColumn> columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20));
|
||||
List<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",true,"date","2026-08-03","time","2026-08-03T21:00:00")).toList();
|
||||
var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L);
|
||||
var second=new ExportBatchResponse(1,"sha256:test","snapshot-9",null,List.of(Map.of("text","\n=SUM(1,2)","integer",13,"decimal",4.5,"flag",false,"date","2026-08-04","time","2026-08-04T21:00:00")),null,false,101L);
|
||||
when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second));
|
||||
FileExportTask task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);task.setExportType("CUSTOMER_LIST");task.setTaskName("客户列表");task.setParametersJson("{}");
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(500);
|
||||
var result=new GenericXlsxExportRunner(provider,storage,new ObjectMapper(),"internal-token",50).run(task,definition,new AtomicBoolean(),ignored->{ });
|
||||
assertEquals(101,result.rows());
|
||||
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){
|
||||
var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue());
|
||||
assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.BOOLEAN,row.getCell(3).getCellType());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType());
|
||||
assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue());
|
||||
assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建");
|
||||
}
|
||||
ArgumentCaptor<ExportBatchRequest> requests=ArgumentCaptor.forClass(ExportBatchRequest.class);verify(provider,times(2)).batch(anyString(),eq("CUSTOMER_LIST"),eq(definition),requests.capture());
|
||||
assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken());
|
||||
}
|
||||
|
||||
@Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception {
|
||||
ExportProviderClient provider=mock(ExportProviderClient.class);
|
||||
LocalExportStorage storage=spy(new LocalExportStorage(temp.toString()));storage.initialize();
|
||||
var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9",
|
||||
List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)),
|
||||
List.of(Map.of("text","row")),null,false,1L);
|
||||
when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(batch));
|
||||
FileExportTask task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);task.setExportType("CUSTOMER_LIST");task.setTaskName("客户列表");task.setParametersJson("{}");
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(500);
|
||||
doThrow(new RuntimeException("size unavailable")).when(storage).size(anyString());
|
||||
|
||||
assertThrows(ExportFailure.class,() -> new GenericXlsxExportRunner(provider,storage,new ObjectMapper(),"internal-token",50)
|
||||
.run(task,definition,new AtomicBoolean(),ignored->{ }));
|
||||
|
||||
try(var paths=Files.walk(temp.resolve("exports"))) {
|
||||
assertEquals(0,paths.filter(Files::isRegularFile).count());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,19 @@ class LocalQualificationStorageTest {
|
||||
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() {
|
||||
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||
storage.initialize();
|
||||
var upload = new MockMultipartFile("file", "oversized.pdf", "application/pdf",
|
||||
new byte[5 * 1024 * 1024 + 1]);
|
||||
|
||||
BizException exception = assertThrows(BizException.class,
|
||||
() -> storage.store(10001L, upload));
|
||||
|
||||
assertTrue(exception.getMessage().contains("5 MB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletesTheReplacedQualificationByItsValidatedDocumentUrl() {
|
||||
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class SizeLimitedOutputStreamTest {
|
||||
@Test void rejectsTheWriteBeforeAnyBytesBeyondTheLimitReachStorage() throws Exception {
|
||||
ByteArrayOutputStream target=new ByteArrayOutputStream();
|
||||
SizeLimitedOutputStream output=new SizeLimitedOutputStream(target,4);
|
||||
output.write(new byte[]{1,2,3,4});
|
||||
|
||||
assertThrows(SizeLimitedOutputStream.ExportSizeExceededException.class,() -> output.write(5));
|
||||
assertArrayEquals(new byte[]{1,2,3,4},target.toByteArray());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user