feat(ft546): add isolated shadow process

This commit is contained in:
zgw
2026-08-12 14:21:32 +08:00
parent 778444e5ea
commit 364668c939
14 changed files with 668 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
package com.ruoyi.shadow;
import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.shadow.web.ShadowStatusController;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class Ft546ShadowApplicationTest {
private final WebApplicationContextRunner contextRunner =
new WebApplicationContextRunner()
.withInitializer(
new ConfigDataApplicationContextInitializer())
.withUserConfiguration(Ft546ShadowApplication.class);
@Test
void explicitReadOnlyShadowConfigurationStartsTheNarrowContext() {
configured(contextRunner).run(context -> {
assertFalse(context.getStartupFailure() != null);
assertEquals("127.0.0.1",
context.getEnvironment().getProperty("server.address"));
assertEquals("18091",
context.getEnvironment().getProperty("server.port"));
assertEquals(ShadowStartupReport.Status.STARTING,
context.getBean(ShadowStartupReport.class).getStatus());
assertNotNull(context.getBean(ShadowStartupReport.class)
.getCheckedAt());
assertNull(context.getBean(ShadowStartupReport.class)
.getLastCompletedCycle());
assertNull(context.getBean(ShadowStartupReport.class)
.getSourceLagMillis());
assertEquals(0L, context.getBean(ShadowStartupReport.class)
.getEvidenceBacklog());
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("SHADOW_ENABLED"));
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("SIDE_EFFECT_BEANS_ABSENT"));
assertEquals("PENDING", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("SOURCE_PERMISSIONS_VERIFIED"));
assertEquals("PENDING", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED"));
assertNotNull(context.getBean(DispatchCoordinator.class));
assertNotNull(context.getBean(ShadowStatusController.class));
assertTrue(context.getBeansOfType(
DeviceCommandGateway.class).isEmpty());
assertTrue(context.getBeansOfType(
WmsNotificationGateway.class).isEmpty());
});
}
@Test
void statusEndpointReturnsTheReadOnlyStartupState() {
configured(contextRunner).run(context -> {
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
mvc.perform(get("/shadow/status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("STARTING"))
.andExpect(jsonPath("$.checkedAt").exists())
.andExpect(jsonPath("$.evidenceBacklog").value(0))
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")
.value("PASS"))
.andExpect(jsonPath(
"$.startupChecks.SIDE_EFFECT_BEANS_ABSENT")
.value("PASS"));
});
}
@Test
void startupFailsClosedUnlessEnabledProfileAndBothJdbcUrlsAreExplicit() {
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
"SHADOW_MUST_BE_EXPLICITLY_ENABLED");
assertStartupFailure(contextRunner.withPropertyValues(
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
"SHADOW_PROFILE_MUST_BE_ACTIVE");
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"),
"SHADOW_SOURCE_JDBC_URL_REQUIRED");
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source"),
"SHADOW_EVIDENCE_JDBC_URL_REQUIRED");
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence",
"server.address=0.0.0.0"),
"SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK");
}
@Test
void startupRejectsAnyRealCommandGatewayBean() {
assertStartupFailure(configured(contextRunner)
.withUserConfiguration(
ForbiddenGatewayConfiguration.class),
"DEVICE_COMMAND_GATEWAY_FORBIDDEN_IN_SHADOW");
}
@Test
void startupRejectsAnyRealWmsGatewayBean() {
assertStartupFailure(configured(contextRunner)
.withUserConfiguration(
ForbiddenWmsGatewayConfiguration.class),
"WMS_NOTIFICATION_GATEWAY_FORBIDDEN_IN_SHADOW");
}
@Test
void startupRejectsAForbiddenFactoryBeanWithUnknownProductType() {
assertStartupFailure(configured(contextRunner)
.withUserConfiguration(
ForbiddenFactoryBeanConfiguration.class),
"FORBIDDEN_PRODUCTION_BEAN_IN_SHADOW:"
+ ForbiddenFactoryBean.class.getName());
}
private static WebApplicationContextRunner configured(
WebApplicationContextRunner runner) {
return runner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence");
}
private static void assertStartupFailure(
WebApplicationContextRunner runner, String reasonCode) {
runner.run(context -> {
Throwable failure = context.getStartupFailure();
assertNotNull(failure);
assertTrue(messages(failure).contains(reasonCode),
messages(failure));
});
}
private static String messages(Throwable failure) {
StringBuilder messages = new StringBuilder();
Throwable current = failure;
while (current != null) {
if (current.getMessage() != null) {
messages.append(current.getMessage()).append('\n');
}
current = current.getCause();
}
return messages.toString();
}
@Configuration(proxyBeanMethods = false)
static class ForbiddenGatewayConfiguration {
@Bean
DeviceCommandGateway forbiddenDeviceCommandGateway() {
return envelope -> {
throw new AssertionError("must never send in shadow test");
};
}
}
@Configuration(proxyBeanMethods = false)
static class ForbiddenWmsGatewayConfiguration {
@Bean
WmsNotificationGateway forbiddenWmsNotificationGateway() {
return notification -> {
throw new AssertionError("must never notify in shadow test");
};
}
}
@Configuration(proxyBeanMethods = false)
static class ForbiddenFactoryBeanConfiguration {
@Bean
ForbiddenFactoryBean forbiddenFactoryBean() {
return new ForbiddenFactoryBean();
}
}
}