This commit is contained in:
2026-08-04 09:05:28 +08:00
parent fdb1e8af1c
commit 3afcff320a
116 changed files with 6749 additions and 749 deletions
@@ -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());
}
}