feat(education): resolve student tenant context

This commit is contained in:
2026-07-27 17:32:36 +08:00
parent 11e9cc6854
commit 0f846fdaf5
16 changed files with 856 additions and 7 deletions

View File

@@ -7,6 +7,8 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -47,4 +49,12 @@ public class EducationPropertiesTest {
assertEquals("1.0.0", defaults.getVersion(), "默认版本应为 1.0.0");
}
@Test
public void testHostnameTenantMapDefaults() {
EducationProperties defaults = new EducationProperties();
assertNotNull(defaults.getHostnameTenantMap(), "hostnameTenantMap 默认不应为 null");
assertTrue(defaults.getHostnameTenantMap().isEmpty(), "hostnameTenantMap 默认应为空");
assertEquals(List.of("PASSWORD", "SMS"), defaults.getLoginMethods(), "默认登录方式应与 Member 入口一致");
}
}

View File

@@ -0,0 +1,87 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link EducationTenantController} 的单元测试
*
* @author 恭学教育
*/
class EducationTenantControllerTest {
// ========== normalizeHostname ==========
@ParameterizedTest
@NullAndEmptySource
void normalizeHostname_shouldReturnNullForBlankInput(String hostname) {
assertNull(EducationTenantController.normalizeHostname(hostname));
}
@Test
void normalizeHostname_shouldLowercase() {
assertEquals("school.example.com",
EducationTenantController.normalizeHostname("School.Example.COM"));
}
@Test
void normalizeHostname_shouldTrimWhitespace() {
assertEquals("school.example.com",
EducationTenantController.normalizeHostname(" school.example.com "));
}
@Test
void normalizeHostname_shouldKeepPort() {
assertEquals("school.example.com:8080",
EducationTenantController.normalizeHostname("school.example.com:8080"));
}
@Test
void normalizeHostname_shouldLowercaseAndKeepPort() {
assertEquals("school.example.com:8080",
EducationTenantController.normalizeHostname("School.Example.COM:8080"));
}
@Test
void normalizeHostname_shouldRejectProtocol() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("http://school.example.com"));
}
@Test
void normalizeHostname_shouldRejectPath() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("school.example.com/path"));
}
@Test
void normalizeHostname_shouldRejectProtocolWithPort() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("https://school.example.com:8443"));
}
@Test
void normalizeHostname_shouldRejectOversizedPort() {
assertThrows(RuntimeException.class,
() -> EducationTenantController.normalizeHostname("school.example.com:999999999999"));
}
@Test
void normalizeHostname_shouldKeepIPv6Colons() {
// A bare IPv6 address like ::1: the port-stripping should be safe
// because last colon is followed by digits -> treated as port
// We'll just check no exception thrown
assertDoesNotThrow(() -> EducationTenantController.normalizeHostname("[::1]"));
}
@Test
void normalizeHostname_shouldRejectNonNumericPort() {
assertThrows(RuntimeException.class,
() -> EducationTenantController.normalizeHostname("host:name"));
}
}

View File

