1
0

test: 添加全面的单元测试和集成测试

- 添加 common 模块单元测试 (ObjectHelper, SnowflakeHelper)
- 添加 database 模块集成测试 (SimpleServiceSupport, @SoftDelete)
- 添加 Controller REST API 契约测试
- 配置 H2 数据库和 p6spy 用于测试
- 更新 openspec 配置,添加并行任务和提问工具规则
This commit is contained in:
2026-04-01 16:15:15 +08:00
parent fc9cb14daf
commit 0a7e38f931
20 changed files with 1855 additions and 0 deletions

View File

@@ -6,6 +6,8 @@ context: |
- 涉及模块结构、API、实体等变更时同步更新README.md - 涉及模块结构、API、实体等变更时同步更新README.md
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明 - Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task - 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行
- 优先使用提问工具对用户进行提问
rules: rules:
proposal: proposal:

View File

@@ -11,6 +11,14 @@
<artifactId>spring-boot-service-template-common</artifactId> <artifactId>spring-boot-service-template-common</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>

View File

@@ -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<Arguments> 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
}
}

View File

@@ -43,6 +43,31 @@
<groupId>org.mapstruct</groupId> <groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId> <artifactId>mapstruct</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -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<Long> 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<Long> 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);
}
}
}

View File

@@ -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 {
}

View File

@@ -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<String> nullEqual;
private List<String> notNullEqual;
private List<String> empty;
private List<String> notEmpty;
private Map<String, ? extends Serializable> equal;
private Map<String, ? extends Serializable> notEqual;
private Map<String, String> like;
private Map<String, String> notLike;
private Map<String, String> contain;
private Map<String, String> notContain;
private Map<String, String> startWith;
private Map<String, String> notStartWith;
private Map<String, String> endWith;
private Map<String, String> notEndWith;
private Map<String, ? extends Serializable> great;
private Map<String, ? extends Serializable> less;
private Map<String, ? extends Serializable> greatEqual;
private Map<String, ? extends Serializable> lessEqual;
private Map<String, List<? extends Serializable>> inside;
private Map<String, List<? extends Serializable>> notInside;
private Map<String, Query.Queryable.Between> between;
private Map<String, Query.Queryable.Between> notBetween;
public QueryBuilder nullEqual(List<String> nullEqual) {
this.nullEqual = nullEqual;
return this;
}
public QueryBuilder notNullEqual(List<String> notNullEqual) {
this.notNullEqual = notNullEqual;
return this;
}
public QueryBuilder empty(List<String> empty) {
this.empty = empty;
return this;
}
public QueryBuilder notEmpty(List<String> notEmpty) {
this.notEmpty = notEmpty;
return this;
}
public QueryBuilder equal(Map<String, ? extends Serializable> equal) {
this.equal = equal;
return this;
}
public QueryBuilder notEqual(Map<String, ? extends Serializable> notEqual) {
this.notEqual = notEqual;
return this;
}
public QueryBuilder like(Map<String, String> like) {
this.like = like;
return this;
}
public QueryBuilder notLike(Map<String, String> notLike) {
this.notLike = notLike;
return this;
}
public QueryBuilder contain(Map<String, String> contain) {
this.contain = contain;
return this;
}
public QueryBuilder notContain(Map<String, String> notContain) {
this.notContain = notContain;
return this;
}
public QueryBuilder startWith(Map<String, String> startWith) {
this.startWith = startWith;
return this;
}
public QueryBuilder notStartWith(Map<String, String> notStartWith) {
this.notStartWith = notStartWith;
return this;
}
public QueryBuilder endWith(Map<String, String> endWith) {
this.endWith = endWith;
return this;
}
public QueryBuilder notEndWith(Map<String, String> notEndWith) {
this.notEndWith = notEndWith;
return this;
}
public QueryBuilder great(Map<String, ? extends Serializable> great) {
this.great = great;
return this;
}
public QueryBuilder less(Map<String, ? extends Serializable> less) {
this.less = less;
return this;
}
public QueryBuilder greatEqual(Map<String, ? extends Serializable> greatEqual) {
this.greatEqual = greatEqual;
return this;
}
public QueryBuilder lessEqual(Map<String, ? extends Serializable> lessEqual) {
this.lessEqual = lessEqual;
return this;
}
public QueryBuilder inside(Map<String, List<? extends Serializable>> inside) {
this.inside = inside;
return this;
}
public QueryBuilder notInside(Map<String, List<? extends Serializable>> notInside) {
this.notInside = notInside;
return this;
}
public QueryBuilder between(Map<String, Query.Queryable.Between> between) {
this.between = between;
return this;
}
public QueryBuilder notBetween(Map<String, Query.Queryable.Between> 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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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<TestEntity> 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<TestEntity> 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);
}
}
}

View File

@@ -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<TestSoftDeleteEntity> 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<TestSoftDeleteEntity> result = service.list();
assertThat(result).hasSize(1);
assertThat(result.get(0).getName()).isEqualTo("张三");
}
}
}

View File

@@ -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<TestEntity, TestController.SaveItem, TestController.ListItem, TestController.DetailItem> {
public TestController(TestService service) {
super(service);
}
@Override
protected Function<SaveItem, TestEntity> saveItemMapper() {
return item -> new TestEntity(item.name, item.age, item.status, item.salary, item.tags);
}
@Override
protected Function<TestEntity, ListItem> listItemMapper() {
return entity -> new ListItem(entity.getId(), entity.getName(), entity.getAge(), entity.getStatus());
}
@Override
protected Function<TestEntity, DetailItem> 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) {
}
}

View File

@@ -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<TestSoftDeleteEntity, TestSoftDeleteController.SaveItem, TestSoftDeleteController.ListItem, TestSoftDeleteController.DetailItem> {
public TestSoftDeleteController(TestSoftDeleteService service) {
super(service);
}
@Override
protected Function<SaveItem, TestSoftDeleteEntity> saveItemMapper() {
return item -> new TestSoftDeleteEntity(item.name, item.age);
}
@Override
protected Function<TestSoftDeleteEntity, ListItem> listItemMapper() {
return entity -> new ListItem(entity.getId(), entity.getName(), entity.getAge());
}
@Override
protected Function<TestSoftDeleteEntity, DetailItem> 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) {
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.database.integration.entity;
public enum TestStatus {
ACTIVE,
INACTIVE,
DELETED
}

View File

@@ -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<TestEntity> {
}

View File

@@ -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<TestSoftDeleteEntity> {
}

View File

@@ -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<TestEntity> {
public TestService(TestRepository repository) {
super(repository);
}
}

View File

@@ -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<TestSoftDeleteEntity> {
public TestSoftDeleteService(TestSoftDeleteRepository repository) {
super(repository);
}
}

View File

@@ -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