diff --git a/openspec/config.yaml b/openspec/config.yaml index 3e4642c..af3ede9 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -6,6 +6,8 @@ context: | - 涉及模块结构、API、实体等变更时同步更新README.md - Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明 - 禁止创建git操作task + - 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行 + - 优先使用提问工具对用户进行提问 rules: proposal: diff --git a/spring-boot-service-template-common/pom.xml b/spring-boot-service-template-common/pom.xml index a7ea47a..8d734e6 100644 --- a/spring-boot-service-template-common/pom.xml +++ b/spring-boot-service-template-common/pom.xml @@ -11,6 +11,14 @@ spring-boot-service-template-common + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/spring-boot-service-template-common/src/test/java/com/lanyuanxiaoyao/service/template/common/helper/ObjectHelperTest.java b/spring-boot-service-template-common/src/test/java/com/lanyuanxiaoyao/service/template/common/helper/ObjectHelperTest.java new file mode 100644 index 0000000..2649a3f --- /dev/null +++ b/spring-boot-service-template-common/src/test/java/com/lanyuanxiaoyao/service/template/common/helper/ObjectHelperTest.java @@ -0,0 +1,263 @@ +package com.lanyuanxiaoyao.service.template.common.helper; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.Arguments; + +import java.util.*; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ObjectHelper Tests") +class ObjectHelperTest { + + @Nested + @DisplayName("isNull/isNotNull") + class NullTests { + + @Test + @DisplayName("isNull with null object returns true") + void isNull_withNull_returnsTrue() { + assertThat(ObjectHelper.isNull(null)).isTrue(); + } + + @Test + @DisplayName("isNull with non-null object returns false") + void isNull_withNonNull_returnsFalse() { + assertThat(ObjectHelper.isNull(new Object())).isFalse(); + } + + @Test + @DisplayName("isNotNull with null object returns false") + void isNotNull_withNull_returnsFalse() { + assertThat(ObjectHelper.isNotNull(null)).isFalse(); + } + + @Test + @DisplayName("isNotNull with non-null object returns true") + void isNotNull_withNonNull_returnsTrue() { + assertThat(ObjectHelper.isNotNull(new Object())).isTrue(); + } + } + + @Nested + @DisplayName("isEmpty/isNotEmpty") + class EmptyTests { + + static Stream emptyObjects() { + return Stream.of( + Arguments.of("null", null, true), + Arguments.of("empty collection", Collections.emptyList(), true), + Arguments.of("non-empty collection", List.of("a"), false), + Arguments.of("empty map", Collections.emptyMap(), true), + Arguments.of("non-empty map", Map.of("key", "value"), false), + Arguments.of("empty string", "", true), + Arguments.of("non-empty string", "text", false), + Arguments.of("empty char sequence", new StringBuilder(), true), + Arguments.of("non-empty char sequence", new StringBuilder("text"), false), + Arguments.of("empty object array", new Object[]{}, true), + Arguments.of("non-empty object array", new Object[]{"a"}, false), + Arguments.of("empty byte array", new byte[]{}, true), + Arguments.of("non-empty byte array", new byte[]{1}, false), + Arguments.of("empty short array", new short[]{}, true), + Arguments.of("non-empty short array", new short[]{1}, false), + Arguments.of("empty int array", new int[]{}, true), + Arguments.of("non-empty int array", new int[]{1}, false), + Arguments.of("empty long array", new long[]{}, true), + Arguments.of("non-empty long array", new long[]{1L}, false), + Arguments.of("empty float array", new float[]{}, true), + Arguments.of("non-empty float array", new float[]{1.0f}, false), + Arguments.of("empty double array", new double[]{}, true), + Arguments.of("non-empty double array", new double[]{1.0}, false), + Arguments.of("empty char array", new char[]{}, true), + Arguments.of("non-empty char array", new char[]{'a'}, false), + Arguments.of("empty boolean array", new boolean[]{}, true), + Arguments.of("non-empty boolean array", new boolean[]{true}, false), + Arguments.of("empty optional", Optional.empty(), true), + Arguments.of("non-empty optional", Optional.of("value"), false), + Arguments.of("non-empty object", new Object(), false) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("emptyObjects") + @DisplayName("isEmpty returns correct result") + void isEmpty_withVariousObjects_returnsCorrectResult(String description, Object obj, boolean expected) { + assertThat(ObjectHelper.isEmpty(obj)).isEqualTo(expected); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("emptyObjects") + @DisplayName("isNotEmpty returns opposite of isEmpty") + void isNotEmpty_withVariousObjects_returnsCorrectResult(String description, Object obj, boolean expected) { + assertThat(ObjectHelper.isNotEmpty(obj)).isEqualTo(!expected); + } + } + + @Nested + @DisplayName("defaultIfNull") + class DefaultIfNullTests { + + @Test + @DisplayName("defaultIfNull with null returns default value") + void defaultIfNull_withNull_returnsDefault() { + assertThat(ObjectHelper.defaultIfNull(null, "default")).isEqualTo("default"); + } + + @Test + @DisplayName("defaultIfNull with non-null returns original value") + void defaultIfNull_withNonNull_returnsOriginal() { + assertThat(ObjectHelper.defaultIfNull("value", "default")).isEqualTo("value"); + } + + @Test + @DisplayName("defaultIfNull with null and null default returns null") + void defaultIfNull_withBothNull_returnsNull() { + String result = ObjectHelper.defaultIfNull(null, null); + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("isComparable") + class ComparableTests { + + @Test + @DisplayName("isComparable with enum class returns true") + void isComparable_withEnum_returnsTrue() { + assertThat(ObjectHelper.isComparable(TestEnum.class)).isTrue(); + } + + @Test + @DisplayName("isComparable with String class returns true") + void isComparable_withString_returnsTrue() { + assertThat(ObjectHelper.isComparable(String.class)).isTrue(); + } + + @Test + @DisplayName("isComparable with Integer class returns true") + void isComparable_withInteger_returnsTrue() { + assertThat(ObjectHelper.isComparable(Integer.class)).isTrue(); + } + + @Test + @DisplayName("isComparable with primitive class returns true") + void isComparable_withPrimitive_returnsTrue() { + assertThat(ObjectHelper.isComparable(int.class)).isTrue(); + } + + @Test + @DisplayName("isComparable with non-comparable class returns false") + void isComparable_withNonComparable_returnsFalse() { + assertThat(ObjectHelper.isComparable(Object.class)).isFalse(); + } + + @Test + @DisplayName("isComparable with null class returns false") + void isComparable_withNull_returnsFalse() { + Class nullClass = null; + assertThat(ObjectHelper.isComparable(nullClass)).isFalse(); + } + + @Test + @DisplayName("isComparable with enum object returns true") + void isComparable_withEnumObject_returnsTrue() { + assertThat(ObjectHelper.isComparable(TestEnum.VALUE)).isTrue(); + } + + @Test + @DisplayName("isComparable with null object returns false") + void isComparable_withNullObject_returnsFalse() { + Object nullObj = null; + assertThat(ObjectHelper.isComparable(nullObj)).isFalse(); + } + } + + @Nested + @DisplayName("isCollection") + class CollectionTests { + + @Test + @DisplayName("isCollection with List class returns true") + void isCollection_withList_returnsTrue() { + assertThat(ObjectHelper.isCollection(List.class)).isTrue(); + } + + @Test + @DisplayName("isCollection with Set class returns true") + void isCollection_withSet_returnsTrue() { + assertThat(ObjectHelper.isCollection(Set.class)).isTrue(); + } + + @Test + @DisplayName("isCollection with non-collection class returns false") + void isCollection_withNonCollection_returnsFalse() { + assertThat(ObjectHelper.isCollection(String.class)).isFalse(); + } + + @Test + @DisplayName("isCollection with null class returns false") + void isCollection_withNull_returnsFalse() { + Class nullClass = null; + assertThat(ObjectHelper.isCollection(nullClass)).isFalse(); + } + + @Test + @DisplayName("isCollection with ArrayList object returns true") + void isCollection_withArrayListObject_returnsTrue() { + assertThat(ObjectHelper.isCollection(new ArrayList<>())).isTrue(); + } + + @Test + @DisplayName("isCollection with null object returns false") + void isCollection_withNullObject_returnsFalse() { + Object nullObj = null; + assertThat(ObjectHelper.isCollection(nullObj)).isFalse(); + } + } + + @Nested + @DisplayName("isString") + class StringTests { + + @Test + @DisplayName("isString with String class returns true") + void isString_withStringClass_returnsTrue() { + assertThat(ObjectHelper.isString(String.class)).isTrue(); + } + + @Test + @DisplayName("isString with non-string class returns false") + void isString_withNonStringClass_returnsFalse() { + assertThat(ObjectHelper.isString(Integer.class)).isFalse(); + } + + @Test + @DisplayName("isString with null class returns false") + void isString_withNullClass_returnsFalse() { + Class nullClass = null; + assertThat(ObjectHelper.isString(nullClass)).isFalse(); + } + + @Test + @DisplayName("isString with string object returns true") + void isString_withStringObject_returnsTrue() { + assertThat(ObjectHelper.isString("text")).isTrue(); + } + + @Test + @DisplayName("isString with null object returns false") + void isString_withNullObject_returnsFalse() { + Object nullObj = null; + assertThat(ObjectHelper.isString(nullObj)).isFalse(); + } + } + + private enum TestEnum { + VALUE + } +} diff --git a/spring-boot-service-template-database/pom.xml b/spring-boot-service-template-database/pom.xml index 845b57f..7f3ba0f 100644 --- a/spring-boot-service-template-database/pom.xml +++ b/spring-boot-service-template-database/pom.xml @@ -43,6 +43,31 @@ org.mapstruct mapstruct + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-data-jpa-test + test + + + org.springframework.boot + spring-boot-webmvc-test + test + + + com.h2database + h2 + test + + + com.github.gavlyukovskiy + p6spy-spring-boot-starter + test + diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/helper/SnowflakeHelperTest.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/helper/SnowflakeHelperTest.java new file mode 100644 index 0000000..d4465da --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/helper/SnowflakeHelperTest.java @@ -0,0 +1,104 @@ +package com.lanyuanxiaoyao.service.template.database.helper; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SnowflakeHelper Tests") +class SnowflakeHelperTest { + + @Nested + @DisplayName("ID Generation") + class IdGenerationTests { + + @Test + @DisplayName("next returns positive number") + void next_returnsPositiveNumber() { + long id = SnowflakeHelper.next(); + assertThat(id).isPositive(); + } + + @Test + @DisplayName("next returns monotonically increasing IDs") + void next_returnsMonotonicallyIncreasingIds() { + long id1 = SnowflakeHelper.next(); + long id2 = SnowflakeHelper.next(); + long id3 = SnowflakeHelper.next(); + + assertThat(id2).isGreaterThan(id1); + assertThat(id3).isGreaterThan(id2); + } + } + + @Nested + @DisplayName("Uniqueness") + class UniquenessTests { + + @Test + @DisplayName("batch generation produces unique IDs") + void batchGeneration_producesUniqueIds() { + int count = 10000; + Set ids = Collections.synchronizedSet(new HashSet<>()); + + for (int i = 0; i < count; i++) { + ids.add(SnowflakeHelper.next()); + } + + assertThat(ids).hasSize(count); + } + + @Test + @DisplayName("concurrent generation produces unique IDs") + void concurrentGeneration_producesUniqueIds() throws InterruptedException { + int threadCount = 10; + int idsPerThread = 1000; + Set ids = Collections.newSetFromMap(new ConcurrentHashMap<>()); + CountDownLatch latch = new CountDownLatch(threadCount); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + for (int j = 0; j < idsPerThread; j++) { + ids.add(SnowflakeHelper.next()); + } + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executor.shutdown(); + + assertThat(ids).hasSize(threadCount * idsPerThread); + } + } + + @Nested + @DisplayName("Performance") + class PerformanceTests { + + @RepeatedTest(5) + @DisplayName("generates 1000 IDs quickly") + void generatesIdsQuickly() { + long start = System.currentTimeMillis(); + for (int i = 0; i < 1000; i++) { + SnowflakeHelper.next(); + } + long elapsed = System.currentTimeMillis() - start; + assertThat(elapsed).isLessThan(1000); + } + } +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/IntegrationTestConfiguration.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/IntegrationTestConfiguration.java new file mode 100644 index 0000000..a67e024 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/IntegrationTestConfiguration.java @@ -0,0 +1,11 @@ +package com.lanyuanxiaoyao.service.template.database.integration; + +import com.blinkfox.fenix.EnableFenix; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableFenix +@EnableJpaAuditing +public class IntegrationTestConfiguration { +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/QueryBuilder.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/QueryBuilder.java new file mode 100644 index 0000000..43dbcee --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/QueryBuilder.java @@ -0,0 +1,151 @@ +package com.lanyuanxiaoyao.service.template.database.integration; + +import com.lanyuanxiaoyao.service.template.database.entity.Query; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +public class QueryBuilder { + private List nullEqual; + private List notNullEqual; + private List empty; + private List notEmpty; + private Map equal; + private Map notEqual; + private Map like; + private Map notLike; + private Map contain; + private Map notContain; + private Map startWith; + private Map notStartWith; + private Map endWith; + private Map notEndWith; + private Map great; + private Map less; + private Map greatEqual; + private Map lessEqual; + private Map> inside; + private Map> notInside; + private Map between; + private Map notBetween; + + public QueryBuilder nullEqual(List nullEqual) { + this.nullEqual = nullEqual; + return this; + } + + public QueryBuilder notNullEqual(List notNullEqual) { + this.notNullEqual = notNullEqual; + return this; + } + + public QueryBuilder empty(List empty) { + this.empty = empty; + return this; + } + + public QueryBuilder notEmpty(List notEmpty) { + this.notEmpty = notEmpty; + return this; + } + + public QueryBuilder equal(Map equal) { + this.equal = equal; + return this; + } + + public QueryBuilder notEqual(Map notEqual) { + this.notEqual = notEqual; + return this; + } + + public QueryBuilder like(Map like) { + this.like = like; + return this; + } + + public QueryBuilder notLike(Map notLike) { + this.notLike = notLike; + return this; + } + + public QueryBuilder contain(Map contain) { + this.contain = contain; + return this; + } + + public QueryBuilder notContain(Map notContain) { + this.notContain = notContain; + return this; + } + + public QueryBuilder startWith(Map startWith) { + this.startWith = startWith; + return this; + } + + public QueryBuilder notStartWith(Map notStartWith) { + this.notStartWith = notStartWith; + return this; + } + + public QueryBuilder endWith(Map endWith) { + this.endWith = endWith; + return this; + } + + public QueryBuilder notEndWith(Map notEndWith) { + this.notEndWith = notEndWith; + return this; + } + + public QueryBuilder great(Map great) { + this.great = great; + return this; + } + + public QueryBuilder less(Map less) { + this.less = less; + return this; + } + + public QueryBuilder greatEqual(Map greatEqual) { + this.greatEqual = greatEqual; + return this; + } + + public QueryBuilder lessEqual(Map lessEqual) { + this.lessEqual = lessEqual; + return this; + } + + public QueryBuilder inside(Map> inside) { + this.inside = inside; + return this; + } + + public QueryBuilder notInside(Map> notInside) { + this.notInside = notInside; + return this; + } + + public QueryBuilder between(Map between) { + this.between = between; + return this; + } + + public QueryBuilder notBetween(Map notBetween) { + this.notBetween = notBetween; + return this; + } + + public Query build() { + return new Query(new Query.Queryable( + nullEqual, notNullEqual, empty, notEmpty, equal, notEqual, + like, notLike, contain, notContain, startWith, notStartWith, + endWith, notEndWith, great, less, greatEqual, lessEqual, + inside, notInside, between, notBetween + ), null, null); + } +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SimpleControllerSupportIntegrationTest.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SimpleControllerSupportIntegrationTest.java new file mode 100644 index 0000000..429ac19 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SimpleControllerSupportIntegrationTest.java @@ -0,0 +1,208 @@ +package com.lanyuanxiaoyao.service.template.database.integration; + +import tools.jackson.databind.ObjectMapper; +import com.lanyuanxiaoyao.service.template.database.entity.Query; +import com.lanyuanxiaoyao.service.template.database.integration.controller.TestController; +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity; +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestStatus; +import com.lanyuanxiaoyao.service.template.database.integration.repository.TestRepository; +import com.lanyuanxiaoyao.service.template.database.integration.service.TestService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.AutoConfigureDataJpa; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TestController.class) +@AutoConfigureDataJpa +@ActiveProfiles("test") +@Import({IntegrationTestConfiguration.class, TestService.class}) +@DisplayName("SimpleControllerSupport Integration Tests") +class SimpleControllerSupportIntegrationTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + TestRepository repository; + + @BeforeEach + void cleanUp() { + repository.deleteAll(); + } + + @Nested + @DisplayName("POST /save") + class SaveTests { + + @Test + @DisplayName("save with valid data returns id") + void save_withValidData_returnsId() throws Exception { + var request = new TestController.SaveItem("张三", 25, TestStatus.ACTIVE, 5000.0, "java"); + + mockMvc.perform(post("/test/save") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(0)) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").isNumber()); + } + + @Test + @DisplayName("saveItemMapper converts correctly") + void saveItemMapper_convertsCorrectly() throws Exception { + var request = new TestController.SaveItem("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring"); + + String response = mockMvc.perform(post("/test/save") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andReturn().getResponse().getContentAsString(); + + Long id = objectMapper.readTree(response).get("data").asLong(); + TestEntity saved = repository.findById(id).orElseThrow(); + + assertThat(saved.getName()).isEqualTo("张三"); + assertThat(saved.getAge()).isEqualTo(25); + assertThat(saved.getStatus()).isEqualTo(TestStatus.ACTIVE); + assertThat(saved.getSalary()).isEqualTo(5000.0); + assertThat(saved.getTags()).isEqualTo("java,spring"); + } + } + + @Nested + @DisplayName("GET /list") + class ListTests { + + @Test + @DisplayName("list returns all entities") + void list_returnsAllEntities() throws Exception { + repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + repository.save(new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python")); + + mockMvc.perform(get("/test/list")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(0)) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.total").value(2)); + } + + @Test + @DisplayName("listItemMapper converts correctly") + void listItemMapper_convertsCorrectly() throws Exception { + var entity = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + + mockMvc.perform(get("/test/list")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].id").value(entity.getId())) + .andExpect(jsonPath("$.data.items[0].name").value("张三")) + .andExpect(jsonPath("$.data.items[0].age").value(25)) + .andExpect(jsonPath("$.data.items[0].status").value("ACTIVE")); + } + } + + @Nested + @DisplayName("POST /list") + class ListWithQueryTests { + + @BeforeEach + void setUp() { + repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + repository.save(new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python")); + } + + @Test + @DisplayName("list with query filters correctly") + void listWithQuery_filtersCorrectly() throws Exception { + var query = new QueryBuilder().equal(java.util.Map.of("name", "张三")).build(); + + mockMvc.perform(post("/test/list") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(query))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].name").value("张三")); + } + + @Test + @DisplayName("list with pagination returns correct page") + void listWithPagination_returnsCorrectPage() throws Exception { + var query = new Query(null, null, new Query.Pageable(1, 1)); + + mockMvc.perform(post("/test/list") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(query))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.total").value(2)); + } + } + + @Nested + @DisplayName("GET /detail/{id}") + class DetailTests { + + @Test + @DisplayName("detail with existing id returns entity") + void detail_withExistingId_returnsEntity() throws Exception { + var entity = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring")); + + mockMvc.perform(get("/test/detail/{id}", entity.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(0)) + .andExpect(jsonPath("$.data.id").value(entity.getId())) + .andExpect(jsonPath("$.data.name").value("张三")) + .andExpect(jsonPath("$.data.age").value(25)) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.salary").value(5000.0)) + .andExpect(jsonPath("$.data.tags").value("java,spring")); + } + + @Test + @DisplayName("detailItemMapper converts correctly") + void detailItemMapper_convertsCorrectly() throws Exception { + var entity = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring")); + + mockMvc.perform(get("/test/detail/{id}", entity.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(entity.getId())) + .andExpect(jsonPath("$.data.name").value("张三")) + .andExpect(jsonPath("$.data.tags").value("java,spring")); + } + } + + @Nested + @DisplayName("GET /remove/{id}") + class RemoveTests { + + @Test + @DisplayName("remove deletes entity") + void remove_deletesEntity() throws Exception { + var entity = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + + mockMvc.perform(get("/test/remove/{id}", entity.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(0)) + .andExpect(jsonPath("$.data").isEmpty()); + + assertThat(repository.findById(entity.getId())).isEmpty(); + } + } +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SimpleServiceSupportIntegrationTest.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SimpleServiceSupportIntegrationTest.java new file mode 100644 index 0000000..5b4c9e6 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SimpleServiceSupportIntegrationTest.java @@ -0,0 +1,662 @@ +package com.lanyuanxiaoyao.service.template.database.integration; + +import com.lanyuanxiaoyao.service.template.database.entity.Query; +import com.lanyuanxiaoyao.service.template.database.exception.IdNotFoundException; +import com.lanyuanxiaoyao.service.template.database.exception.NotCollectionException; +import com.lanyuanxiaoyao.service.template.database.exception.NotComparableException; +import com.lanyuanxiaoyao.service.template.database.exception.NotStringException; +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity; +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestStatus; +import com.lanyuanxiaoyao.service.template.database.integration.repository.TestRepository; +import com.lanyuanxiaoyao.service.template.database.integration.service.TestService; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@ActiveProfiles("test") +@Import({IntegrationTestConfiguration.class, TestService.class}) +@DisplayName("SimpleServiceSupport Integration Tests") +class SimpleServiceSupportIntegrationTest { + + @Autowired + TestService service; + + @Autowired + TestRepository repository; + + @Autowired + EntityManager entityManager; + + @BeforeEach + void cleanUp() { + repository.deleteAllInBatch(); + } + + @Nested + @DisplayName("CRUD Operations") + class CrudTests { + + @Test + @DisplayName("save with new entity returns generated id") + void save_withNewEntity_returnsId() { + var entity = new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring"); + + Long id = service.save(entity); + + assertThat(id).isNotNull().isPositive(); + var saved = repository.findById(id).orElseThrow(); + assertThat(saved.getName()).isEqualTo("张三"); + assertThat(saved.getAge()).isEqualTo(25); + assertThat(saved.getStatus()).isEqualTo(TestStatus.ACTIVE); + } + + @Test + @DisplayName("save with existing entity updates non-null fields") + void save_withExistingEntity_updatesFields() { + var entity = new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"); + Long id = service.save(entity); + + var update = new TestEntity(null, 26, null, 6000.0, null); + update.setId(id); + service.save(update); + + var updated = repository.findById(id).orElseThrow(); + assertThat(updated.getName()).isEqualTo("张三"); + assertThat(updated.getAge()).isEqualTo(26); + assertThat(updated.getStatus()).isEqualTo(TestStatus.ACTIVE); + assertThat(updated.getSalary()).isEqualTo(6000.0); + } + + @Test + @DisplayName("save multiple entities saves all") + void save_multipleEntities_savesAll() { + var entities = List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python") + ); + + service.save(entities); + + assertThat(repository.count()).isEqualTo(2); + } + + @Test + @DisplayName("count returns correct total") + void count_returnsCorrectTotal() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python") + )); + + Long count = service.count(); + + assertThat(count).isEqualTo(2); + } + + @Test + @DisplayName("list returns all entities") + void list_returnsAllEntities() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python") + )); + + List result = service.list(); + + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("list with ids returns matching entities") + void list_withIds_returnsMatching() { + var e1 = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + var e2 = repository.save(new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python")); + repository.save(new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go")); + + List result = service.list(Set.of(e1.getId(), e2.getId())); + + assertThat(result).hasSize(2) + .extracting(TestEntity::getName) + .containsExactlyInAnyOrder("张三", "李四"); + } + + @Test + @DisplayName("detail with existing id returns entity") + void detail_withExistingId_returnsEntity() { + var saved = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + + TestEntity result = service.detail(saved.getId()); + + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("张三"); + } + + @Test + @DisplayName("detail with non-existing id returns null") + void detail_withNonExistingId_returnsNull() { + TestEntity result = service.detail(999L); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("detailOrThrow with non-existing id throws exception") + void detailOrThrow_withNonExistingId_throwsException() { + assertThatThrownBy(() -> service.detailOrThrow(999L)) + .isInstanceOf(IdNotFoundException.class); + } + + @Test + @DisplayName("remove deletes entity") + void remove_deletesEntity() { + var saved = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + repository.flush(); + entityManager.clear(); + + service.remove(saved.getId()); + repository.flush(); + + var nativeQuery = entityManager.createNativeQuery("SELECT COUNT(*) FROM test_entity WHERE id = ?"); + nativeQuery.setParameter(1, saved.getId()); + var count = ((Number) nativeQuery.getSingleResult()).longValue(); + assertThat(count).isZero(); + } + + @Test + @DisplayName("remove multiple entities deletes all") + void remove_multipleEntities_deletesAll() { + var e1 = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + var e2 = repository.save(new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python")); + + service.remove(Set.of(e1.getId(), e2.getId())); + repository.flush(); + + assertThat(repository.count()).isZero(); + } + } + + @Nested + @DisplayName("Equality Conditions") + class EqualityConditionTests { + + @BeforeEach + void setUp() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, null), + new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go") + )); + } + + @Test + @DisplayName("equal condition filters correctly") + void equalCondition_filtersCorrectly() { + var query = new QueryBuilder().equal(Map.of("name", "张三")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).getName()).isEqualTo("张三"); + } + + @Test + @DisplayName("notEqual condition filters correctly") + void notEqualCondition_filtersCorrectly() { + var query = new QueryBuilder().notEqual(Map.of("name", "张三")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).noneMatch(e -> "张三".equals(e.getName())); + } + + @Test + @DisplayName("nullEqual condition filters null values") + void nullEqualCondition_filtersNullValues() { + var query = new QueryBuilder().nullEqual(List.of("tags")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).getTags()).isNull(); + } + + @Test + @DisplayName("notNullEqual condition filters non-null values") + void notNullEqualCondition_filtersNonNullValues() { + var query = new QueryBuilder().notNullEqual(List.of("tags")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).allMatch(e -> e.getTags() != null); + } + } + + @Nested + @DisplayName("String Conditions") + class StringConditionTests { + + @BeforeEach + void setUp() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring"), + new TestEntity("李四三", 30, TestStatus.INACTIVE, 6000.0, "python"), + new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go") + )); + } + + @Test + @DisplayName("like condition filters correctly") + void likeCondition_filtersCorrectly() { + var query = new QueryBuilder().like(Map.of("name", "%三%")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + } + + @Test + @DisplayName("notLike condition filters correctly") + void notLikeCondition_filtersCorrectly() { + var query = new QueryBuilder().notLike(Map.of("name", "%三%")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).getName()).isEqualTo("王五"); + } + + @Test + @DisplayName("contain condition filters correctly") + void containCondition_filtersCorrectly() { + var query = new QueryBuilder().contain(Map.of("name", "三")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + } + + @Test + @DisplayName("notContain condition filters correctly") + void notContainCondition_filtersCorrectly() { + var query = new QueryBuilder().notContain(Map.of("name", "三")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(1); + } + + @Test + @DisplayName("startWith condition filters correctly") + void startWithCondition_filtersCorrectly() { + var query = new QueryBuilder().startWith(Map.of("name", "张")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).getName()).isEqualTo("张三"); + } + + @Test + @DisplayName("notStartWith condition filters correctly") + void notStartWithCondition_filtersCorrectly() { + var query = new QueryBuilder().notStartWith(Map.of("name", "张")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).noneMatch(e -> e.getName().startsWith("张")); + } + + @Test + @DisplayName("endWith condition filters correctly") + void endWithCondition_filtersCorrectly() { + var query = new QueryBuilder().endWith(Map.of("name", "三")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + } + + @Test + @DisplayName("notEndWith condition filters correctly") + void notEndWithCondition_filtersCorrectly() { + var query = new QueryBuilder().notEndWith(Map.of("name", "三")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).getName()).isEqualTo("王五"); + } + } + + @Nested + @DisplayName("Comparison Conditions") + class ComparisonConditionTests { + + @BeforeEach + void setUp() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 35, TestStatus.INACTIVE, 6000.0, "python"), + new TestEntity("王五", 45, TestStatus.DELETED, 7000.0, "go") + )); + } + + @Test + @DisplayName("great condition filters correctly") + void greatCondition_filtersCorrectly() { + var query = new QueryBuilder().great(Map.of("age", 30)).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).allMatch(e -> e.getAge() > 30); + } + + @Test + @DisplayName("less condition filters correctly") + void lessCondition_filtersCorrectly() { + var query = new QueryBuilder().less(Map.of("age", 40)).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).allMatch(e -> e.getAge() < 40); + } + + @Test + @DisplayName("greatEqual condition filters correctly") + void greatEqualCondition_filtersCorrectly() { + var query = new QueryBuilder().greatEqual(Map.of("age", 35)).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).allMatch(e -> e.getAge() >= 35); + } + + @Test + @DisplayName("lessEqual condition filters correctly") + void lessEqualCondition_filtersCorrectly() { + var query = new QueryBuilder().lessEqual(Map.of("age", 35)).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).allMatch(e -> e.getAge() <= 35); + } + + @Test + @DisplayName("between condition filters correctly") + void betweenCondition_filtersCorrectly() { + var query = new QueryBuilder().between(Map.of("age", new Query.Queryable.Between(25, 35))).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).allMatch(e -> e.getAge() >= 25 && e.getAge() <= 35); + } + + @Test + @DisplayName("notBetween condition filters correctly") + void notBetweenCondition_filtersCorrectly() { + var query = new QueryBuilder().notBetween(Map.of("age", new Query.Queryable.Between(26, 44))).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + } + } + + @Nested + @DisplayName("Collection Conditions") + class CollectionConditionTests { + + @Test + @DisplayName("inside condition filters correctly") + void insideCondition_filtersCorrectly() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"), + new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go") + )); + + var query = new QueryBuilder().inside(Map.of("age", List.of(25, 35))).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + } + + @Test + @DisplayName("notInside condition filters correctly") + void notInsideCondition_filtersCorrectly() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"), + new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go") + )); + + var query = new QueryBuilder().notInside(Map.of("age", List.of(25, 35))).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).getAge()).isEqualTo(30); + } + + @Test + @DisplayName("empty condition filters correctly") + void emptyCondition_filtersCorrectly() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"), + new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go") + )); + + var query = new QueryBuilder().empty(List.of("tags")).build(); + + assertThatThrownBy(() -> service.list(query)) + .isInstanceOf(NotCollectionException.class); + } + + @Test + @DisplayName("notEmpty condition filters correctly") + void notEmptyCondition_filtersCorrectly() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"), + new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go") + )); + + var query = new QueryBuilder().notEmpty(List.of("tags")).build(); + + assertThatThrownBy(() -> service.list(query)) + .isInstanceOf(NotCollectionException.class); + } + } + + @Nested + @DisplayName("Enum Condition") + class EnumConditionTests { + + @BeforeEach + void setUp() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"), + new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go") + )); + } + + @Test + @DisplayName("enum condition filters correctly") + void enumCondition_filtersCorrectly() { + var query = new QueryBuilder().equal(Map.of("status", "ACTIVE")).build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).getStatus()).isEqualTo(TestStatus.ACTIVE); + } + } + + @Nested + @DisplayName("Combined Conditions") + class CombinedConditionTests { + + @BeforeEach + void setUp() { + repository.saveAll(List.of( + new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"), + new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"), + new TestEntity("王五", 35, TestStatus.ACTIVE, 7000.0, "go"), + new TestEntity("赵六", 40, TestStatus.ACTIVE, 8000.0, "rust") + )); + } + + @Test + @DisplayName("combined conditions filter correctly") + void combinedConditions_filterCorrectly() { + var query = new QueryBuilder() + .equal(Map.of("status", "ACTIVE")) + .great(Map.of("age", 30)) + .build(); + + var result = service.list(query); + + assertThat(result.items()).hasSize(2); + assertThat(result.items()).allMatch(e -> e.getStatus() == TestStatus.ACTIVE && e.getAge() > 30); + } + } + + @Nested + @DisplayName("Pagination and Sorting") + class PaginationSortingTests { + + @BeforeEach + void setUp() { + repository.deleteAllInBatch(); + entityManager.flush(); + entityManager.clear(); + for (int i = 1; i <= 25; i++) { + repository.save(new TestEntity("用户" + i, 20 + i, TestStatus.ACTIVE, 5000.0 + i * 100, "tag" + i)); + } + } + + @Test + @DisplayName("default pagination returns first page with 10 items") + void defaultPagination_returnsFirstPage() { + var query = new Query(null, null, null); + + var result = service.list(query); + + assertThat(result.items()).hasSize(10); + assertThat(result.total()).isEqualTo(25); + } + + @Test + @DisplayName("custom pagination returns correct page") + void customPagination_returnsCorrectPage() { + var query = new Query(null, null, new Query.Pageable(1, 5)); + + var result = service.list(query); + + assertThat(result.items()).hasSize(5); + assertThat(result.total()).isEqualTo(25); + } + + @Test + @DisplayName("custom sorting sorts correctly") + void customSorting_sortsCorrectly() { + var query = new Query(null, List.of(new Query.Sortable("age", Query.Sortable.Direction.ASC)), new Query.Pageable(1, 10)); + + var result = service.list(query); + + assertThat(result.items()).hasSize(10); + assertThat(result.items().get(0).getAge()).isEqualTo(21); + assertThat(result.items()).isSortedAccordingTo((a, b) -> a.getAge().compareTo(b.getAge())); + } + + @Test + @DisplayName("multiple sort fields sort correctly") + void multipleSortFields_sortCorrectly() { + repository.deleteAllInBatch(); + entityManager.flush(); + entityManager.clear(); + + repository.saveAll(List.of( + new TestEntity("A", 20, TestStatus.ACTIVE, 5000.0, null), + new TestEntity("B", 30, TestStatus.ACTIVE, 3000.0, null), + new TestEntity("C", 30, TestStatus.ACTIVE, 4000.0, null) + )); + + var query = new Query(null, List.of( + new Query.Sortable("age", Query.Sortable.Direction.ASC), + new Query.Sortable("salary", Query.Sortable.Direction.DESC) + ), new Query.Pageable(1, 10)); + + var result = service.list(query); + + assertThat(result.items()).hasSize(3); + assertThat(result.items().get(0).getName()).isEqualTo("A"); + assertThat(result.items().get(1).getName()).isEqualTo("C"); + assertThat(result.items().get(2).getName()).isEqualTo("B"); + } + } + + @Nested + @DisplayName("Exception Handling") + class ExceptionTests { + + @Test + @DisplayName("like on non-string field throws NotStringException") + void likeOnNonStringField_throwsNotStringException() { + repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + + var query = new QueryBuilder().like(Map.of("age", "%5%")).build(); + + assertThatThrownBy(() -> service.list(query)) + .isInstanceOf(NotStringException.class); + } + + @Test + @DisplayName("great on enum field with enum value throws exception") + void greatOnEnumField_withEnumValue_throwsException() { + repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + + var query = new QueryBuilder().great(Map.of("status", TestStatus.ACTIVE)).build(); + + assertThatThrownBy(() -> service.list(query)) + .isInstanceOf(org.springframework.dao.InvalidDataAccessApiUsageException.class) + .hasMessageContaining("枚举类型字段需要 String 类型的值"); + } + + @Test + @DisplayName("empty on non-collection field throws NotCollectionException") + void emptyOnNonCollectionField_throwsNotCollectionException() { + repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java")); + + var query = new QueryBuilder().empty(List.of("name")).build(); + + assertThatThrownBy(() -> service.list(query)) + .isInstanceOf(NotCollectionException.class); + } + } +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SoftDeleteIntegrationTest.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SoftDeleteIntegrationTest.java new file mode 100644 index 0000000..bee43c7 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/SoftDeleteIntegrationTest.java @@ -0,0 +1,194 @@ +package com.lanyuanxiaoyao.service.template.database.integration; + +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestSoftDeleteEntity; +import com.lanyuanxiaoyao.service.template.database.integration.repository.TestSoftDeleteRepository; +import com.lanyuanxiaoyao.service.template.database.integration.service.TestSoftDeleteService; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import({IntegrationTestConfiguration.class, TestSoftDeleteService.class}) +@DisplayName("@SoftDelete Integration Tests") +class SoftDeleteIntegrationTest { + + @Autowired + TestSoftDeleteService service; + + @Autowired + TestSoftDeleteRepository repository; + + @Autowired + EntityManager entityManager; + + @BeforeEach + void cleanUp() { + repository.deleteAllInBatch(); + } + + @Nested + @DisplayName("Save and Delete Behavior") + class SaveDeleteTests { + + @Test + @DisplayName("save entity sets deleted to false") + void saveEntity_setsDeletedFalse() { + var entity = new TestSoftDeleteEntity("张三", 25); + + Long id = service.save(entity); + + var saved = repository.findById(id).orElseThrow(); + assertThat(saved.getName()).isEqualTo("张三"); + + var nativeQuery = entityManager.createNativeQuery("SELECT deleted FROM test_soft_delete_entity WHERE id = ?"); + nativeQuery.setParameter(1, id); + var deleted = (Boolean) nativeQuery.getSingleResult(); + assertThat(deleted).isFalse(); + } + + @Test + @DisplayName("remove sets deleted to true") + void remove_setsDeletedTrue() { + var entity = new TestSoftDeleteEntity("张三", 25); + Long id = service.save(entity); + repository.flush(); + entityManager.clear(); + + service.remove(id); + repository.flush(); + + var nativeQuery = entityManager.createNativeQuery("SELECT deleted FROM test_soft_delete_entity WHERE id = ?"); + nativeQuery.setParameter(1, id); + var deleted = (Boolean) nativeQuery.getSingleResult(); + assertThat(deleted).isTrue(); + } + + @Test + @DisplayName("batch remove sets deleted to true") + void batchRemove_setsDeletedTrue() { + var e1 = new TestSoftDeleteEntity("张三", 25); + var e2 = new TestSoftDeleteEntity("李四", 30); + Long id1 = service.save(e1); + Long id2 = service.save(e2); + + service.remove(Set.of(id1, id2)); + + var nativeQuery = entityManager.createNativeQuery("SELECT COUNT(*) FROM test_soft_delete_entity WHERE deleted = true"); + var count = ((Number) nativeQuery.getSingleResult()).longValue(); + assertThat(count).isEqualTo(2); + + assertThat(repository.findAll()).isEmpty(); + } + } + + @Nested + @DisplayName("Query Behavior") + class QueryTests { + + @Test + @DisplayName("list filters out deleted entities") + void list_filtersDeletedEntities() { + var e1 = repository.save(new TestSoftDeleteEntity("张三", 25)); + repository.save(new TestSoftDeleteEntity("李四", 30)); + + entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?") + .setParameter(1, e1.getId()) + .executeUpdate(); + entityManager.clear(); + + List result = service.list(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("李四"); + } + + @Test + @DisplayName("count excludes deleted entities") + void count_excludesDeletedEntities() { + var e1 = repository.save(new TestSoftDeleteEntity("张三", 25)); + repository.save(new TestSoftDeleteEntity("李四", 30)); + + entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?") + .setParameter(1, e1.getId()) + .executeUpdate(); + entityManager.clear(); + + Long count = service.count(); + + assertThat(count).isEqualTo(1); + } + + @Test + @DisplayName("detail returns null for deleted entity") + void detail_returnsNullForDeletedEntity() { + var entity = repository.save(new TestSoftDeleteEntity("张三", 25)); + + entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?") + .setParameter(1, entity.getId()) + .executeUpdate(); + entityManager.clear(); + + TestSoftDeleteEntity result = service.detail(entity.getId()); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("query with conditions filters deleted entities") + void queryWithConditions_filtersDeletedEntities() { + var e1 = repository.save(new TestSoftDeleteEntity("张三", 25)); + repository.save(new TestSoftDeleteEntity("李四", 30)); + + entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?") + .setParameter(1, e1.getId()) + .executeUpdate(); + entityManager.clear(); + + var query = new QueryBuilder().equal(Map.of("name", "张三")).build(); + + var result = service.list(query); + + assertThat(result.items()).isEmpty(); + } + } + + @Nested + @DisplayName("Restore Behavior") + class RestoreTests { + + @Test + @DisplayName("restore deleted entity makes it visible again") + void restore_makesEntityVisible() { + var entity = repository.save(new TestSoftDeleteEntity("张三", 25)); + + entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?") + .setParameter(1, entity.getId()) + .executeUpdate(); + entityManager.clear(); + + assertThat(service.list()).isEmpty(); + + entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = false WHERE id = ?") + .setParameter(1, entity.getId()) + .executeUpdate(); + entityManager.clear(); + + List result = service.list(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("张三"); + } + } +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/controller/TestController.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/controller/TestController.java new file mode 100644 index 0000000..a99244c --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/controller/TestController.java @@ -0,0 +1,41 @@ +package com.lanyuanxiaoyao.service.template.database.integration.controller; + +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity; +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestStatus; +import com.lanyuanxiaoyao.service.template.database.integration.service.TestService; +import com.lanyuanxiaoyao.service.template.database.controller.SimpleControllerSupport; +import java.util.function.Function; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/test") +public class TestController extends SimpleControllerSupport { + public TestController(TestService service) { + super(service); + } + + @Override + protected Function saveItemMapper() { + return item -> new TestEntity(item.name, item.age, item.status, item.salary, item.tags); + } + + @Override + protected Function listItemMapper() { + return entity -> new ListItem(entity.getId(), entity.getName(), entity.getAge(), entity.getStatus()); + } + + @Override + protected Function detailItemMapper() { + return entity -> new DetailItem(entity.getId(), entity.getName(), entity.getAge(), entity.getStatus(), entity.getSalary(), entity.getTags()); + } + + public record SaveItem(String name, Integer age, TestStatus status, Double salary, String tags) { + } + + public record ListItem(Long id, String name, Integer age, TestStatus status) { + } + + public record DetailItem(Long id, String name, Integer age, TestStatus status, Double salary, String tags) { + } +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/controller/TestSoftDeleteController.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/controller/TestSoftDeleteController.java new file mode 100644 index 0000000..9cbde42 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/controller/TestSoftDeleteController.java @@ -0,0 +1,40 @@ +package com.lanyuanxiaoyao.service.template.database.integration.controller; + +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestSoftDeleteEntity; +import com.lanyuanxiaoyao.service.template.database.integration.service.TestSoftDeleteService; +import com.lanyuanxiaoyao.service.template.database.controller.SimpleControllerSupport; +import java.util.function.Function; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/test-soft-delete") +public class TestSoftDeleteController extends SimpleControllerSupport { + public TestSoftDeleteController(TestSoftDeleteService service) { + super(service); + } + + @Override + protected Function saveItemMapper() { + return item -> new TestSoftDeleteEntity(item.name, item.age); + } + + @Override + protected Function listItemMapper() { + return entity -> new ListItem(entity.getId(), entity.getName(), entity.getAge()); + } + + @Override + protected Function detailItemMapper() { + return entity -> new DetailItem(entity.getId(), entity.getName(), entity.getAge()); + } + + public record SaveItem(String name, Integer age) { + } + + public record ListItem(Long id, String name, Integer age) { + } + + public record DetailItem(Long id, String name, Integer age) { + } +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestEntity.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestEntity.java new file mode 100644 index 0000000..7a3265d --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestEntity.java @@ -0,0 +1,42 @@ +package com.lanyuanxiaoyao.service.template.database.integration.entity; + +import com.lanyuanxiaoyao.service.template.database.entity.SimpleEntity; +import com.lanyuanxiaoyao.service.template.database.entity.SnowflakeId; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; + +@Entity +@Table(name = "test_entity") +@Getter +@Setter +@ToString +@FieldNameConstants +@NoArgsConstructor +@AllArgsConstructor +public class TestEntity extends SimpleEntity { + @SnowflakeId + @Column(comment = "名称") + private String name; + + @Column(comment = "年龄") + private Integer age; + + @Enumerated(EnumType.STRING) + @Column(comment = "状态") + private TestStatus status; + + @Column(comment = "薪资") + private Double salary; + + @Column(comment = "标签") + private String tags; +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestSoftDeleteEntity.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestSoftDeleteEntity.java new file mode 100644 index 0000000..02b4dbe --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestSoftDeleteEntity.java @@ -0,0 +1,33 @@ +package com.lanyuanxiaoyao.service.template.database.integration.entity; + +import com.lanyuanxiaoyao.service.template.database.entity.SimpleEntity; +import com.lanyuanxiaoyao.service.template.database.entity.SnowflakeId; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.FieldNameConstants; +import org.hibernate.annotations.SoftDelete; +import org.hibernate.annotations.SoftDeleteType; + +@Entity +@Table(name = "test_soft_delete_entity") +@SoftDelete(strategy = SoftDeleteType.DELETED) +@Getter +@Setter +@ToString +@FieldNameConstants +@NoArgsConstructor +@AllArgsConstructor +public class TestSoftDeleteEntity extends SimpleEntity { + @SnowflakeId + @Column(comment = "名称") + private String name; + + @Column(comment = "年龄") + private Integer age; +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestStatus.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestStatus.java new file mode 100644 index 0000000..f3a90d6 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/entity/TestStatus.java @@ -0,0 +1,7 @@ +package com.lanyuanxiaoyao.service.template.database.integration.entity; + +public enum TestStatus { + ACTIVE, + INACTIVE, + DELETED +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/repository/TestRepository.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/repository/TestRepository.java new file mode 100644 index 0000000..9c93583 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/repository/TestRepository.java @@ -0,0 +1,9 @@ +package com.lanyuanxiaoyao.service.template.database.integration.repository; + +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity; +import com.lanyuanxiaoyao.service.template.database.repository.SimpleRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TestRepository extends SimpleRepository { +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/repository/TestSoftDeleteRepository.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/repository/TestSoftDeleteRepository.java new file mode 100644 index 0000000..1ba41b9 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/repository/TestSoftDeleteRepository.java @@ -0,0 +1,9 @@ +package com.lanyuanxiaoyao.service.template.database.integration.repository; + +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestSoftDeleteEntity; +import com.lanyuanxiaoyao.service.template.database.repository.SimpleRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TestSoftDeleteRepository extends SimpleRepository { +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/service/TestService.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/service/TestService.java new file mode 100644 index 0000000..82d05a1 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/service/TestService.java @@ -0,0 +1,13 @@ +package com.lanyuanxiaoyao.service.template.database.integration.service; + +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity; +import com.lanyuanxiaoyao.service.template.database.integration.repository.TestRepository; +import com.lanyuanxiaoyao.service.template.database.service.SimpleServiceSupport; +import org.springframework.stereotype.Service; + +@Service +public class TestService extends SimpleServiceSupport { + public TestService(TestRepository repository) { + super(repository); + } +} diff --git a/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/service/TestSoftDeleteService.java b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/service/TestSoftDeleteService.java new file mode 100644 index 0000000..417ef07 --- /dev/null +++ b/spring-boot-service-template-database/src/test/java/com/lanyuanxiaoyao/service/template/database/integration/service/TestSoftDeleteService.java @@ -0,0 +1,13 @@ +package com.lanyuanxiaoyao.service.template.database.integration.service; + +import com.lanyuanxiaoyao.service.template.database.integration.entity.TestSoftDeleteEntity; +import com.lanyuanxiaoyao.service.template.database.integration.repository.TestSoftDeleteRepository; +import com.lanyuanxiaoyao.service.template.database.service.SimpleServiceSupport; +import org.springframework.stereotype.Service; + +@Service +public class TestSoftDeleteService extends SimpleServiceSupport { + public TestSoftDeleteService(TestSoftDeleteRepository repository) { + super(repository); + } +} diff --git a/spring-boot-service-template-database/src/test/resources/application-test.yaml b/spring-boot-service-template-database/src/test/resources/application-test.yaml new file mode 100644 index 0000000..969f709 --- /dev/null +++ b/spring-boot-service-template-database/src/test/resources/application-test.yaml @@ -0,0 +1,20 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + +decorator: + datasource: + p6spy: + logging: slf4j + log-format: "%(executionTime) ms | %(sqlSingleLine)" + +logging: + level: + p6spy: INFO