@@ -0,0 +1,197 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.app.tenant.vo.EducationTenantRespVO;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDateTime;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* {@link EducationTenantController} 的集成测试 — 测试租户解析流程
*
* @author 恭学教育
*/
@SpringBootTest(classes = EducationTenantResolveIntegrationTest.Config.class,
properties = {
"yudao.education.enabled=true",
"yudao.education.version=1.0.0-test"
},
webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("unit-test")
class EducationTenantResolveIntegrationTest {
@Configuration
@EnableConfigurationProperties(EducationProperties.class)
@Import(EducationTenantController.class)
static class Config {
}
@Resource
private EducationTenantController controller;
@MockitoBean
private TenantCommonApi tenantCommonApi;
// ========== happy path ==========
@Test
void resolve_byTenantName_shouldReturnActiveTenant() {
TenantRespDTO dto = buildTenant(100L, "demo-school", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByName("demo-school")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve(null, "demo-school");
assertTrue(result.isSuccess());
EducationTenantRespVO data = result.getData();
assertEquals(100L, data.getTenantId());
assertEquals("demo-school", data.getTenantName());
assertEquals("ACTIVE", data.getStatus());
assertTrue(data.getLoginMethods().contains("PASSWORD"));
assertTrue(data.getLoginMethods().contains("SMS"));
}
@Test
void resolve_byHostname_shouldFindByWebsite() {
TenantRespDTO dto = buildTenant(200L, "school-alpha", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByWebsite("school.example.com")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve("school.example.com", null);
assertTrue(result.isSuccess());
assertEquals(200L, result.getData().getTenantId());
assertEquals("school-alpha", result.getData().getTenantName());
}
@Test
void resolve_byHostname_shouldNormalizeCaseAndKeepPort() {
TenantRespDTO dto = buildTenant(300L, "school-beta", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByWebsite("school.example.com:8443")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve("School.Example.COM:8443", null);
assertTrue(result.isSuccess());
assertEquals(300L, result.getData().getTenantId());
}
@Test
void resolve_conflictingHostnameAndTenantName_shouldFail() {
TenantRespDTO byName = buildTenant(600L, "school-a", CommonStatusEnum.ENABLE.getStatus());
TenantRespDTO byHost = buildTenant(601L, "school-b", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByName("school-a")).thenReturn(byName);
when(tenantCommonApi.getTenantByWebsite("school-b.example.com")).thenReturn(byHost);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> controller.resolve("school-b.example.com", "school-a"));
assertTrue(exception.getMessage().contains("指向不同租户"));
}
// ========== error cases ==========
@Test
void resolve_noInput_shouldFail() {
try {
controller.resolve(null, null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("不能同时为空"));
}
}
@Test
void resolve_blankInput_shouldFail() {
try {
controller.resolve(" ", "");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("不能同时为空"));
}
}
@Test
void resolve_notFound_shouldFail() {
when(tenantCommonApi.getTenantByName("nonexistent")).thenReturn(null);
when(tenantCommonApi.getTenantByWebsite(anyString())).thenReturn(null);
try {
controller.resolve(null, "nonexistent");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("租户不存在"));
}
}
@Test
void resolve_disabled_shouldFail() {
TenantRespDTO dto = buildTenant(400L, "disabled-school", CommonStatusEnum.DISABLE.getStatus());
when(tenantCommonApi.getTenantByName("disabled-school")).thenReturn(dto);
try {
controller.resolve(null, "disabled-school");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("已被禁用"));
}
}
@Test
void resolve_expired_shouldFail() {
TenantRespDTO dto = buildTenant(500L, "expired-school", CommonStatusEnum.ENABLE.getStatus());
dto.setExpireTime(LocalDateTime.now().minusDays(1));
when(tenantCommonApi.getTenantByName("expired-school")).thenReturn(dto);
try {
controller.resolve(null, "expired-school");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("当前租户不可用"));
}
}
@Test
void resolve_protocolInHostname_shouldFail() {
try {
controller.resolve("http://evil.com", null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("协议"));
}
}
@Test
void resolve_pathInHostname_shouldFail() {
try {
controller.resolve("school.com/admin", null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("路径"));
}
}
// ========== helpers ==========
private static TenantRespDTO buildTenant(Long id, String name, Integer status) {
TenantRespDTO dto = new TenantRespDTO();
dto.setId(id);
dto.setName(name);
dto.setStatus(status);
dto.setWebsites(Collections.singletonList(name + ".example.com"));
dto.setExpireTime(LocalDateTime.now().plusYears(1));
return dto;
}
}

View File

@@ -1,10 +1,15 @@
package cn.iocoder.yudao.module.education.framework.web.config;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.admin.EducationCapabilityController;
import cn.iocoder.yudao.module.education.controller.app.EducationContextController;
import cn.iocoder.yudao.module.education.controller.app.tenant.EducationTenantController;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* {@link EducationWebConfiguration} 的单元测试
@@ -14,12 +19,19 @@ import static org.assertj.core.api.Assertions.assertThat;
class EducationWebConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(EducationWebConfiguration.class, EducationCapabilityController.class);
.withUserConfiguration(EducationWebConfiguration.class,
EducationCapabilityController.class,
EducationTenantController.class,
EducationContextController.class)
.withBean(TenantCommonApi.class, () -> mock(TenantCommonApi.class))
.withBean(EducationProperties.class, EducationProperties::new);
@Test
void shouldNotRegisterEducationWebBeansByDefault() {
contextRunner.run(context -> {
assertThat(context).doesNotHaveBean(EducationCapabilityController.class);
assertThat(context).doesNotHaveBean(EducationTenantController.class);
assertThat(context).doesNotHaveBean(EducationContextController.class);
assertThat(context).doesNotHaveBean("educationGroupedOpenApi");
});
}
@@ -29,6 +41,8 @@ class EducationWebConfigurationTest {
contextRunner.withPropertyValues("yudao.education.enabled=true")
.run(context -> {
assertThat(context).hasSingleBean(EducationCapabilityController.class);
assertThat(context).hasSingleBean(EducationTenantController.class);
assertThat(context).hasSingleBean(EducationContextController.class);
assertThat(context).hasBean("educationGroupedOpenApi");
});
}