diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb07d12fe..c95b7322f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main, dev ] + branches: [ main ] pull_request: - branches: [ main, dev ] + branches: [ main ] jobs: test: diff --git a/.gitignore b/.gitignore index 1b25cb077..d92c4cedf 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ logs ### Customs ### TODO.* + +### AI Coder ### +/.claude/ diff --git a/.lingma/rules/project_rule.md b/.lingma/rules/project_rule.md new file mode 100644 index 000000000..d9fd2068f --- /dev/null +++ b/.lingma/rules/project_rule.md @@ -0,0 +1,111 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 框架定位 + +Compose Server 是一个现代化、模块化的 Kotlin 企业级服务端开发**框架**,而非脚手架。它通过 Gradle 多模块方式,提供安全、数据库、缓存、对象存储、支付、AI 等企业级能力,支持按需集成到任意 Spring Boot 项目中。 + +## 构建和测试命令 + +这是一个基于 Gradle 的 Kotlin 多模块项目。 + +- `./gradlew build` - 构建整个项目 +- `./gradlew clean` - 清理构建输出 +- `./gradlew publishToMavenLocal` - 发布到本地 Maven 仓库 +- `./gradlew versionCatalogUpdate` - 更新版本目录中的依赖版本 +- `./gradlew test` - 运行所有测试 +- `./gradlew :模块名:test` - 运行特定模块的测试 +- `./gradlew spotlessCheck` - 检查代码格式 +- `./gradlew spotlessApply` - 自动修复代码格式 + +## 项目架构 + +### 模块化结构 + +本框架采用多模块设计,主要模块包括: + +- **shared** - 核心基础组件,包含通用工具类、异常处理、类型定义、统一响应、分页等 +- **meta** - 元数据和注解处理器 +- **rds** - 数据库相关(Jimmer ORM、CRUD、PostgreSQL 扩展、Flyway 迁移) +- **surveillance** - 监控组件 +- **security** - 安全相关(Spring Security、OAuth2、加密解密) +- **oss** - 对象存储(MinIO、阿里云 OSS、华为云 OBS) +- **pay** - 支付模块(微信支付 V3) +- **cacheable** - 多级缓存(Redis、Caffeine) +- **data** - 数据处理(EasyExcel、爬虫、行政区划等) +- **depend** - 特定依赖处理 +- **testtoolkit** - 测试工具包 +- **gradle-plugin** - Gradle 插件 +- **ksp** - Kotlin Symbol Processing +- **sms** - 短信服务(腾讯云短信,短信抽象层) +- **mcp** - AI 能力(LangChain4j、Ollama、智谱 AI) + +> 所有模块均可独立集成,推荐组合见下表。 + +### 推荐模块组合 + +| 使用场景 | 推荐模块组合 | +|----------------|----------------------------------------| +| 基础 Web API | shared + security-spring | +| 数据库操作 | shared + rds-shared + rds-crud | +| 文件存储 | shared + oss-shared + oss-minio | +| 微信支付 | shared + pay | +| 数据导入导出 | shared + data-extract | +| AI 能力 | shared + mcp | + +## 技术栈 + +- **Kotlin** 2.2.x +- **Spring Boot** 3.5.x +- **Jimmer** 0.9.x +- **Gradle** 9.x +- **PostgreSQL**、**Redis**、**Caffeine**、**MinIO**、**阿里云 OSS**、**华为云 OBS** 等 + +## 依赖管理 + +- 统一使用 Gradle Version Catalog(`gradle/libs.versions.toml`)管理依赖版本 +- 所有模块版本、groupId 通过根项目统一管理 +- 推荐通过 `publishToMavenLocal` 集成本地开发版本 + + +## 代码约定 + +- 所有模块使用 `kotlinspring-convention` 插件,集成 Spring Boot 与 Kotlin 规范 +- 包名格式:`io.github.truenine.composeserver.模块名` + +- 代码格式化:使用 Spotless,提交前请运行 `./gradlew spotlessApply` +- 数据库迁移:使用 Flyway,脚本位于 `rds/flyway-migration-数据库类型/src/main/resources/db/migration/`,命名规则 `V版本号__描述.sql` + +## 集成与最佳实践 + +1. **依赖引入** + 在业务项目的 `build.gradle.kts` 中按需添加依赖,例如: + ```kotlin + implementation("io.github.truenine:composeserver-shared:latest") + implementation("io.github.truenine:composeserver-rds-shared:latest") + implementation("io.github.truenine:composeserver-security-spring:latest") + ``` +2. **自动配置** + 启用自动配置注解(如有): + ```kotlin + @SpringBootApplication + @EnableComposeServer + class YourApplication + ``` +3. **统一响应、异常、分页等** + 推荐使用框架内置的统一响应、异常处理、分页等能力,详见 `shared` 模块。 + +4. **测试与发布** + - 修改代码后先格式化,再运行测试,最后构建或发布到本地 Maven 仓库 + - 推荐使用 `./gradlew test`、`./gradlew build`、`./gradlew publishToMavenLocal` + +## 其他说明 + +- 本项目为**框架库**,不包含脚手架或项目初始化功能 +- 所有模块均已发布至 Maven Central,详见 [README.md] 或 [Maven Central](https://central.sonatype.com/search?q=g:io.github.truenine) +- 详细 API、集成示例、变更日志等请参考 [README.md] 和官方文档 + +--- + +如需为本项目贡献代码或扩展模块,请遵循上述规范和最佳实践。 diff --git a/CLAUDE.md b/CLAUDE.md index 7a9b8081f..69fc4c617 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 框架定位 -Compose Server 是一个现代化、模块化的 Kotlin 企业级服务端开发**框架**,而非脚手架。它通过 Gradle 多模块方式,提供安全、数据库、缓存、对象存储、支付、AI 等企业级能力,支持按需集成到任意 Spring Boot 项目中。 +Compose Server 是一个现代化、模块化的 Kotlin 企业级服务端开发**框架**,而非脚手架。它通过 Gradle 多模块方式,提供安全、数据库、缓存、对象存储、支付、AI 等企业级能力,支持按需集成到任意 +Spring Boot 项目中。 ## 构建和测试命令 @@ -45,14 +46,14 @@ Compose Server 是一个现代化、模块化的 Kotlin 企业级服务端开发 ### 推荐模块组合 -| 使用场景 | 推荐模块组合 | -|----------------|----------------------------------------| -| 基础 Web API | shared + security-spring | -| 数据库操作 | shared + rds-shared + rds-crud | -| 文件存储 | shared + oss-shared + oss-minio | -| 微信支付 | shared + pay | -| 数据导入导出 | shared + data-extract | -| AI 能力 | shared + mcp | +| 使用场景 | 推荐模块组合 | +|------------|---------------------------------| +| 基础 Web API | shared + security-spring | +| 数据库操作 | shared + rds-shared + rds-crud | +| 文件存储 | shared + oss-shared + oss-minio | +| 微信支付 | shared + pay | +| 数据导入导出 | shared + data-extract | +| AI 能力 | shared + mcp | ## 技术栈 @@ -68,7 +69,6 @@ Compose Server 是一个现代化、模块化的 Kotlin 企业级服务端开发 - 所有模块版本、groupId 通过根项目统一管理 - 推荐通过 `publishToMavenLocal` 集成本地开发版本 - ## 代码约定 - 所有模块使用 `kotlinspring-convention` 插件,集成 Spring Boot 与 Kotlin 规范 @@ -96,9 +96,10 @@ Compose Server 是一个现代化、模块化的 Kotlin 企业级服务端开发 3. **统一响应、异常、分页等** 推荐使用框架内置的统一响应、异常处理、分页等能力,详见 `shared` 模块。 -4. **测试与发布** - - 修改代码后先格式化,再运行测试,最后构建或发布到本地 Maven 仓库 - - 推荐使用 `./gradlew test`、`./gradlew build`、`./gradlew publishToMavenLocal` +4. **测试与发布** + +- 修改代码后先格式化,再运行测试,最后构建或发布到本地 Maven 仓库 +- 推荐使用 `./gradlew test`、`./gradlew build`、`./gradlew publishToMavenLocal` ## 其他说明 @@ -106,6 +107,335 @@ Compose Server 是一个现代化、模块化的 Kotlin 企业级服务端开发 - 所有模块均已发布至 Maven Central,详见 [README.md] 或 [Maven Central](https://central.sonatype.com/search?q=g:io.github.truenine) - 详细 API、集成示例、变更日志等请参考 [README.md] 和官方文档 ---- +## Git 提交规范 + +### 提交消息格式 + +本项目采用表情符号 + 英文 Conventional Commits 格式,支持详细功能列表: + +### 基础格式 + +``` +emoji [scope] description +``` + +### 详细列表格式(可选) + +``` +emoji [scope] main feature description + +- emoji specific change 1 +- emoji specific change 2 +- emoji specific change 3 +``` + +**格式说明:** + +- `emoji`:从下表中选择合适的表情符号 +- `[scope]`:模块或功能范围,用方括号包围 +- `description`:简洁的功能描述(支持中英文) +- **详细列表**:当改动较多时,可添加表情符号格式的具体变更列表 + +**使用原则:** + +- 🎯 **简单改动**:仅使用基础格式 +- 📋 **复杂改动**:使用详细列表格式,每项变更前加对应表情符号 +- 🌐 **语言选择**:描述支持中英文,根据团队习惯选择 + +**基础格式示例:** + +``` +✨ [shared] add unified exception handling +🐛 [rds] fix connection pool issue +♻️ [security] refactor JWT validation +``` + +**详细列表格式示例:** + +``` +✨ [shared] 日志功能 + +- 🚑 紧急修复日志级别 +- 🐛 依赖倒转缺失问题 +- 💄 日志框架进行 inline 处理 + +🔧 [buildlogic] 构建系统优化 + +- ⚡ 提升编译性能 +- 🔧 更新 Gradle 配置 +- 📦 优化依赖管理 +- 🚨 修复 Spotless 检查 + +♻️ [security] 安全模块重构 + +- 🏗️ 重构认证流程 +- 🔐 增强权限控制 +- 🛡️ 修复安全漏洞 +- 📈 添加安全监控 +``` + +### 表情符号参考表 + +| 表情符号 | 类型 | 描述 | 使用场景 | +|------|-----------|----------|----------------------| +| 🎉 | feat | 新功能/重大更新 | 添加新功能、重大特性、项目初始化 | +| ✨ | feat | 新功能/增强 | 添加新特性、功能增强、文档更新 | +| 🐛 | fix | Bug 修复 | 修复错误、解决问题 | +| 🔧 | config | 配置修改 | 修改配置文件、CI/CD 配置、构建配置 | +| 📝 | docs | 文档更新 | 更新文档、README、注释 | +| 🎨 | style | 代码风格/格式化 | 代码格式化、样式调整、结构优化 | +| ♻️ | refactor | 重构 | 代码重构、包结构调整、命名空间变更 | +| ⚡ | perf | 性能优化 | 提升性能、优化算法 | +| 🔥 | remove | 删除代码/文件 | 删除无用代码、移除功能、清理文件 | +| 🧪 | test | 测试相关 | 添加测试、修复测试、测试配置 | +| 👷 | ci | CI/CD 相关 | 持续集成、构建脚本、部署配置 | +| 📦 | build | 构建系统 | 依赖管理、构建配置、打包相关 | +| ⬆️ | upgrade | 升级依赖版本 | 升级依赖库版本、更新第三方库 | +| ⬇️ | downgrade | 降级依赖版本 | 降级依赖库版本、回退版本 | +| 🚀 | release | 发布版本 | 版本发布、标签创建 | +| 🔀 | merge | 合并分支 | 分支合并、冲突解决 | +| 🤖 | ai | AI 工具配置 | AI 助手配置、自动化工具 | +| 💄 | optimize | 优化/性能优化 | 性能优化、代码优化、算法改进 | +| 🌐 | network | 网络相关 | 网络配置、API 调用、远程服务 | +| 🔐 | security | 安全/防御编程 | 安全修复、权限控制、格式校验、防御编程 | +| 🚑 | hotfix | 紧急修复 | 紧急修复、补救措施、临时解决方案 | +| 📈 | analytics | 分析/监控 | 性能监控、数据分析、日志记录 | +| 🍱 | assets | 资源文件 | 图片、字体、静态资源 | +| 🚨 | lint | 代码检查 | 修复 linting 警告、代码质量 | +| 💡 | comment | 注释 | 添加或更新注释、文档字符串 | +| 🔊 | log | 日志 | 添加日志、调试信息 | +| 🔇 | log | 移除日志 | 删除日志、静默输出 | + +### 功能类型详细说明 + +#### 🎉 重大功能 (Major Features) + +- 🚀 项目初始化和重大里程碑 +- 📦 新模块或子系统的引入 +- 🏗️ 架构重大变更 + +#### ✨ 新功能 (Features) + +- 🔌 新增 API 接口 +- 🛠️ 新增工具类或组件 +- ⚡ 功能增强和改进 + +#### 🐛 Bug 修复 (Bug Fixes) + +- 🔨 修复功能错误 +- 🔄 解决兼容性问题 +- 🛡️ 修复安全漏洞 + +#### ♻️ 重构 (Refactoring) + +- 🏗️ 代码结构优化 +- 📁 包名或命名空间调整 +- 🎯 设计模式应用 + +#### 🔧 配置 (Configuration) + +- ⚙️ Gradle 构建配置 +- 🔄 CI/CD 流水线配置 +- 📋 环境配置文件 + +#### 📝 文档 (Documentation) + +- 📖 README 更新 +- 📚 API 文档 +- 💬 代码注释完善 + +#### 💄 优化 (Optimization) + +- ⚡ 性能优化 +- 🔧 代码优化 +- 📈 算法改进 + +#### 🌐 网络 (Network) + +- 🔌 API 接口调用 +- 🌍 远程服务集成 +- 📡 网络配置 + +#### 🔐 安全/防御编程 (Security & Defensive Programming) + +- 🛡️ 安全修复 +- 🔒 权限控制 +- ✅ 格式校验 +- 🛠️ 防御编程 + +#### 🚑 紧急修复 (Hotfix) + +- 🔥 紧急修复 +- 🩹 补救措施 +- ⚡ 临时解决方案 + +### Scope 范围说明 + +使用方括号 `[]` 包围 scope,从以下类别中选择合适的范围: + +#### 🏗️ 模块名称 (Module Names) + +``` +[shared] [rds] [security] [oss] +[pay] [cacheable] [data] [testtoolkit] +[surveillance][meta] [depend] [gradle-plugin] +[ksp] [sms] [mcp] +``` + +#### 🎯 功能领域 (Functional Areas) + +``` +[auth] [api] [db] [cache] +[config] [test] [build] [ci] +[docs] [core] [utils] [validation] +[logging] [monitoring] [migration] [integration] +``` + +#### 🔧 特定组件 (Specific Components) + +``` +[gradle] [buildlogic] [deps] [publish] +[release] [claude] [cursor] [spotless] +[flyway] [jimmer] [spring] [kotlin] +[docker] [k8s] [helm] [terraform] +``` + +#### 🌐 技术栈 (Technology Stack) + +``` +[postgresql] [redis] [minio] [oauth2] +[jwt] [wechat] [aliyun] [huawei] +[tencent] [ollama] [langchain] [ai] +``` + +#### 📁 文件类型 (File Types) + +``` +[readme] [changelog] [license] [gitignore] +[dockerfile] [compose] [makefile] [scripts] +``` + +### 提交规范要求 + +1. **表情符号使用**:每个提交必须以合适的表情符号开头,详细列表中每项也需要对应表情符号 +2. **消息长度**:标题行不超过 72 个字符,详细列表每行不超过 50 个字符 +3. **语言规范**:描述支持中英文,保持团队一致性 +4. **列表使用**: + +- 简单改动(1-2个变更):使用基础格式 +- 复杂改动(3个以上变更):推荐使用详细列表格式 +- 列表项按重要性或逻辑顺序排列 + +5. **原子性**:每个提交应该是一个逻辑上完整的变更单元 +6. **测试验证**:提交前确保代码通过测试和格式检查 + +### 提交示例 + +#### 基础格式示例 + +```bash +# 简单新功能 +git commit -m "✨ [shared] add unified exception handling" +git commit -m "🐛 [rds] fix connection pool issue" +git commit -m "♻️ [security] refactor JWT validation" +git commit -m "💄 [cache] optimize Redis performance" +git commit -m "🌐 [api] integrate third-party service" +git commit -m "🔐 [validation] add input format validation" +git commit -m "🚑 [auth] emergency fix for login issue" +git commit -m "📝 [docs] update API documentation" +``` + +#### 详细列表格式示例 + +```bash +# 复杂功能开发 +git commit -m "✨ [shared] 日志功能 + +- 🚑 紧急修复日志级别 +- 🐛 依赖倒转缺失问题 +- 💄 日志框架进行 inline 处理" + +# 性能优化 +git commit -m "💄 [cache] 缓存系统优化 + +- ⚡ 提升 Redis 连接性能 +- 🔧 优化缓存策略 +- 📈 改进缓存命中率 +- 🚨 修复内存泄漏问题" + +# 网络集成 +git commit -m "🌐 [api] 第三方服务集成 + +- 🔌 集成微信支付 API +- 📡 配置网络代理 +- 🛠️ 添加重试机制 +- 📊 网络监控埋点" + +# 安全防御编程 +git commit -m "🔐 [security] 安全防护增强 + +- 🛡️ 修复 SQL 注入漏洞 +- ✅ 添加输入格式校验 +- 🔒 增强权限控制 +- 🛠️ 防御性编程实践" + +# 多模块测试更新 +git commit -m "🧪 [testtoolkit] 测试框架升级 + +- 🔧 更新 JUnit 配置 +- 📦 集成 Testcontainers +- ⚡ 优化测试性能 +- 📊 添加覆盖率报告" + +# 版本发布和依赖管理 +git commit -m "🚀 [release] version 0.0.5" +git commit -m "⬆️ [deps] upgrade Spring Boot to 3.5.0" +git commit -m "⬇️ [deps] downgrade Kotlin for compatibility" + +# 清理和删除 +git commit -m "🔥 [buildlogic] remove unused imports" +git commit -m "🔥 [EmptyDefault] remove related definitions" + +# AI 工具配置 +git commit -m "🤖 [claude] add AI configuration" +git commit -m "🤖 [lingma] add Tongyi Lingma support" +``` + +#### 混合格式示例 + +```bash +# 大版本发布(包含多项重要变更) +git commit -m "🚀 [release] version 1.0.0 + +- ✨ 新增统一异常处理 +- 🔐 增强安全防护机制 +- 💄 优化数据库连接池性能 +- 🌐 集成第三方支付服务 +- 📝 完善 API 文档 +- 🧪 添加集成测试套件" + +# 紧急修复(包含多个相关问题) +git commit -m "🚑 [security] 紧急安全修复 + +- 🛡️ 修复 JWT 验证漏洞 +- 🔐 增强输入格式校验 +- ✅ 添加防御性编程检查 +- 📈 添加安全审计日志 +- 🚨 更新安全检查规则" + +# 性能优化专项(多个优化措施) +git commit -m "💄 [performance] 系统性能优化 + +- ⚡ 优化数据库查询性能 +- 🔧 改进缓存算法 +- 📈 提升 API 响应速度 +- 🛠️ 优化内存使用" + +# 网络功能增强 +git commit -m "🌐 [network] 网络功能增强 -如需为本项目贡献代码或扩展模块,请遵循上述规范和最佳实践。 +- 🔌 集成新的 API 服务 +- 📡 优化网络连接配置 +- 🛠️ 添加网络异常处理 +- 📊 网络性能监控" +``` diff --git a/README.md b/README.md index 8f57c2919..fadfc01e1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.2.0-blue.svg)](https://kotlinlang.org/) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.3-brightgreen.svg)](https://spring.io/projects/spring-boot) -[![Jimmer](https://img.shields.io/badge/Jimmer-0.9.97-orange.svg)](https://github.com/babyfish-ct/jimmer) +[![Jimmer](https://img.shields.io/badge/Jimmer-0.9.99-orange.svg)](https://github.com/babyfish-ct/jimmer) [![Maven Central](https://img.shields.io/maven-central/v/io.github.truenine/composeserver-shared.svg)](https://central.sonatype.com/search?q=g:io.github.truenine) [![License](https://img.shields.io/badge/License-LGPL%202.1-blue.svg)](LICENSE) diff --git a/build-logic/src/main/kotlin/buildlogic.jacoco-conventions.gradle.kts b/build-logic/src/main/kotlin/buildlogic.jacoco-conventions.gradle.kts index f98fb7597..22faca596 100644 --- a/build-logic/src/main/kotlin/buildlogic.jacoco-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/buildlogic.jacoco-conventions.gradle.kts @@ -15,7 +15,7 @@ tasks.withType().configureEach { mustRunAfter(tasks.withType()) reports { - xml.required.set(true) + xml.required.set(false) html.required.set(true) } val mainSrc = listOf("src/main/java", "src/main/kotlin") diff --git a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressService.kt b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressService.kt index 7dfb2c38e..736a0cb1e 100644 --- a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressService.kt +++ b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressService.kt @@ -1,6 +1,6 @@ package io.github.truenine.composeserver.data.extract.service -import io.github.truenine.composeserver.SysLogger +import io.github.truenine.composeserver.SystemLogger import io.github.truenine.composeserver.consts.IRegexes import io.github.truenine.composeserver.data.extract.domain.CnDistrictCode import io.github.truenine.composeserver.nonText @@ -70,7 +70,7 @@ interface ILazyAddressService { // --- 服务属性 --- /** 提供的日志记录器 (可选) */ - val logger: SysLogger? + val logger: SystemLogger? get() = null /** diff --git a/depend/jackson/src/test/kotlin/io/github/truenine/composeserver/depend/jackson/DataClassSerializerTest.kt b/depend/jackson/src/test/kotlin/io/github/truenine/composeserver/depend/jackson/DataClassSerializerTest.kt index b58c23afb..5cabbc677 100644 --- a/depend/jackson/src/test/kotlin/io/github/truenine/composeserver/depend/jackson/DataClassSerializerTest.kt +++ b/depend/jackson/src/test/kotlin/io/github/truenine/composeserver/depend/jackson/DataClassSerializerTest.kt @@ -5,15 +5,12 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import io.github.truenine.composeserver.datetime import io.github.truenine.composeserver.depend.jackson.autoconfig.JacksonAutoConfiguration -import io.github.truenine.composeserver.testtoolkit.annotations.SpringServletTest import io.github.truenine.composeserver.testtoolkit.log import jakarta.annotation.Resource import kotlin.test.Test import kotlin.test.assertTrue import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.post +import org.springframework.boot.test.context.SpringBootTest data class A(val a: String, val b: String) @@ -21,22 +18,11 @@ class B { lateinit var s: String } -@SpringServletTest +@SpringBootTest class DataClassSerializerTest { - @Resource lateinit var mockMvc: MockMvc @Resource lateinit var mapper: ObjectMapper - @Test - fun `test web request`() { - mockMvc - .post("/v1/a") { - content = A("a", "b") - contentType = MediaType.APPLICATION_JSON - } - .andDo { this.print() } - } - @Test fun `test serialize class with late init var`() { val b = B() @@ -88,6 +74,83 @@ class DataClassSerializerTest { log.info("obj c: {}", obj) log.info("obj c class: {}", obj::class) } + + @Test + fun `test serialize null values`() { + val a = InterFace.InternalClass("a", "b", null, null) + val json = mapper.writeValueAsString(a) + log.info("null values json: {}", json) + val obj = mapper.readValue(json) + log.info("null values obj: {}", obj) + } + + @Test + fun `test serialize empty strings`() { + val a = A("", "") + val json = mapper.writeValueAsString(a) + log.info("empty strings json: {}", json) + val obj = mapper.readValue(json) + log.info("empty strings obj: {}", obj) + } + + @Test + fun `test serialize with special characters`() { + val a = A("hello\nworld", "test\"quote'apostrophe") + val json = mapper.writeValueAsString(a) + log.info("special chars json: {}", json) + val obj = mapper.readValue(json) + log.info("special chars obj: {}", obj) + } + + @Test + fun `test serialize unicode characters`() { + val a = A("测试中文", "🎉emoji") + val json = mapper.writeValueAsString(a) + log.info("unicode json: {}", json) + val obj = mapper.readValue(json) + log.info("unicode obj: {}", obj) + } + + @Test + fun `test serialize large strings`() { + val largeString = "x".repeat(10000) + val a = A(largeString, "normal") + val json = mapper.writeValueAsString(a) + log.info("large string json length: {}", json.length) + val obj = mapper.readValue(json) + log.info("large string obj a length: {}", obj.a.length) + } + + @Test + fun `test serialize class with uninitialized lateinit var should fail`() { + val b = B() + try { + val json = mapper.writeValueAsString(b) + log.error("Unexpected success: {}", json) + } catch (e: Exception) { + log.info("Expected exception for uninitialized lateinit: {}", e.javaClass.simpleName) + } + } + + @Test + fun `test deserialize malformed json should fail`() { + try { + val obj = mapper.readValue("{\"a\":\"test\",\"b\":}") + log.error("Unexpected success: {}", obj) + } catch (e: Exception) { + log.info("Expected exception for malformed JSON: {}", e.javaClass.simpleName) + } + } + + @Test + fun `test deserialize missing required field should fail`() { + try { + val obj = mapper.readValue("{\"a\":\"test\"}") + log.error("Unexpected success: {}", obj) + } catch (e: Exception) { + log.info("Expected exception for missing field: {}", e.javaClass.simpleName) + } + } } interface InterFace { diff --git a/depend/jackson/src/test/kotlin/io/github/truenine/composeserver/depend/jackson/SailedClassSerializerTest.kt b/depend/jackson/src/test/kotlin/io/github/truenine/composeserver/depend/jackson/SailedClassSerializerTest.kt index 981c1369d..c03eb920f 100644 --- a/depend/jackson/src/test/kotlin/io/github/truenine/composeserver/depend/jackson/SailedClassSerializerTest.kt +++ b/depend/jackson/src/test/kotlin/io/github/truenine/composeserver/depend/jackson/SailedClassSerializerTest.kt @@ -2,7 +2,6 @@ package io.github.truenine.composeserver.depend.jackson import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.exc.InvalidDefinitionException -import io.github.truenine.composeserver.i16 import kotlin.test.Test import kotlin.test.assertFailsWith import kotlin.test.assertNotNull @@ -13,7 +12,7 @@ class SailedClassSerializerTest { val defaultMapper: ObjectMapper = ObjectMapper() sealed class IpAddress { - data class V4(val v1: i16, val v2: i16, val v3: i16, val v4: i16) : IpAddress() + data class V4(val v1: Long, val v2: Long, val v3: Long, val v4: Long) : IpAddress() data class V6(val ip: String) : IpAddress() } diff --git a/depend/paho/src/main/kotlin/io/github/truenine/composeserver/depend/paho/properties/SingleMqttProperties.kt b/depend/paho/src/main/kotlin/io/github/truenine/composeserver/depend/paho/properties/SingleMqttProperties.kt index 0d3a3e2eb..7c71d7b33 100644 --- a/depend/paho/src/main/kotlin/io/github/truenine/composeserver/depend/paho/properties/SingleMqttProperties.kt +++ b/depend/paho/src/main/kotlin/io/github/truenine/composeserver/depend/paho/properties/SingleMqttProperties.kt @@ -1,7 +1,5 @@ package io.github.truenine.composeserver.depend.paho.properties -import io.github.truenine.composeserver.i32 -import io.github.truenine.composeserver.i64 import java.util.* import org.springframework.boot.context.properties.ConfigurationProperties @@ -11,15 +9,15 @@ private const val PREFIX = "compose.depend.paho" data class SingleMqttProperties( /** schema = tcp:// */ var url: String? = null, - var port: i32 = 1883, + var port: Int = 1883, var clientId: String = UUID.randomUUID().toString(), var topics: MutableList = ArrayList(), var username: String = "", var password: String = "", - var connectTimeoutSecond: i32 = 10, - var completionTimeout: i64 = 1000L * 5L, - var qos: i32 = 0, - var keepAliveSecond: i32 = 10, + var connectTimeoutSecond: Int = 10, + var completionTimeout: Long = 1000L * 5L, + var qos: Int = 0, + var keepAliveSecond: Int = 10, ) { val fullUrl: String get() = "$url:$port" diff --git a/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/IReadableAttachmentFns.kt b/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/IReadableAttachmentFns.kt index 04246cab7..f0c837905 100644 --- a/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/IReadableAttachmentFns.kt +++ b/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/IReadableAttachmentFns.kt @@ -5,7 +5,7 @@ import org.springframework.web.multipart.MultipartFile fun MultipartFile.toReadableAttachment(): IReadableAttachment { return IReadableAttachment.DefaultReadableAttachment( - name = this.originalFilename ?: this.name, + name = this.originalFilename?.takeIf { it.isNotBlank() } ?: this.name, mimeType = this.contentType, size = this.size, bytes = { this.bytes }, diff --git a/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/SpringResponseEntityFns.kt b/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/SpringResponseEntityFns.kt index f42cbb14b..9d860bd0e 100644 --- a/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/SpringResponseEntityFns.kt +++ b/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/SpringResponseEntityFns.kt @@ -1,24 +1,22 @@ package io.github.truenine.composeserver.depend.servlet import io.github.truenine.composeserver.datetime -import io.github.truenine.composeserver.i32 -import io.github.truenine.composeserver.i64 import io.github.truenine.composeserver.toMillis import org.springframework.http.MediaType import org.springframework.http.ResponseEntity data class ResponseEntityScope( - internal var status: i32 = 200, + internal var status: Int = 200, internal var contentType: String? = null, - internal var lastModifier: i64? = null, - internal var contentLength: i64? = null, + internal var lastModifier: Long? = null, + internal var contentLength: Long? = null, ) { fun type(contentType: String?) { this.contentType = contentType } - fun size(len: () -> i64?) { + fun size(len: () -> Long?) { contentLength = len() } diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/HttpServletRequestFnsTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/HttpServletRequestFnsTest.kt new file mode 100644 index 000000000..c7c3c52a6 --- /dev/null +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/HttpServletRequestFnsTest.kt @@ -0,0 +1,88 @@ +package io.github.truenine.composeserver.depend.servlet + +import io.github.truenine.composeserver.consts.IHeaders +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.springframework.mock.web.MockHttpServletRequest + +class HttpServletRequestFnsTest { + + @Test + fun `headerMap 应正确转换请求头为 Map`() { + val request = MockHttpServletRequest() + request.addHeader("Content-Type", "application/json") + request.addHeader("Accept", "application/json") + request.addHeader("User-Agent", "Test-Agent") + + val headerMap = request.headerMap + + assertEquals("application/json", headerMap["Content-Type"]) + assertEquals("application/json", headerMap["Accept"]) + assertEquals("Test-Agent", headerMap["User-Agent"]) + assertEquals(3, headerMap.size) + } + + @Test + fun `headerMap 空请求头应返回空 Map`() { + val request = MockHttpServletRequest() + val headerMap = request.headerMap + assertTrue(headerMap.isEmpty()) + } + + @Test + fun `deviceId 应正确从请求头获取设备 ID`() { + val request = MockHttpServletRequest() + request.addHeader(IHeaders.X_DEVICE_ID, "test-device-123") + + val deviceId = request.deviceId + + assertEquals("test-device-123", deviceId) + } + + @Test + fun `remoteRequestIp 应获取请求 IP 地址`() { + val request = MockHttpServletRequest() + request.remoteAddr = "192.168.1.100" + + val remoteIp = request.remoteRequestIp + + assertNotNull(remoteIp) + // IP 地址获取逻辑在 IInterAddr.getRequestIpAddress 中实现 + } + + @Test + fun `remoteRequestIp 应处理代理头部获取真实 IP`() { + val request = MockHttpServletRequest() + request.addHeader("X-Forwarded-For", "203.0.113.195") + request.addHeader("X-Real-IP", "203.0.113.195") + request.remoteAddr = "10.0.0.1" + + val remoteIp = request.remoteRequestIp + + assertNotNull(remoteIp) + // 具体的 IP 解析逻辑取决于 IInterAddr.getRequestIpAddress 的实现 + } + + @Test + fun `扩展属性应在 MockHttpServletRequest 中正常工作`() { + val request = MockHttpServletRequest() + request.addHeader("Content-Type", "application/json") + request.addHeader("Accept", "application/json") + request.addHeader(IHeaders.X_DEVICE_ID, "test-device") + request.remoteAddr = "192.168.1.100" + + // 测试扩展属性 + val headerMap = request.headerMap + val deviceId = request.deviceId + val remoteIp = request.remoteRequestIp + + // 验证扩展属性工作正常 + assertNotNull(headerMap) + assertEquals("test-device", deviceId) + assertNotNull(remoteIp) + assertTrue(headerMap.containsKey("Content-Type")) + assertTrue(headerMap.containsKey("Accept")) + } +} diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/HttpServletResponseFnsTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/HttpServletResponseFnsTest.kt new file mode 100644 index 000000000..3c7f01aae --- /dev/null +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/HttpServletResponseFnsTest.kt @@ -0,0 +1,158 @@ +package io.github.truenine.composeserver.depend.servlet + +import io.github.truenine.composeserver.consts.IHeaders +import io.github.truenine.composeserver.typing.MimeTypes +import java.nio.charset.StandardCharsets +import java.util.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.springframework.mock.web.MockHttpServletResponse + +class HttpServletResponseFnsTest { + + @Test + fun `headerMap 应正确转换响应头为 Map`() { + val response = MockHttpServletResponse() + response.setHeader("Content-Type", "application/json") + response.setHeader("Cache-Control", "no-cache") + response.setHeader("X-Custom-Header", "custom-value") + + val headerMap = response.headerMap + + assertEquals("application/json", headerMap["Content-Type"]) + assertEquals("no-cache", headerMap["Cache-Control"]) + assertEquals("custom-value", headerMap["X-Custom-Header"]) + assertEquals(3, headerMap.size) + } + + @Test + fun `headerMap 空响应头应返回空 Map`() { + val response = MockHttpServletResponse() + val headerMap = response.headerMap + assertTrue(headerMap.isEmpty()) + } + + @Test + fun `useResponse 应正确设置响应属性`() { + val response = MockHttpServletResponse() + + val result = + response.useResponse(contentType = MimeTypes.JSON, charset = StandardCharsets.UTF_8, locale = Locale.US) { resp -> + resp.setHeader("X-Test", "test-value") + resp + } + + assertTrue(result.contentType?.contains("application/json") == true) + assertEquals(StandardCharsets.UTF_8.displayName(), result.characterEncoding) + assertEquals(Locale.US, result.locale) + assertEquals("test-value", result.getHeader("X-Test")) + } + + @Test + fun `useResponse 使用默认参数应设置正确值`() { + val response = MockHttpServletResponse() + + val result = response.useResponse { it } + + assertTrue(result.contentType?.contains("application/octet-stream") == true) + assertEquals(Charsets.UTF_8.displayName(), result.characterEncoding) + assertEquals(Locale.CHINA, result.locale) + } + + @Test + fun `useSse 应设置 SSE 相关响应头`() { + val response = MockHttpServletResponse() + + val result = + response.useSse(charset = StandardCharsets.UTF_8, locale = Locale.US) { resp -> + resp.setHeader("X-SSE-Test", "sse-value") + resp + } + + assertTrue(result.contentType?.contains("text/event-stream") == true) + assertEquals(StandardCharsets.UTF_8.displayName(), result.characterEncoding) + assertEquals(Locale.US, result.locale) + assertEquals("sse-value", result.getHeader("X-SSE-Test")) + } + + @Test + fun `useSse 使用默认参数应设置 SSE 类型`() { + val response = MockHttpServletResponse() + + val result = response.useSse { it } + + assertTrue(result.contentType?.contains("text/event-stream") == true) + assertEquals(Charsets.UTF_8.displayName(), result.characterEncoding) + assertEquals(Locale.CHINA, result.locale) + } + + @Test + fun `withDownload 应设置下载相关响应头`() { + val response = MockHttpServletResponse() + val testData = "test file content".toByteArray() + + response.withDownload(fileName = "test.txt", contentType = MimeTypes.TEXT, charset = StandardCharsets.UTF_8) { outputStream -> + outputStream.write(testData) + } + + // 验证 Content-Disposition 头设置正确 + val contentDisposition = response.getHeader(IHeaders.CONTENT_DISPOSITION) + assertNotNull(contentDisposition) + assertTrue(contentDisposition.contains("attachment")) + + // 验证 Content-Type 头设置正确 + assertTrue(response.getHeader(IHeaders.CONTENT_TYPE)?.contains("text/plain") == true) + assertEquals(StandardCharsets.UTF_8.displayName(), response.characterEncoding) + + // 验证输出内容 + assertEquals(testData.toString(StandardCharsets.UTF_8), response.contentAsString) + } + + @Test + fun `withDownload 使用默认参数应设置正确值`() { + val response = MockHttpServletResponse() + + response.withDownload(fileName = "default.bin", closeBlock = null) + + val contentDisposition = response.getHeader(IHeaders.CONTENT_DISPOSITION) + assertNotNull(contentDisposition) + assertTrue(contentDisposition.contains("attachment")) + + assertTrue(response.getHeader(IHeaders.CONTENT_TYPE)?.contains("application/octet-stream") == true) + assertEquals(Charsets.UTF_8.displayName(), response.characterEncoding) + } + + @Test + fun `withDownload 处理中文文件名应正确编码`() { + val response = MockHttpServletResponse() + + response.withDownload(fileName = "测试文件.txt", contentType = MimeTypes.TEXT, charset = StandardCharsets.UTF_8, closeBlock = null) + + val contentDisposition = response.getHeader(IHeaders.CONTENT_DISPOSITION) + assertNotNull(contentDisposition) + assertTrue(contentDisposition.contains("attachment")) + // 文件名编码取决于 IHeaders.downloadDisposition 的实现 + } + + @Test + fun `withDownload 空文件名应正确处理`() { + val response = MockHttpServletResponse() + + response.withDownload(fileName = "", closeBlock = null) + + val contentDisposition = response.getHeader(IHeaders.CONTENT_DISPOSITION) + assertNotNull(contentDisposition) + } + + @Test + fun `withDownload 处理大文件应正确写入`() { + val response = MockHttpServletResponse() + val largeData = ByteArray(1024) { it.toByte() } + + response.withDownload(fileName = "large.bin", contentType = MimeTypes.BINARY) { outputStream -> outputStream.write(largeData) } + + assertEquals(largeData.size, response.contentAsByteArray.size) + } +} diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/IReadableAttachmentFnsTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/IReadableAttachmentFnsTest.kt new file mode 100644 index 000000000..37251d549 --- /dev/null +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/IReadableAttachmentFnsTest.kt @@ -0,0 +1,159 @@ +package io.github.truenine.composeserver.depend.servlet + +import io.github.truenine.composeserver.domain.IReadableAttachment +import java.nio.charset.StandardCharsets +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.springframework.mock.web.MockMultipartFile + +class IReadableAttachmentFnsTest { + + @Test + fun `toReadableAttachment 应正确转换普通文件`() { + val content = "test file content".toByteArray(StandardCharsets.UTF_8) + val multipartFile = MockMultipartFile("testFile", "test.txt", "text/plain", content) + + val attachment = multipartFile.toReadableAttachment() + + assertEquals("test.txt", attachment.name) + assertEquals("text/plain", attachment.mimeType) + assertEquals(content.size.toLong(), attachment.size) + assertEquals(content.contentToString(), attachment.bytes?.invoke()?.contentToString()) + assertFalse(attachment.empty) + } + + @Test + fun `toReadableAttachment 应处理空文件`() { + val multipartFile = MockMultipartFile("emptyFile", "empty.txt", "text/plain", ByteArray(0)) + + val attachment = multipartFile.toReadableAttachment() + + assertEquals("empty.txt", attachment.name) + assertEquals("text/plain", attachment.mimeType) + assertEquals(0L, attachment.size) + assertEquals(0, attachment.bytes?.invoke()?.size ?: 0) + assertTrue(attachment.empty) + } + + @Test + fun `toReadableAttachment 应处理无原始文件名的情况`() { + val content = "no original filename".toByteArray() + val multipartFile = + MockMultipartFile( + "fieldName", + null, // 无原始文件名 + "application/octet-stream", + content, + ) + + val attachment = multipartFile.toReadableAttachment() + + // 修复后的转换逻辑:this.originalFilename?.takeIf { it.isNotBlank() } ?: this.name + // 当 originalFilename 为空字符串时,应该 fallback 到 name + assertEquals("fieldName", attachment.name) // 现在应该使用 name + assertEquals("application/octet-stream", attachment.mimeType) + assertEquals(content.size.toLong(), attachment.size) + assertFalse(attachment.empty) + } + + @Test + fun `toReadableAttachment 应处理 null 内容类型`() { + val content = "null content type".toByteArray() + val multipartFile = + MockMultipartFile( + "testFile", + "test.bin", + null, // null 内容类型 + content, + ) + + val attachment = multipartFile.toReadableAttachment() + + assertEquals("test.bin", attachment.name) + assertEquals(null, attachment.mimeType) + assertEquals(content.size.toLong(), attachment.size) + assertFalse(attachment.empty) + } + + @Test + fun `toReadableAttachment 应提供可用的 InputStream`() { + val content = "stream test content".toByteArray(StandardCharsets.UTF_8) + val multipartFile = MockMultipartFile("streamFile", "stream.txt", "text/plain", content) + + val attachment = multipartFile.toReadableAttachment() + + attachment.inputStream?.use { inputStream -> + val readContent = inputStream.readAllBytes() + assertEquals(content.contentToString(), readContent.contentToString()) + } + } + + @Test + fun `toReadableAttachment 字节函数应返回相同内容`() { + val content = "bytes test content".toByteArray(StandardCharsets.UTF_8) + val multipartFile = MockMultipartFile("bytesFile", "bytes.txt", "text/plain", content) + + val attachment = multipartFile.toReadableAttachment() + + val bytes1 = attachment.bytes?.invoke() + val bytes2 = attachment.bytes?.invoke() + + assertEquals(content.contentToString(), bytes1?.contentToString()) + assertEquals(content.contentToString(), bytes2?.contentToString()) + assertEquals(bytes1?.contentToString(), bytes2?.contentToString()) + } + + @Test + fun `toReadableAttachment 应正确处理二进制文件`() { + val binaryContent = ByteArray(256) { it.toByte() } + val multipartFile = MockMultipartFile("binaryFile", "binary.bin", "application/octet-stream", binaryContent) + + val attachment = multipartFile.toReadableAttachment() + + assertEquals("binary.bin", attachment.name) + assertEquals("application/octet-stream", attachment.mimeType) + assertEquals(256L, attachment.size) + assertEquals(binaryContent.contentToString(), attachment.bytes?.invoke()?.contentToString()) + assertFalse(attachment.empty) + } + + @Test + fun `toReadableAttachment 应正确处理大文件`() { + val largeContent = ByteArray(10240) { (it % 256).toByte() } + val multipartFile = MockMultipartFile("largeFile", "large.bin", "application/octet-stream", largeContent) + + val attachment = multipartFile.toReadableAttachment() + + assertEquals("large.bin", attachment.name) + assertEquals("application/octet-stream", attachment.mimeType) + assertEquals(10240L, attachment.size) + assertEquals(largeContent.size, attachment.bytes?.invoke()?.size ?: 0) + assertFalse(attachment.empty) + } + + @Test + fun `toReadableAttachment 应创建 DefaultReadableAttachment 类型`() { + val multipartFile = MockMultipartFile("testFile", "test.txt", "text/plain", "test".toByteArray()) + + val attachment = multipartFile.toReadableAttachment() + + assertTrue(attachment is IReadableAttachment.DefaultReadableAttachment) + } + + @Test + fun `toReadableAttachment 多次调用应产生独立实例`() { + val content = "independence test".toByteArray() + val multipartFile = MockMultipartFile("testFile", "test.txt", "text/plain", content) + + val attachment1 = multipartFile.toReadableAttachment() + val attachment2 = multipartFile.toReadableAttachment() + + // 应该是不同的实例但内容相同 + assertEquals(attachment1.name, attachment2.name) + assertEquals(attachment1.mimeType, attachment2.mimeType) + assertEquals(attachment1.size, attachment2.size) + assertEquals(attachment1.bytes?.invoke()?.contentToString(), attachment2.bytes?.invoke()?.contentToString()) + } +} diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/PathVariableTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/PathVariableTest.kt index 51f022e5e..864082ec0 100644 --- a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/PathVariableTest.kt +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/PathVariableTest.kt @@ -1,8 +1,9 @@ package io.github.truenine.composeserver.depend.servlet -import io.github.truenine.composeserver.testtoolkit.annotations.SpringServletTest import jakarta.annotation.Resource import kotlin.test.Test +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Import import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get @@ -12,7 +13,8 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController /** # 确保 pathVariable 的解析性质 */ -@SpringServletTest +@SpringBootTest(classes = [TestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc @Import(PathVariableTest.TestPathVariableController::class) class PathVariableTest { lateinit var mockMvc: MockMvc diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/SpringResponseEntityFnsTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/SpringResponseEntityFnsTest.kt index 3e147cc3e..6af71794c 100644 --- a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/SpringResponseEntityFnsTest.kt +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/SpringResponseEntityFnsTest.kt @@ -1,24 +1,221 @@ package io.github.truenine.composeserver.depend.servlet +import java.time.LocalDateTime import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.springframework.http.MediaType class SpringResponseEntityFnsTest { + @Test - fun `test exists response entity`() { - val r = headMethodResponse { + fun `test exists response entity with various types`() { + headMethodResponse { + // Boolean tests exists { true } assertEquals(200, this@headMethodResponse.status) + exists { false } assertEquals(404, this@headMethodResponse.status) + // Null tests exists { null } assertEquals(404, this@headMethodResponse.status) + // Number tests exists { 0 } assertEquals(200, this@headMethodResponse.status) + exists { -1 } assertEquals(404, this@headMethodResponse.status) + + exists { 42 } + assertEquals(200, this@headMethodResponse.status) + + // String tests + exists { "non-empty" } + assertEquals(200, this@headMethodResponse.status) + + exists { "" } + assertEquals(404, this@headMethodResponse.status) + + // Array tests + exists { arrayOf(1, 2, 3) } + assertEquals(200, this@headMethodResponse.status) + + exists { emptyArray() } + assertEquals(404, this@headMethodResponse.status) + + // List tests + exists { listOf(1, 2, 3) } + assertEquals(200, this@headMethodResponse.status) + + exists { emptyList() } + assertEquals(404, this@headMethodResponse.status) + + // Map tests + exists { mapOf("key" to "value") } + assertEquals(200, this@headMethodResponse.status) + + exists { emptyMap() } + assertEquals(404, this@headMethodResponse.status) + + // Unit test + exists { Unit } + assertEquals(404, this@headMethodResponse.status) + + // Any object test + exists { Any() } + assertEquals(200, this@headMethodResponse.status) + } + } + + @Test + fun `test ResponseEntityScope type function`() { + val response = headMethodResponse { + type("application/json") + exists { true } + } + + assertEquals(200, response.statusCode.value()) + assertEquals(MediaType.APPLICATION_JSON, response.headers.contentType) + } + + @Test + fun `test ResponseEntityScope type with invalid media type`() { + val response = headMethodResponse { + type("invalid/media/type/format") + exists { true } + } + + assertEquals(200, response.statusCode.value()) + assertEquals(MediaType.APPLICATION_OCTET_STREAM, response.headers.contentType) + } + + @Test + fun `test ResponseEntityScope size function`() { + val response = headMethodResponse { + size { 1024L } + exists { true } + } + + assertEquals(200, response.statusCode.value()) + assertEquals(1024L, response.headers.contentLength) + } + + @Test + fun `test ResponseEntityScope size with null`() { + val response = headMethodResponse { + size { null } + exists { true } + } + + assertEquals(200, response.statusCode.value()) + assertEquals(-1L, response.headers.contentLength) // Spring default when not set + } + + @Test + fun `test ResponseEntityScope lastModifyBy function`() { + val testDateTime = LocalDateTime.of(2025, 7, 13, 12, 0, 0) + val response = headMethodResponse { + lastModifyBy { testDateTime } + exists { true } + } + + assertEquals(200, response.statusCode.value()) + assertNotNull(response.headers.lastModified) + assertTrue(response.headers.lastModified > 0) + } + + @Test + fun `test ResponseEntityScope lastModifyBy with null`() { + val response = headMethodResponse { + lastModifyBy { null } + exists { true } + } + + assertEquals(200, response.statusCode.value()) + assertEquals(-1L, response.headers.lastModified) // Spring default when not set + } + + @Test + fun `test complete response with all headers`() { + val testDateTime = LocalDateTime.of(2025, 7, 13, 12, 0, 0) + val response = headMethodResponse { + type("text/plain") + size { 256L } + lastModifyBy { testDateTime } + exists { "content exists" } + } + + assertEquals(200, response.statusCode.value()) + assertEquals(MediaType.TEXT_PLAIN, response.headers.contentType) + assertEquals(256L, response.headers.contentLength) + assertNotNull(response.headers.lastModified) + assertTrue(response.headers.lastModified > 0) + } + + @Test + fun `test response with 404 status should not affect other headers`() { + val testDateTime = LocalDateTime.of(2025, 7, 13, 12, 0, 0) + val response = headMethodResponse { + type("application/xml") + size { 512L } + lastModifyBy { testDateTime } + exists { false } // This should set status to 404 + } + + assertEquals(404, response.statusCode.value()) + assertEquals(MediaType.APPLICATION_XML, response.headers.contentType) + assertEquals(512L, response.headers.contentLength) + assertNotNull(response.headers.lastModified) + } + + @Test + fun `test ResponseEntityScope default values`() { + val response = headMethodResponse { + // No configuration, should use defaults + } + + assertEquals(200, response.statusCode.value()) // Default status + assertEquals(-1L, response.headers.contentLength) // No content length set + assertEquals(-1L, response.headers.lastModified) // No last modified set + // No content type set by default + } + + @Test + fun `test exists with complex objects`() { + data class TestObject(val value: String) + + headMethodResponse { + exists { TestObject("test") } + assertEquals(200, this@headMethodResponse.status) + } + } + + @Test + fun `test exists with nested collections`() { + headMethodResponse { + exists { listOf(listOf(1, 2), listOf(3, 4)) } + assertEquals(200, this@headMethodResponse.status) + + exists { listOf(emptyList()) } // List with empty nested list + assertEquals(200, this@headMethodResponse.status) + } + } + + @Test + fun `test multiple exists calls should use last result`() { + headMethodResponse { + exists { true } + assertEquals(200, this@headMethodResponse.status) + + exists { false } // Should override previous + assertEquals(404, this@headMethodResponse.status) + + exists { "final result" } // Should override again + assertEquals(200, this@headMethodResponse.status) } } } diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/TestApplication.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/TestApplication.kt new file mode 100644 index 000000000..255344103 --- /dev/null +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/TestApplication.kt @@ -0,0 +1,11 @@ +package io.github.truenine.composeserver.depend.servlet + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.runApplication + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) class TestApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/annotations/HeadMappingTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/annotations/HeadMappingTest.kt index 52a58f162..88a63fec9 100644 --- a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/annotations/HeadMappingTest.kt +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/annotations/HeadMappingTest.kt @@ -1,8 +1,10 @@ package io.github.truenine.composeserver.depend.servlet.annotations -import io.github.truenine.composeserver.testtoolkit.annotations.SpringServletTest +import io.github.truenine.composeserver.depend.servlet.TestApplication import jakarta.annotation.Resource import kotlin.test.Test +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Import import org.springframework.http.ResponseEntity import org.springframework.test.web.servlet.MockMvc @@ -10,7 +12,8 @@ import org.springframework.test.web.servlet.head import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -@SpringServletTest +@SpringBootTest(classes = [TestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc @Import(HeadMappingTest.HeadController::class) class HeadMappingTest { lateinit var mock: MockMvc diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/parameter/GetParameterTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/parameter/GetParameterTest.kt index 9d7630132..2201fa414 100644 --- a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/parameter/GetParameterTest.kt +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/parameter/GetParameterTest.kt @@ -1,10 +1,12 @@ package io.github.truenine.composeserver.depend.servlet.parameter import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.testtoolkit.annotations.SpringServletTest +import io.github.truenine.composeserver.depend.servlet.TestApplication import jakarta.annotation.Resource import kotlin.test.* import org.apache.catalina.util.URLEncoder +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc @@ -16,7 +18,8 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController /** # 验证以何种方式 给 spring boot 传递 get 参数 */ -@SpringServletTest +@SpringBootTest(classes = [TestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc @Import(GetParameterTest.TestGetParameterController::class) class GetParameterTest { lateinit var mockMvc: MockMvc diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/parameter/UploadFileParamAssertTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/parameter/UploadFileParamAssertTest.kt index 00a6db5e8..0a6327b97 100644 --- a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/parameter/UploadFileParamAssertTest.kt +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/parameter/UploadFileParamAssertTest.kt @@ -1,5 +1,6 @@ package io.github.truenine.composeserver.depend.servlet.parameter +import io.github.truenine.composeserver.depend.servlet.TestApplication import jakarta.annotation.Resource import java.nio.charset.StandardCharsets import kotlin.test.Test @@ -19,7 +20,7 @@ import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile @AutoConfigureMockMvc -@SpringBootTest +@SpringBootTest(classes = [TestApplication::class]) @Import(UploadFileParamAssertTest.TestUploadController::class) class UploadFileParamAssertTest { lateinit var mvc: MockMvc diff --git a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/resolvers/IPageParamLikeArgumentResolverTest.kt b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/resolvers/IPageParamLikeArgumentResolverTest.kt index b77173eb5..71ae316ee 100644 --- a/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/resolvers/IPageParamLikeArgumentResolverTest.kt +++ b/depend/servlet/src/test/kotlin/io/github/truenine/composeserver/depend/servlet/resolvers/IPageParamLikeArgumentResolverTest.kt @@ -3,7 +3,6 @@ package io.github.truenine.composeserver.depend.servlet.resolvers import io.github.truenine.composeserver.Pq import io.github.truenine.composeserver.domain.IPageParam import io.github.truenine.composeserver.domain.IPageParamLike -import io.github.truenine.composeserver.i32 import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -34,7 +33,7 @@ class IPageParamLikeArgumentResolverTest { mockMvc = MockMvcBuilders.standaloneSetup(TestController()).setCustomArgumentResolvers(IPageParamLikeArgumentResolver()).build() } - data class IPageParamLikeImpl(override val o: i32?, override val s: i32?) : IPageParamLike + data class IPageParamLikeImpl(override val o: Int?, override val s: Int?) : IPageParamLike data class IPageParamLikeDefaultValueImpl(val e: String) : IPageParamLike diff --git a/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/ApiEndpointsTest.kt b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/ApiEndpointsTest.kt new file mode 100644 index 000000000..954f57ba7 --- /dev/null +++ b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/ApiEndpointsTest.kt @@ -0,0 +1,133 @@ +package io.github.truenine.composeserver.depend.springdocopenapi + +import jakarta.annotation.Resource +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.TestPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +@SpringBootTest(classes = [TestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestPropertySource( + properties = + [ + "compose.depend.springdoc-open-api.group=api-test", + "compose.depend.springdoc-open-api.enable-jwt-header=true", + "compose.depend.springdoc-open-api.scan-packages[0]=io.github.truenine.composeserver.depend.springdocopenapi", + "compose.depend.springdoc-open-api.author-info.title=API Endpoints Test", + "compose.depend.springdoc-open-api.author-info.version=1.0.0", + ] +) +class ApiEndpointsTest { + + @Resource lateinit var mockMvc: MockMvc + + @Test + fun `should access swagger ui successfully`() { + mockMvc.get("/swagger-ui/index.html").andExpect { + status { isOk() } + content { contentTypeCompatibleWith(MediaType.TEXT_HTML) } + } + } + + @Test + fun `should access openapi json docs successfully`() { + mockMvc.get("/v3/api-docs").andExpect { + status { isOk() } + content { + contentType(MediaType.APPLICATION_JSON) + jsonPath("$.openapi") { exists() } + jsonPath("$.info") { exists() } + jsonPath("$.info.title") { value("API Endpoints Test") } + jsonPath("$.info.version") { value("1.0.0") } + jsonPath("$.paths") { exists() } + } + } + } + + @Test + fun `should access grouped api docs successfully`() { + mockMvc.get("/v3/api-docs/api-test").andExpect { + status { isOk() } + content { + contentType(MediaType.APPLICATION_JSON) + jsonPath("$.openapi") { exists() } + jsonPath("$.info.title") { value("API Endpoints Test") } + jsonPath("$.paths") { exists() } + } + } + } + + @Test + @DisplayName("测试 Swagger 配置端点") + fun `should access swagger config successfully`() { + mockMvc.get("/v3/api-docs/swagger-config").andExpect { + status { isOk() } + content { + contentType(MediaType.APPLICATION_JSON) + jsonPath("$.configUrl") { value("/v3/api-docs/swagger-config") } + jsonPath("$.urls") { exists() } + } + } + } + + @Test + fun `should access test controller endpoints successfully`() { + // 测试 hello 端点 + mockMvc.get("/test/hello").andExpect { + status { isOk() } + content { + contentType(MediaType.APPLICATION_JSON) + jsonPath("$.message") { value("Hello, World!") } + } + } + + // 测试 info 端点 + mockMvc.get("/test/info").andExpect { + status { isOk() } + content { + contentType(MediaType.APPLICATION_JSON) + jsonPath("$.service") { value("springdoc-openapi-test") } + jsonPath("$.version") { value("1.0.0") } + jsonPath("$.timestamp") { exists() } + } + } + } + + @Test + @DisplayName("测试 API 文档包含测试端点") + fun `should include test endpoints in api docs`() { + mockMvc.get("/v3/api-docs").andExpect { + status { isOk() } + content { + contentType(MediaType.APPLICATION_JSON) + jsonPath("$.paths./test/hello") { exists() } + jsonPath("$.paths./test/info") { exists() } + jsonPath("$.paths./test/hello.get") { exists() } + jsonPath("$.paths./test/info.get") { exists() } + } + } + } + + @Test + @DisplayName("测试 JWT 头参数在 API 文档中的存在") + fun `should include jwt headers in api documentation`() { + mockMvc.get("/v3/api-docs").andExpect { + status { isOk() } + content { + contentType(MediaType.APPLICATION_JSON) + // 由于 JWT 头参数是通过 OperationCustomizer 添加的, + // 我们需要检查实际的 API 文档结构 + jsonPath("$.openapi") { exists() } + jsonPath("$.info") { exists() } + jsonPath("$.paths") { exists() } + jsonPath("$.paths./test/hello") { exists() } + jsonPath("$.paths./test/info") { exists() } + } + } + } +} diff --git a/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/AutoConfigurationTest.kt b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/AutoConfigurationTest.kt new file mode 100644 index 000000000..a78761769 --- /dev/null +++ b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/AutoConfigurationTest.kt @@ -0,0 +1,103 @@ +package io.github.truenine.composeserver.depend.springdocopenapi + +import io.github.truenine.composeserver.depend.springdocopenapi.autoconfig.AutoConfigEntrance +import io.github.truenine.composeserver.depend.springdocopenapi.autoconfig.OpenApiDocConfig +import io.github.truenine.composeserver.depend.springdocopenapi.properties.SpringdocOpenApiProperties +import io.swagger.v3.oas.models.OpenAPI +import jakarta.annotation.Resource +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.Test +import org.springdoc.core.models.GroupedOpenApi +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationContext +import org.springframework.test.context.TestPropertySource + +@SpringBootTest(classes = [TestApplication::class]) +@TestPropertySource(properties = ["compose.depend.springdoc-open-api.group=auto-config-test", "compose.depend.springdoc-open-api.enable-jwt-header=true"]) +class AutoConfigurationTest { + + @Resource lateinit var applicationContext: ApplicationContext + + @Test + fun `should load auto configuration entrance correctly`() { + val autoConfigEntrance = applicationContext.getBean(AutoConfigEntrance::class.java) + assertNotNull(autoConfigEntrance, "AutoConfigEntrance should be loaded") + } + + @Test + fun `should create configuration properties bean correctly`() { + val properties = applicationContext.getBean(SpringdocOpenApiProperties::class.java) + assertNotNull(properties, "SpringdocOpenApiProperties should be created") + } + + @Test + fun `should create openapi config bean correctly`() { + val openApiDocConfig = applicationContext.getBean(OpenApiDocConfig::class.java) + assertNotNull(openApiDocConfig, "OpenApiDocConfig should be created") + } + + @Test + fun `should create grouped openapi bean correctly`() { + val groupedOpenApi = applicationContext.getBean(GroupedOpenApi::class.java) + assertNotNull(groupedOpenApi, "GroupedOpenApi should be created") + } + + @Test + fun `should create custom openapi bean correctly`() { + val customOpenApi = applicationContext.getBean(OpenAPI::class.java) + assertNotNull(customOpenApi, "Custom OpenAPI should be created") + } + + @Test + fun `should have all required beans in application context`() { + val beanNames = applicationContext.beanDefinitionNames.toList() + + // 检查关键 Bean 是否存在 + assertTrue(beanNames.any { it.contains("autoConfigEntrance") || it.contains("AutoConfigEntrance") }, "AutoConfigEntrance bean should exist") + assertTrue( + beanNames.any { it.contains("springdocOpenApiProperties") || it.contains("SpringdocOpenApiProperties") }, + "SpringdocOpenApiProperties bean should exist", + ) + assertTrue(beanNames.any { it.contains("openApiDocConfig") || it.contains("OpenApiDocConfig") }, "OpenApiDocConfig bean should exist") + } + + @Test + fun `should have correct bean dependencies`() { + val openApiDocConfig = applicationContext.getBean(OpenApiDocConfig::class.java) + val properties = applicationContext.getBean(SpringdocOpenApiProperties::class.java) + + assertNotNull(openApiDocConfig, "OpenApiDocConfig should be available") + assertNotNull(properties, "SpringdocOpenApiProperties should be available") + + // 验证配置是否正确应用 + val groupedOpenApi = applicationContext.getBean(GroupedOpenApi::class.java) + val customOpenApi = applicationContext.getBean(OpenAPI::class.java) + + assertNotNull(groupedOpenApi, "GroupedOpenApi should be created with dependencies") + assertNotNull(customOpenApi, "Custom OpenAPI should be created with dependencies") + } +} + +@SpringBootTest(classes = [TestApplication::class]) +@TestPropertySource(properties = ["compose.depend.springdoc-open-api.enable-jwt-header=false"]) +class ConditionalConfigurationTest { + + @Resource lateinit var applicationContext: ApplicationContext + + @Test + fun `should configure correctly when jwt header is disabled`() { + val properties = applicationContext.getBean(SpringdocOpenApiProperties::class.java) + assertNotNull(properties, "Properties should still be created") + + val groupedOpenApi = applicationContext.getBean(GroupedOpenApi::class.java) + assertNotNull(groupedOpenApi, "GroupedOpenApi should still be created") + } + + @Test + fun `should configure correctly for web application`() { + // 验证 @ConditionalOnWebApplication 注解的效果 + val groupedOpenApi = applicationContext.getBean(GroupedOpenApi::class.java) + assertNotNull(groupedOpenApi, "GroupedOpenApi should be created in web application context") + } +} diff --git a/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/BeanSetupTest.kt b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/BeanSetupTest.kt index f3a085ede..fbf497be8 100644 --- a/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/BeanSetupTest.kt +++ b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/BeanSetupTest.kt @@ -1,19 +1,32 @@ package io.github.truenine.composeserver.depend.springdocopenapi -import io.github.truenine.composeserver.testtoolkit.annotations.SpringServletTest import io.github.truenine.composeserver.testtoolkit.log import jakarta.annotation.Resource import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertNotNull +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType +import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get -@SpringServletTest +@SpringBootTest(classes = [TestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestPropertySource( + properties = + [ + "compose.depend.springdoc-open-api.group=test-group", + "compose.depend.springdoc-open-api.enable-jwt-header=true", + "compose.depend.springdoc-open-api.scan-packages[0]=io.github.truenine.composeserver.depend.springdocopenapi", + "compose.depend.springdoc-open-api.author-info.title=Test API", + "compose.depend.springdoc-open-api.author-info.version=1.0.0", + "compose.depend.springdoc-open-api.author-info.description=Test API Description", + ] +) class BeanSetupTest { - lateinit var mock: MockMvc - @Resource set + @Resource lateinit var mock: MockMvc @BeforeTest fun setup() { @@ -38,6 +51,6 @@ class BeanSetupTest { .andReturn() .response .contentAsString - log.info(jsonStr) + log.info("Swagger config response: $jsonStr") } } diff --git a/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/OpenApiConfigTest.kt b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/OpenApiConfigTest.kt new file mode 100644 index 000000000..0f07a69ae --- /dev/null +++ b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/OpenApiConfigTest.kt @@ -0,0 +1,94 @@ +package io.github.truenine.composeserver.depend.springdocopenapi + +import io.github.truenine.composeserver.depend.springdocopenapi.autoconfig.OpenApiDocConfig +import io.github.truenine.composeserver.depend.springdocopenapi.properties.SpringdocOpenApiProperties +import io.swagger.v3.oas.models.OpenAPI +import jakarta.annotation.Resource +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springdoc.core.models.GroupedOpenApi +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.TestPropertySource + +@SpringBootTest(classes = [TestApplication::class]) +@TestPropertySource( + properties = + [ + "compose.depend.springdoc-open-api.group=test-api", + "compose.depend.springdoc-open-api.enable-jwt-header=true", + "compose.depend.springdoc-open-api.scan-packages[0]=io.github.truenine.composeserver.depend.springdocopenapi", + "compose.depend.springdoc-open-api.scan-url-patterns[0]=/test/**", + "compose.depend.springdoc-open-api.author-info.title=Test API Documentation", + "compose.depend.springdoc-open-api.author-info.version=2.0.0", + "compose.depend.springdoc-open-api.author-info.description=This is a test API for SpringDoc OpenAPI integration", + "compose.depend.springdoc-open-api.author-info.license=MIT", + "compose.depend.springdoc-open-api.author-info.license-url=https://opensource.org/licenses/MIT", + "compose.depend.springdoc-open-api.jwt-header-info.auth-token-name=X-Auth-Token", + "compose.depend.springdoc-open-api.jwt-header-info.refresh-token-name=X-Refresh-Token", + ] +) +class OpenApiConfigTest { + + @Resource lateinit var openApiDocConfig: OpenApiDocConfig + + @Resource lateinit var springdocOpenApiProperties: SpringdocOpenApiProperties + + @Resource lateinit var groupedOpenApi: GroupedOpenApi + + @Resource lateinit var customOpenApi: OpenAPI + + @Test + fun `should create OpenAPI config beans correctly`() { + assertNotNull(openApiDocConfig, "OpenApiDocConfig bean should be created") + assertNotNull(springdocOpenApiProperties, "SpringdocOpenApiProperties bean should be created") + assertNotNull(groupedOpenApi, "GroupedOpenApi bean should be created") + assertNotNull(customOpenApi, "Custom OpenAPI bean should be created") + } + + @Test + fun `should load configuration properties correctly`() { + with(springdocOpenApiProperties) { + assertEquals("test-api", group, "Group name should match configuration") + assertTrue(enableJwtHeader, "JWT header should be enabled") + assertTrue(scanPackages.contains("io.github.truenine.composeserver.depend.springdocopenapi"), "Scan packages should contain test package") + assertTrue(scanUrlPatterns.contains("/test/**"), "Scan URL patterns should contain test pattern") + + with(authorInfo) { + assertEquals("Test API Documentation", title, "Title should match configuration") + assertEquals("2.0.0", version, "Version should match configuration") + assertEquals("This is a test API for SpringDoc OpenAPI integration", description, "Description should match configuration") + assertEquals("MIT", license, "License should match configuration") + assertEquals("https://opensource.org/licenses/MIT", licenseUrl, "License URL should match configuration") + } + + with(jwtHeaderInfo) { + assertEquals("X-Auth-Token", authTokenName, "Auth token name should match configuration") + assertEquals("X-Refresh-Token", refreshTokenName, "Refresh token name should match configuration") + } + } + } + + @Test + @DisplayName("测试 GroupedOpenApi 配置") + fun `should configure GroupedOpenApi correctly`() { + assertEquals("test-api", groupedOpenApi.group, "GroupedOpenApi group should match configuration") + assertNotNull(groupedOpenApi.pathsToMatch, "Paths to match should be configured") + assertNotNull(groupedOpenApi.packagesToScan, "Packages to scan should be configured") + } + + @Test + fun `should configure custom OpenAPI correctly`() { + with(customOpenApi.info) { + assertEquals("Test API Documentation", title, "OpenAPI title should match configuration") + assertEquals("2.0.0", version, "OpenAPI version should match configuration") + assertEquals("This is a test API for SpringDoc OpenAPI integration", description, "OpenAPI description should match configuration") + + assertNotNull(license, "License should be configured") + assertEquals("MIT", license.name, "License name should match configuration") + assertEquals("https://opensource.org/licenses/MIT", license.url, "License URL should match configuration") + } + } +} diff --git a/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/PropertiesTest.kt b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/PropertiesTest.kt new file mode 100644 index 000000000..586bc40c1 --- /dev/null +++ b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/PropertiesTest.kt @@ -0,0 +1,121 @@ +package io.github.truenine.composeserver.depend.springdocopenapi + +import io.github.truenine.composeserver.depend.springdocopenapi.properties.JwtHeaderInfoProperties +import io.github.truenine.composeserver.depend.springdocopenapi.properties.SpringdocOpenApiProperties +import io.github.truenine.composeserver.depend.springdocopenapi.properties.SwaggerDescInfo +import jakarta.annotation.Resource +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.TestPropertySource + +@SpringBootTest(classes = [TestApplication::class]) +@TestPropertySource( + properties = + [ + "compose.depend.springdoc-open-api.group=custom-group", + "compose.depend.springdoc-open-api.enable-jwt-header=false", + "compose.depend.springdoc-open-api.scan-packages[0]=com.example.package1", + "compose.depend.springdoc-open-api.scan-packages[1]=com.example.package2", + "compose.depend.springdoc-open-api.scan-url-patterns[0]=/api/v1/**", + "compose.depend.springdoc-open-api.scan-url-patterns[1]=/api/v2/**", + "compose.depend.springdoc-open-api.author-info.title=Custom API", + "compose.depend.springdoc-open-api.author-info.version=3.1.0", + "compose.depend.springdoc-open-api.author-info.description=Custom API Description", + "compose.depend.springdoc-open-api.author-info.location=https://example.com", + "compose.depend.springdoc-open-api.author-info.license=Apache-2.0", + "compose.depend.springdoc-open-api.author-info.license-url=https://www.apache.org/licenses/LICENSE-2.0", + "compose.depend.springdoc-open-api.jwt-header-info.auth-token-name=Authorization", + "compose.depend.springdoc-open-api.jwt-header-info.refresh-token-name=Refresh-Token", + ] +) +class PropertiesTest { + + @Resource lateinit var properties: SpringdocOpenApiProperties + + @Test + fun `should load basic configuration properties correctly`() { + with(properties) { + assertEquals("custom-group", group, "Group should match configuration") + assertFalse(enableJwtHeader, "JWT header should be disabled") + + // 注意:OpenApiDocConfig 会自动添加 "net.yan100.compose" 包,所以总数会是 3 + assertTrue(scanPackages.size >= 2, "Should have at least 2 scan packages") + assertTrue(scanPackages.contains("com.example.package1"), "Should contain package1") + assertTrue(scanPackages.contains("com.example.package2"), "Should contain package2") + + assertEquals(2, scanUrlPatterns.size, "Should have 2 URL patterns") + assertTrue(scanUrlPatterns.contains("/api/v1/**"), "Should contain v1 pattern") + assertTrue(scanUrlPatterns.contains("/api/v2/**"), "Should contain v2 pattern") + } + } + + @Test + fun `should load author info configuration correctly`() { + with(properties.authorInfo) { + assertEquals("Custom API", title, "Title should match configuration") + assertEquals("3.1.0", version, "Version should match configuration") + assertEquals("Custom API Description", description, "Description should match configuration") + assertEquals("https://example.com", location, "Location should match configuration") + assertEquals("Apache-2.0", license, "License should match configuration") + assertEquals("https://www.apache.org/licenses/LICENSE-2.0", licenseUrl, "License URL should match configuration") + } + } + + @Test + fun `should load JWT header info configuration correctly`() { + with(properties.jwtHeaderInfo) { + assertEquals("Authorization", authTokenName, "Auth token name should match configuration") + assertEquals("Refresh-Token", refreshTokenName, "Refresh token name should match configuration") + } + } + + @Test + fun `should create nested configuration objects correctly`() { + assertNotNull(properties.authorInfo, "Author info should not be null") + assertNotNull(properties.jwtHeaderInfo, "JWT header info should not be null") + + assertTrue(properties.authorInfo is SwaggerDescInfo, "Author info should be SwaggerDescInfo type") + assertTrue(properties.jwtHeaderInfo is JwtHeaderInfoProperties, "JWT header info should be JwtHeaderInfoProperties type") + } +} + +@SpringBootTest(classes = [TestApplication::class]) +class DefaultPropertiesTest { + + @Resource lateinit var properties: SpringdocOpenApiProperties + + @Test + fun `should use default configuration values when not specified`() { + with(properties) { + assertEquals("default", group, "Default group should be 'default'") + assertFalse(enableJwtHeader, "JWT header should be disabled by default") + + // OpenApiDocConfig 会自动添加 "net.yan100.compose" 包,所以不会为空 + assertTrue(scanPackages.size >= 1, "Scan packages should contain at least the auto-added packages") + + assertEquals(1, scanUrlPatterns.size, "Should have default URL pattern") + assertTrue(scanUrlPatterns.contains("/**"), "Should contain default pattern") + } + } + + @Test + fun `should use default author info when not specified`() { + with(properties.authorInfo) { + assertNotNull(title, "Title should have default value") + assertNotNull(version, "Version should have default value") + assertNotNull(description, "Description should have default value") + } + } + + @Test + fun `should use default JWT header info when not specified`() { + with(properties.jwtHeaderInfo) { + assertNotNull(authTokenName, "Auth token name should have default value") + assertNotNull(refreshTokenName, "Refresh token name should have default value") + } + } +} diff --git a/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/TestApplication.kt b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/TestApplication.kt new file mode 100644 index 000000000..fc76a7106 --- /dev/null +++ b/depend/springdoc-openapi/src/test/kotlin/io/github/truenine/composeserver/depend/springdocopenapi/TestApplication.kt @@ -0,0 +1,26 @@ +package io.github.truenine.composeserver.depend.springdocopenapi + +import io.github.truenine.composeserver.depend.springdocopenapi.autoconfig.AutoConfigEntrance +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.context.annotation.Import +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) @Import(AutoConfigEntrance::class) class TestApplication + +@RestController +@RequestMapping("/test") +class TestController { + + @GetMapping("/hello") + fun hello(): Map { + return mapOf("message" to "Hello, World!") + } + + @GetMapping("/info") + fun info(): Map { + return mapOf("service" to "springdoc-openapi-test", "version" to "1.0.0", "timestamp" to System.currentTimeMillis()) + } +} diff --git a/docs/GLOBAL_CLAUDE.md b/docs/GLOBAL_CLAUDE.md index 537d40f41..a6becf34f 100644 --- a/docs/GLOBAL_CLAUDE.md +++ b/docs/GLOBAL_CLAUDE.md @@ -1,34 +1,77 @@ -# Claude Code 专用 AI 规则(2025-07 版) - -## 通用规范 -- 所有回复必须使用简体中文。 - -## SQL 规范 -- SQL 语句全部小写(关键字、标识符)。 -- 命名风格统一使用下划线命名法(snake_case)。 -- 严禁拼接 SQL 字符串,必须使用参数化查询,防止 SQL 注入。 -- 表、字段、索引等命名需简洁明了,避免缩写。 -- 禁止在代码中硬编码数据库连接信息,需使用配置文件或安全管理方式。 -- 建议为重要业务表和字段添加注释,便于维护。 - -## 通用代码规范 -- 文档注释必须由英文书写。 -- 优先采用提前返回(early return),减少嵌套。 -- 禁止行尾注释,注释需单独成行。 -- 统一使用 2 空格缩进,仅用空格,不用 Tab。 -- 禁止全量 import,必须显式导入所需内容。 -- 编程语言优先选择编译型语言,避免动态语言,以便尽早发现错误。 -- 应积极采用日志记录方式协助诊断问题,并合理控制日志级别,避免无效或过量日志输出。 - -## markdown 规范 -- Markdown 文件需扁平、紧凑,严禁嵌套列表。 -- 采用分级标题+扁平条目归类结构,避免多层嵌套,整体风格清晰、紧凑、分组明确。 -- 内容应准确、简明,避免歧义和冗余。 -- 结构应清晰,分组合理,便于查阅。 -- 统一术语和表达风格,避免同义词混用。 -- 注重可读性,适当分段,避免长段落堆砌。 -- 人类以阅读为主,AI 需确保文档对人类友好、易于理解。 - -## 依赖与技术规范 -- 仅推荐和采用最新技术,所有建议和代码实现前必须查阅最新官方文档,确保内容为 2025 年及以后主流方案。 -- 禁止试图通过降级依赖版本来解决问题,遇到依赖或兼容性问题时,必须优先查阅最新技术文档或最佳实践,积极采用主流和前沿方案。 +# claude code 核心工作规则 + +## 沟通效率优化 +- 批量处理相关任务以减少交互轮次 +- 将多个相关修改合并为单次操作 + +# 通用代码标准 + +## 编程原则 +- 函数参数限制在5个以内 +- 禁止硬编码常量,使用配置或常量定义 +- 避免静默失败 + +## 代码风格一致性 +- 强制使用大括号包围代码块,即使只有一行代码(防止类似苹果 goto fail 的安全漏洞) + +## 注释标准 +- API 文档注释使用英文 +- 注释解释"为什么"而非"是什么" + +## 安全编程指南 +- 严格禁止在代码中暴露敏感信息(密钥、密码等) +- 使用参数化查询防止注入攻击 + +# 语言特定标准 + +## SQL +- 必须使用参数化查询,严格禁止字符串拼接 +- 使用 snake_case 命名约定 + +## Markdown +- 代码块必须指定语言类型 +- 避免标题层级跳跃 + +## Java/Kotlin +- 强制使用大括号包围所有代码块 +- 优先使用 final(Java)或 val(Kotlin) +- Kotlin 中使用空安全操作符,避免 !! 操作符 + +## Rust 标准 +- 避免使用 unwrap(),使用 expect() 或模式匹配 +- 优先使用迭代器而非索引循环 + +## TypeScript 标准 +- 启用严格模式(strict: true) +- 避免使用 any 类型,使用 unknown 或具体类型 +- 函数参数和返回值必须有类型注解 + +## Vue 标准 +- 使用 Composition API 和