Files
ocdp-go/backend/docs/api-and-test.md
mangomqy c5e51ed069 ocdp v1
2025-11-13 02:54:06 +00:00

1803 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 📚 API 与测试文档
> **💡 推荐使用 OpenAPI 规范**
>
> 本项目现在提供标准的 **OpenAPI 3.0 规范**
>
> - 📖 **交互式 API 文档**: [http://localhost:8080/api/docs](http://localhost:8080/api/docs) (Swagger UI)
> - 📄 **OpenAPI 规范文件**: [openapi.yaml](./openapi.yaml)
> - 🔧 **在线测试**: 在 Swagger UI 中直接测试所有 API
> - 🚀 **客户端生成**: 使用 OpenAPI 规范自动生成客户端代码
>
> **本文档 (Markdown 版本)** 作为参考文档保留,内容与 OpenAPI 规范保持一致。
> 如需最新的 API 定义,请参考 [openapi.yaml](./openapi.yaml)。
---
## 目录
### Part 1: API 文档
- [基础信息](#基础信息)
- [认证 API](#认证-api)
- [集群管理 API](#集群管理-api)
- [Registry 管理 API](#registry-管理-api)
- [Artifact 浏览 API](#artifact-浏览-api)
- [实例管理 API](#实例管理-api)
- [监控 API](#监控-api)
- [响应格式](#响应格式)
- [错误处理](#错误处理)
### Part 2: 测试文档
- [测试策略](#测试策略)
- [单元测试](#单元测试)
- [集成测试](#集成测试)
- [API 测试](#api-测试)
- [E2E 测试](#e2e-测试)
- [Mock 测试](#mock-测试)
- [测试工具](#测试工具)
- [测试最佳实践](#测试最佳实践)
---
# Part 1: API 文档
## 基础信息
### Base URL
```
http://localhost:8080/api/v1
```
### 健康检查
```bash
GET /health
# 响应
{
"status": "healthy"
}
```
### 通用请求头
```
Content-Type: application/json
Authorization: Bearer <JWT_TOKEN> # 部分接口需要
```
---
## 认证 API
### 用户注册
```bash
POST /api/v1/auth/register
Content-Type: application/json
{
"username": "john",
"password": "secret123",
"email": "john@example.com"
}
# 响应 201
{
"id": "user-123",
"username": "john",
"email": "john@example.com",
"createdAt": "2025-11-09T10:00:00Z"
}
```
### 用户登录
```bash
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "john",
"password": "secret123"
}
# 响应 200
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"userId": "user-123",
"username": "john"
}
```
### 刷新 Token
```bash
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
# 响应 200
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"userId": "user-123",
"username": "john"
}
```
---
## 集群管理 API
### 创建集群
```bash
POST /api/v1/clusters
Content-Type: application/json
{
"name": "Production Cluster",
"host": "https://k8s.example.com:6443",
"description": "生产环境集群",
"caData": "LS0tLS1CRUdJTi0...", # Base64 编码的 CA 证书
"certData": "LS0tLS1CRUdJTi0...", # Base64 编码的客户端证书
"keyData": "LS0tLS1CRUdJTi0..." # Base64 编码的客户端密钥
}
# 响应 201
{
"id": "cluster-abc123",
"name": "Production Cluster",
"host": "https://k8s.example.com:6443",
"description": "生产环境集群",
"status": "healthy",
"createdAt": "2025-11-09T10:00:00Z",
"updatedAt": "2025-11-09T10:00:00Z"
}
```
### 列出所有集群
```bash
GET /api/v1/clusters
# 响应 200
[
{
"id": "cluster-abc123",
"name": "Production Cluster",
"host": "https://k8s.example.com:6443",
"description": "生产环境集群",
"status": "healthy",
"createdAt": "2025-11-09T10:00:00Z"
}
]
```
### 获取集群详情
```bash
GET /api/v1/clusters/{clusterId}
# 响应 200
{
"id": "cluster-abc123",
"name": "Production Cluster",
"host": "https://k8s.example.com:6443",
"description": "生产环境集群",
"status": "healthy",
"version": "v1.28.0",
"nodeCount": 5,
"createdAt": "2025-11-09T10:00:00Z",
"updatedAt": "2025-11-09T10:00:00Z"
}
```
### 更新集群
```bash
PUT /api/v1/clusters/{clusterId}
Content-Type: application/json
{
"name": "Production Cluster (Updated)",
"description": "更新后的描述"
}
# 响应 200
{
"id": "cluster-abc123",
"name": "Production Cluster (Updated)",
"description": "更新后的描述",
"updatedAt": "2025-11-09T11:00:00Z"
}
```
### 删除集群
```bash
DELETE /api/v1/clusters/{clusterId}
# 响应 204 No Content
```
### 集群健康检查
```bash
GET /api/v1/clusters/{clusterId}/health
# 响应 200
{
"clusterId": "cluster-abc123",
"status": "healthy",
"version": "v1.28.0",
"nodeCount": 5,
"readyNodes": 5,
"cpuCapacity": "40 cores",
"memoryCapacity": "160Gi",
"checkedAt": "2025-11-09T12:00:00Z"
}
```
---
## Registry 管理 API
> **OCI 标准**: 所有 Registry 都遵循 OCI (Open Container Initiative) 标准,支持 Harbor, Docker Hub, GHCR, Nexus, 以及任何兼容 OCI Distribution Spec 的 Registry。
### 创建 Registry
```bash
POST /api/v1/registries
Content-Type: application/json
{
"name": "Harbor Production",
"url": "https://harbor.example.com",
"description": "生产环境 Harbor 仓库",
"username": "admin",
"password": "secret",
"insecure": false
}
# 响应 201
{
"id": "registry-123",
"name": "Harbor Production",
"url": "https://harbor.example.com",
"description": "生产环境 Harbor 仓库",
"username": "admin",
"insecure": false,
"createdAt": "2025-11-09T10:00:00Z",
"updatedAt": "2025-11-09T10:00:00Z"
}
```
**字段说明**:
- `url`: Registry URL所有 Registry 都使用 OCI Distribution API
- `username/password`: 可选,用于私有 Registry 认证
- `insecure`: 是否跳过 TLS 验证(开发环境可用)
> **注意**: 密码不会在响应中返回,存储时会自动加密。
### 列出所有 Registries
```bash
GET /api/v1/registries
# 响应 200
[
{
"id": "registry-123",
"name": "Harbor Production",
"url": "https://harbor.example.com",
"description": "生产环境 Harbor 仓库",
"username": "admin",
"insecure": false,
"createdAt": "2025-11-09T10:00:00Z"
}
]
```
### 获取 Registry 详情
```bash
GET /api/v1/registries/{registryId}
# 响应 200
{
"id": "registry-123",
"name": "Harbor Production",
"url": "https://harbor.example.com",
"description": "生产环境 Harbor 仓库",
"username": "admin",
"insecure": false,
"createdAt": "2025-11-09T10:00:00Z",
"updatedAt": "2025-11-09T10:00:00Z"
}
```
### 更新 Registry
```bash
PUT /api/v1/registries/{registryId}
Content-Type: application/json
{
"name": "Harbor Production (Updated)",
"url": "https://new-harbor.example.com",
"password": "new-secret" # 可选,只在需要更新密码时提供
}
# 响应 200
{
"id": "registry-123",
"name": "Harbor Production (Updated)",
"url": "https://new-harbor.example.com",
"updatedAt": "2025-11-09T11:00:00Z"
}
```
### 删除 Registry
```bash
DELETE /api/v1/registries/{registryId}
# 响应 204 No Content
```
### Registry 健康检查
```bash
GET /api/v1/registries/{registryId}/health
# 响应 200
{
"registryId": "registry-123",
"status": "healthy",
"url": "https://harbor.example.com",
"reachable": true,
"authenticated": true,
"responseTime": 125, # 毫秒
"checkedAt": "2025-11-09T12:00:00Z"
}
# 响应 503 (不健康)
{
"registryId": "registry-123",
"status": "unhealthy",
"url": "https://harbor.example.com",
"reachable": false,
"error": "connection timeout",
"checkedAt": "2025-11-09T12:00:00Z"
}
```
---
## Artifact 浏览 API
### 列出 Repositories
```bash
GET /api/v1/registries/{registryId}/repositories
# 响应 200
{
"registryId": "registry-123",
"registryUrl": "https://harbor.example.com",
"repositories": [
"charts/nginx",
"charts/redis",
"charts/vllm-serve",
"library/alpine"
],
"total": 4,
"catalogSupported": true,
"source": "catalog"
}
```
> **注意**: 需要 Registry 支持 `_catalog` APIOCI Distribution Spec
### 列出 Artifacts
```bash
GET /api/v1/registries/{registryId}/repositories/{repository_name}/artifacts
# 示例(需要 URL 编码)
GET /api/v1/registries/registry-123/repositories/charts%2Fnginx/artifacts
# 响应 200
{
"repositoryName": "charts/nginx",
"tags": [
{
"name": "1.0.0",
"digest": "sha256:abc123def456...",
"type": "chart",
"size": 12345678,
"createdAt": "2025-11-01T10:00:00Z"
}
],
"total": 1
}
```
**Artifact 类型识别**:
- `chart`: Helm Chart
- `image`: Docker Image / OCI Image
- `other`: 其他类型
### 获取 Artifact 详情
```bash
GET /api/v1/registries/{registryId}/repositories/{repository_name}/artifacts/{reference}
# reference 可以是 tag 或 digest
GET /api/v1/registries/registry-123/repositories/charts%2Fnginx/artifacts/1.0.0
# 响应 200
{
"repositoryName": "charts/nginx",
"tag": "1.0.0",
"digest": "sha256:abc123def456...",
"type": "chart",
"size": 12345678,
"createdAt": "2025-11-01T10:00:00Z"
}
```
**字段说明**:
- `repositoryName`: 仓库名称
- `tag`: 标签名称(如果使用 tag 引用)
- `digest`: SHA256 摘要
- `type`: 制品类型,从 mediaType 自动识别:
- `chart`: Helm ChartmediaType 包含 `helm.config``helm.chart`
- `image`: Docker/OCI ImagemediaType 包含 `docker.container.image``oci.image`
- `other`: 其他类型
- `size`: 总大小(字节)
- `createdAt`: 创建时间ISO 8601 格式)
### 获取 Helm Chart Values Schema
```bash
GET /api/v1/registries/{registryId}/repositories/{repository_name}/artifacts/{reference}/values-schema
# 仅支持 Helm Chart 类型
GET /api/v1/registries/registry-123/repositories/charts%2Fnginx/artifacts/1.0.0/values-schema
# 响应 200
{
"repositoryName": "charts/nginx",
"tag": "1.0.0",
"valuesSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"replicaCount": {
"type": "integer",
"default": 1,
"description": "Number of replicas"
},
"image": {
"type": "object",
"properties": {
"repository": {
"type": "string",
"default": "nginx"
},
"tag": {
"type": "string",
"default": "latest"
}
}
}
}
}
}
```
> **URL 编码提示**: Repository 名称包含斜杠时(如 `charts/nginx`),需要编码为 `charts%2Fnginx`。
---
## 实例管理 API
### 安装应用
```bash
POST /api/v1/clusters/{clusterId}/instances
Content-Type: application/json
# 方式 1: 使用 JSON values
{
"name": "my-nginx",
"namespace": "default",
"registryId": "registry-123",
"repository": "charts/nginx",
"chart": "nginx",
"version": "1.0.0",
"description": "My NGINX deployment",
"values": {
"replicaCount": 2,
"image": {
"tag": "1.21.0"
}
}
}
# 响应 201
{
"id": "instance-xyz789",
"name": "my-nginx",
"namespace": "default",
"clusterId": "cluster-abc123",
"registryId": "registry-123",
"chart": "nginx",
"version": "1.0.0",
"status": "deployed",
"revision": 1,
"description": "My NGINX deployment",
"createdAt": "2025-11-09T10:00:00Z",
"updatedAt": "2025-11-09T10:00:00Z"
}
```
### 列出应用实例
```bash
GET /api/v1/clusters/{clusterId}/instances
# 响应 200
[
{
"id": "instance-xyz789",
"name": "my-nginx",
"namespace": "default",
"chart": "nginx",
"version": "1.0.0",
"status": "deployed",
"revision": 1,
"createdAt": "2025-11-09T10:00:00Z"
}
]
```
### 升级应用
```bash
PUT /api/v1/clusters/{clusterId}/instances/{instanceId}
Content-Type: application/json
{
"version": "1.1.0",
"values": {
"replicaCount": 3
},
"description": "Upgrade to v1.1.0"
}
# 响应 200
{
"id": "instance-xyz789",
"name": "my-nginx",
"version": "1.1.0",
"status": "deployed",
"revision": 4,
"updatedAt": "2025-11-09T12:00:00Z"
}
```
### 卸载应用
```bash
DELETE /api/v1/clusters/{clusterId}/instances/{instanceId}
# 响应 204 No Content
```
### 获取实例访问入口
```bash
GET /api/v1/clusters/{clusterId}/instances/{instanceId}/entries
# 响应 200
[
{
"kind": "Service",
"name": "test-nginx",
"namespace": "default",
"type": "ClusterIP",
"clusterIP": "10.43.120.15",
"ports": [
{
"name": "http",
"protocol": "TCP",
"port": 80,
"targetPort": "http"
}
]
},
{
"kind": "Ingress",
"name": "test-nginx",
"namespace": "default",
"type": "nginx",
"hosts": [
{
"host": "nginx.example.com",
"paths": [
{
"path": "/",
"serviceName": "test-nginx",
"servicePort": "http"
}
]
}
],
"loadBalancerIngress": [
"34.120.0.12"
]
}
]
```
---
## 监控 API
### 列出集群监控信息
```bash
GET /api/v1/monitoring/clusters
# 响应 200
[
{
"clusterId": "cluster-abc123",
"clusterName": "Production Cluster",
"status": "healthy",
"cpuUsage": 45.2,
"memoryUsage": 62.8,
"nodeCount": 5,
"readyNodes": 5,
"podCount": 120,
"timestamp": "2025-11-09T12:00:00Z"
}
]
```
### 获取监控摘要
```bash
GET /api/v1/monitoring/summary
# 响应 200
{
"totalClusters": 3,
"healthyClusters": 2,
"unhealthyClusters": 1,
"totalNodes": 15,
"totalInstances": 45,
"averageCpuUsage": 42.3,
"averageMemoryUsage": 58.6,
"timestamp": "2025-11-09T12:00:00Z"
}
```
---
## 响应格式
### 成功响应
**单个资源**:
```json
{
"id": "resource-123",
"name": "Resource Name",
"status": "active",
"createdAt": "2025-11-09T10:00:00Z"
}
```
**资源列表**:
```json
[
{
"id": "resource-123",
"name": "Resource 1"
},
{
"id": "resource-456",
"name": "Resource 2"
}
]
```
### HTTP 状态码
| 状态码 | 说明 | 使用场景 |
|--------|------|----------|
| 200 | OK | 请求成功 |
| 201 | Created | 资源创建成功 |
| 204 | No Content | 删除成功(无返回内容) |
| 400 | Bad Request | 请求参数错误 |
| 401 | Unauthorized | 未认证或 Token 无效 |
| 403 | Forbidden | 无权限访问 |
| 404 | Not Found | 资源不存在 |
| 409 | Conflict | 资源冲突(如重复创建) |
| 500 | Internal Server Error | 服务器内部错误 |
| 503 | Service Unavailable | 服务不可用 |
---
## 错误处理
### 错误响应格式
```json
{
"error": "Error Title",
"message": "Detailed error message",
"code": "ERROR_CODE",
"details": {...}
}
```
### 常见错误示例
#### 400 Bad Request
```json
{
"error": "Invalid Request",
"message": "Field 'name' is required"
}
```
#### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Invalid or expired token"
}
```
#### 404 Not Found
```json
{
"error": "Not Found",
"message": "Cluster with ID 'cluster-123' not found"
}
```
#### 500 Internal Server Error
```json
{
"error": "Internal Server Error",
"message": "Failed to connect to database"
}
```
---
# Part 2: 测试文档
## 测试策略
OCDP Backend 采用多层测试策略,确保代码质量和系统稳定性。
### 测试金字塔
```
┌────────────┐
E2E ╱│ 少量(慢速、昂贵)
集成测试 │ 中等(中速、适度)
单元测试 │ 大量(快速、便宜)
└──────────┘ │
│ │
└────────────┘
```
### 测试类型
| 测试类型 | 范围 | 速度 | 依赖 | 执行频率 |
|---------|------|------|------|----------|
| **单元测试** | Domain Layer | 快 | 无 | 每次提交 |
| **集成测试** | 跨层交互 | 中 | Mock | 每次提交 |
| **API 测试** | HTTP 接口 | 中 | Mock | 每次 PR |
| **E2E 测试** | 完整流程 | 慢 | 真实环境 | 发布前 |
---
## 单元测试
### 测试 Domain Service
单元测试专注于业务逻辑,使用 Mock Repository。
#### 示例:测试 ClusterService
```go
// internal/domain/service/cluster_service_test.go
package service_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ocdp-backend/internal/adapter/output/persistence/mock"
"ocdp-backend/internal/domain/service"
)
func TestClusterService_CreateCluster(t *testing.T) {
// Arrange
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
ctx := context.Background()
name := "Test Cluster"
host := "https://k8s.test:6443"
// Act
cluster, err := svc.CreateCluster(ctx, name, host, "desc", "ca", "cert", "key")
// Assert
require.NoError(t, err)
assert.NotEmpty(t, cluster.ID)
assert.Equal(t, name, cluster.Name)
assert.Equal(t, host, cluster.Host)
}
func TestClusterService_GetCluster(t *testing.T) {
// Arrange
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
ctx := context.Background()
// 先创建集群
created, _ := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
// Act
cluster, err := svc.GetCluster(ctx, created.ID)
// Assert
require.NoError(t, err)
assert.Equal(t, created.ID, cluster.ID)
assert.Equal(t, created.Name, cluster.Name)
}
func TestClusterService_GetCluster_NotFound(t *testing.T) {
// Arrange
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
ctx := context.Background()
// Act
cluster, err := svc.GetCluster(ctx, "non-existent-id")
// Assert
assert.Error(t, err)
assert.Nil(t, cluster)
}
func TestClusterService_ListClusters(t *testing.T) {
// Arrange
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
ctx := context.Background()
// 创建多个集群
svc.CreateCluster(ctx, "Cluster 1", "https://k8s1.test:6443", "", "", "", "")
svc.CreateCluster(ctx, "Cluster 2", "https://k8s2.test:6443", "", "", "", "")
// Act
clusters, err := svc.ListClusters(ctx)
// Assert
require.NoError(t, err)
assert.Len(t, clusters, 2)
}
func TestClusterService_DeleteCluster(t *testing.T) {
// Arrange
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
ctx := context.Background()
cluster, _ := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
// Act
err := svc.DeleteCluster(ctx, cluster.ID)
// Assert
require.NoError(t, err)
// 验证已删除
_, err = svc.GetCluster(ctx, cluster.ID)
assert.Error(t, err)
}
```
#### 示例:测试 AuthService
```go
// internal/domain/service/auth_service_test.go
package service_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ocdp-backend/internal/adapter/output/persistence/mock"
"ocdp-backend/internal/domain/service"
"ocdp-backend/internal/pkg/password"
"ocdp-backend/internal/pkg/jwt"
)
func TestAuthService_Register(t *testing.T) {
// Arrange
userRepo := mock.NewUserRepositoryMock()
hasher := password.NewBcryptHasher()
jwtGen := jwt.NewJWTGenerator("test-secret")
svc := service.NewAuthService(userRepo, hasher, jwtGen)
ctx := context.Background()
// Act
user, err := svc.Register(ctx, "testuser", "password123", "test@example.com")
// Assert
require.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.Equal(t, "testuser", user.Username)
assert.NotEqual(t, "password123", user.PasswordHash) // 密码已哈希
}
func TestAuthService_Login(t *testing.T) {
// Arrange
userRepo := mock.NewUserRepositoryMock()
hasher := password.NewBcryptHasher()
jwtGen := jwt.NewJWTGenerator("test-secret")
svc := service.NewAuthService(userRepo, hasher, jwtGen)
ctx := context.Background()
// 先注册用户
svc.Register(ctx, "testuser", "password123", "test@example.com")
// Act
response, err := svc.Login(ctx, "testuser", "password123")
// Assert
require.NoError(t, err)
assert.NotEmpty(t, response.AccessToken)
assert.NotEmpty(t, response.RefreshToken)
assert.Equal(t, "testuser", response.User.Username)
}
func TestAuthService_Login_WrongPassword(t *testing.T) {
// Arrange
userRepo := mock.NewUserRepositoryMock()
hasher := password.NewBcryptHasher()
jwtGen := jwt.NewJWTGenerator("test-secret")
svc := service.NewAuthService(userRepo, hasher, jwtGen)
ctx := context.Background()
svc.Register(ctx, "testuser", "password123", "test@example.com")
// Act
response, err := svc.Login(ctx, "testuser", "wrongpassword")
// Assert
assert.Error(t, err)
assert.Nil(t, response)
}
```
### 运行单元测试
```bash
# 运行所有单元测试
go test ./internal/domain/...
# 运行特定包
go test ./internal/domain/service
# 带覆盖率
go test -cover ./internal/domain/...
# 详细输出
go test -v ./internal/domain/...
# 生成覆盖率报告
go test -coverprofile=coverage.out ./internal/domain/...
go tool cover -html=coverage.out
```
---
## 集成测试
集成测试验证跨层交互,使用 Mock 适配器。
### 示例:测试 REST Handler + Service
```go
// internal/adapter/input/http/rest/cluster_handler_test.go
package rest_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ocdp-backend/internal/adapter/input/http/dto"
"ocdp-backend/internal/adapter/input/http/rest"
"ocdp-backend/internal/adapter/output/persistence/mock"
"ocdp-backend/internal/domain/service"
)
func setupClusterHandler() (*rest.ClusterHandler, *service.ClusterService) {
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
handler := rest.NewClusterHandler(svc)
return handler, svc
}
func TestClusterHandler_CreateCluster(t *testing.T) {
// Arrange
handler, _ := setupClusterHandler()
reqBody := dto.CreateClusterRequest{
Name: "Test Cluster",
Host: "https://k8s.test:6443",
Description: "Test cluster",
CAData: "ca-data",
CertData: "cert-data",
KeyData: "key-data",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/clusters", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
// Act
handler.CreateCluster(rec, req)
// Assert
assert.Equal(t, http.StatusCreated, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
assert.NotEmpty(t, response["id"])
assert.Equal(t, "Test Cluster", response["name"])
}
func TestClusterHandler_GetAllClusters(t *testing.T) {
// Arrange
handler, svc := setupClusterHandler()
// 创建测试数据
ctx := context.Background()
svc.CreateCluster(ctx, "Cluster 1", "https://k8s1.test:6443", "", "", "", "")
svc.CreateCluster(ctx, "Cluster 2", "https://k8s2.test:6443", "", "", "", "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/clusters", nil)
rec := httptest.NewRecorder()
// Act
handler.GetAllClusters(rec, req)
// Assert
assert.Equal(t, http.StatusOK, rec.Code)
var clusters []map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &clusters)
assert.Len(t, clusters, 2)
}
func TestClusterHandler_GetCluster(t *testing.T) {
// Arrange
handler, svc := setupClusterHandler()
ctx := context.Background()
cluster, _ := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/clusters/"+cluster.ID, nil)
req = mux.SetURLVars(req, map[string]string{"clusterId": cluster.ID})
rec := httptest.NewRecorder()
// Act
handler.GetCluster(rec, req)
// Assert
assert.Equal(t, http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
assert.Equal(t, cluster.ID, response["id"])
}
```
### 运行集成测试
```bash
# 使用 Mock 模式运行所有测试
ADAPTER_MODE=mock go test ./...
# 测试特定模块
go test ./internal/adapter/input/http/rest/...
# 并行测试
go test -parallel 4 ./...
```
---
## API 测试
使用 HTTP 客户端测试完整的 API 流程。
### 使用 cURL 测试
```bash
#!/bin/bash
# scripts/test-api.sh
BASE_URL="http://localhost:8080/api/v1"
# 健康检查
echo "=== Health Check ==="
curl -X GET http://localhost:8080/health
# 注册用户
echo "\n=== Register User ==="
curl -X POST $BASE_URL/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"password": "test123",
"email": "test@example.com"
}'
# 登录
echo "\n=== Login ==="
LOGIN_RESPONSE=$(curl -X POST $BASE_URL/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"password": "test123"
}')
TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.accessToken')
# 创建集群
echo "\n=== Create Cluster ==="
curl -X POST $BASE_URL/clusters \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Test Cluster",
"host": "https://k8s.test:6443",
"description": "Test cluster",
"caData": "LS0tLS...",
"certData": "LS0tLS...",
"keyData": "LS0tLS..."
}'
# 列出集群
echo "\n=== List Clusters ==="
curl -X GET $BASE_URL/clusters \
-H "Authorization: Bearer $TOKEN"
# 创建 Registry
echo "\n=== Create Registry ==="
curl -X POST $BASE_URL/registries \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Test Harbor",
"url": "https://harbor.test.com",
"username": "admin",
"password": "secret"
}'
# 列出 Registries
echo "\n=== List Registries ==="
curl -X GET $BASE_URL/registries \
-H "Authorization: Bearer $TOKEN"
```
### 使用 Go 测试 HTTP 接口
```go
// test/api/api_test.go
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const baseURL = "http://localhost:8080/api/v1"
func TestAPI_HealthCheck(t *testing.T) {
resp, err := http.Get("http://localhost:8080/health")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestAPI_AuthFlow(t *testing.T) {
// 1. 注册
registerBody := map[string]string{
"username": "apitest",
"password": "test123",
"email": "apitest@example.com",
}
body, _ := json.Marshal(registerBody)
resp, err := http.Post(baseURL+"/auth/register", "application/json", bytes.NewBuffer(body))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
// 2. 登录
loginBody := map[string]string{
"username": "apitest",
"password": "test123",
}
body, _ = json.Marshal(loginBody)
resp, err = http.Post(baseURL+"/auth/login", "application/json", bytes.NewBuffer(body))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var loginResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&loginResponse)
assert.NotEmpty(t, loginResponse["accessToken"])
assert.NotEmpty(t, loginResponse["refreshToken"])
}
func TestAPI_ClusterCRUD(t *testing.T) {
// 先获取 token
token := getAuthToken(t)
// 1. 创建集群
clusterBody := map[string]string{
"name": "API Test Cluster",
"host": "https://k8s.test:6443",
"description": "Created by API test",
"caData": "test-ca",
"certData": "test-cert",
"keyData": "test-key",
}
body, _ := json.Marshal(clusterBody)
req, _ := http.NewRequest(http.MethodPost, baseURL+"/clusters", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var cluster map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cluster)
clusterID := cluster["id"].(string)
// 2. 获取集群
req, _ = http.NewRequest(http.MethodGet, baseURL+"/clusters/"+clusterID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// 3. 删除集群
req, _ = http.NewRequest(http.MethodDelete, baseURL+"/clusters/"+clusterID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
}
func getAuthToken(t *testing.T) string {
// 登录并返回 token
loginBody := map[string]string{
"username": "admin",
"password": "admin123",
}
body, _ := json.Marshal(loginBody)
resp, _ := http.Post(baseURL+"/auth/login", "application/json", bytes.NewBuffer(body))
defer resp.Body.Close()
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
return response["accessToken"].(string)
}
```
---
## E2E 测试
端到端测试验证完整的用户场景。
### E2E 测试示例
```go
// test/e2e/deployment_test.go
package e2e_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestE2E_CompleteDeploymentFlow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping E2E test in short mode")
}
ctx := context.Background()
// 1. 注册用户
t.Log("Step 1: Register user")
user := registerUser(t, "e2euser", "password123")
assert.NotEmpty(t, user.ID)
// 2. 登录获取 token
t.Log("Step 2: Login")
token := login(t, "e2euser", "password123")
assert.NotEmpty(t, token)
// 3. 创建 Registry
t.Log("Step 3: Create registry")
registry := createRegistry(t, token, "Test Harbor", "https://harbor.test.com")
assert.NotEmpty(t, registry.ID)
// 4. 创建 Cluster
t.Log("Step 4: Create cluster")
cluster := createCluster(t, token, "Test K8s", "https://k8s.test:6443")
assert.NotEmpty(t, cluster.ID)
// 5. 部署应用
t.Log("Step 5: Deploy application")
instance := deployApp(t, token, cluster.ID, registry.ID, "nginx", "1.0.0")
assert.Equal(t, "deployed", instance.Status)
// 6. 等待应用就绪
t.Log("Step 6: Wait for application ready")
time.Sleep(10 * time.Second)
// 7. 检查应用状态
t.Log("Step 7: Check application status")
status := getInstanceStatus(t, token, cluster.ID, instance.ID)
assert.Equal(t, "deployed", status)
// 8. 升级应用
t.Log("Step 8: Upgrade application")
upgraded := upgradeApp(t, token, cluster.ID, instance.ID, "1.1.0")
assert.Equal(t, 2, upgraded.Revision)
// 9. 卸载应用
t.Log("Step 9: Uninstall application")
err := uninstallApp(t, token, cluster.ID, instance.ID)
assert.NoError(t, err)
// 10. 清理
t.Log("Step 10: Cleanup")
deleteCluster(t, token, cluster.ID)
deleteRegistry(t, token, registry.ID)
}
```
### 运行 E2E 测试
```bash
# 使用 Production 模式运行 E2E 测试
ADAPTER_MODE=production DATABASE_URL="..." go test ./test/e2e/... -timeout 30m
# 跳过 E2E 测试
go test -short ./...
```
---
## Mock 测试
### Mock Repository 示例
所有 Repository 都有 Mock 实现,位于 `internal/adapter/output/persistence/mock/`
```go
// internal/adapter/output/persistence/mock/cluster_repository_mock.go
package mock
import (
"context"
"fmt"
"sync"
"ocdp-backend/internal/domain/entity"
"ocdp-backend/internal/domain/repository"
)
type ClusterRepositoryMock struct {
clusters map[string]*entity.Cluster
mu sync.RWMutex
}
func NewClusterRepositoryMock() repository.ClusterRepository {
return &ClusterRepositoryMock{
clusters: make(map[string]*entity.Cluster),
}
}
func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Cluster) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.clusters[cluster.ID]; exists {
return fmt.Errorf("cluster already exists")
}
r.clusters[cluster.ID] = cluster
return nil
}
func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
r.mu.RLock()
defer r.mu.RUnlock()
cluster, exists := r.clusters[id]
if !exists {
return nil, fmt.Errorf("cluster not found")
}
return cluster, nil
}
func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) {
r.mu.RLock()
defer r.mu.RUnlock()
clusters := make([]*entity.Cluster, 0, len(r.clusters))
for _, cluster := range r.clusters {
clusters = append(clusters, cluster)
}
return clusters, nil
}
func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.clusters[id]; !exists {
return fmt.Errorf("cluster not found")
}
delete(r.clusters, id)
return nil
}
```
---
## 测试工具
### 推荐的测试库
```go
import (
"testing"
"github.com/stretchr/testify/assert" // 断言
"github.com/stretchr/testify/require" // 必需条件
"github.com/stretchr/testify/mock" // Mock 对象
"github.com/stretchr/testify/suite" // 测试套件
)
```
### 安装测试依赖
```bash
go get github.com/stretchr/testify
```
### 测试覆盖率
```bash
# 生成覆盖率报告
go test -coverprofile=coverage.out ./...
# 查看覆盖率
go tool cover -func=coverage.out
# HTML 报告
go tool cover -html=coverage.out -o coverage.html
```
### 性能测试
```go
func BenchmarkClusterService_CreateCluster(b *testing.B) {
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
}
}
// 运行性能测试
// go test -bench=. -benchmem ./internal/domain/service/
```
---
## 测试最佳实践
### 1. 测试命名规范
```go
// ✅ 好的命名
func TestClusterService_CreateCluster(t *testing.T)
func TestClusterService_CreateCluster_DuplicateName(t *testing.T)
func TestClusterHandler_GetCluster_NotFound(t *testing.T)
// ❌ 不好的命名
func TestCreate(t *testing.T)
func Test1(t *testing.T)
```
### 2. AAA 模式 (Arrange-Act-Assert)
```go
func TestExample(t *testing.T) {
// Arrange - 准备测试数据
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
// Act - 执行操作
cluster, err := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
// Assert - 验证结果
require.NoError(t, err)
assert.NotEmpty(t, cluster.ID)
}
```
### 3. 表驱动测试
```go
func TestClusterValidation(t *testing.T) {
tests := []struct {
name string
input string
want bool
wantErr bool
}{
{"valid URL", "https://k8s.example.com:6443", true, false},
{"invalid URL", "not-a-url", false, true},
{"empty URL", "", false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := validateClusterURL(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.want, got)
}
})
}
}
```
### 4. 使用测试辅助函数
```go
// test/helpers/helpers.go
package helpers
func CreateTestCluster(t *testing.T, svc *service.ClusterService) *entity.Cluster {
t.Helper()
cluster, err := svc.CreateCluster(
context.Background(),
"Test Cluster",
"https://k8s.test:6443",
"", "", "", "",
)
require.NoError(t, err)
return cluster
}
// 在测试中使用
func TestSomething(t *testing.T) {
cluster := helpers.CreateTestCluster(t, svc)
// ...
}
```
### 5. 清理测试数据
```go
func TestWithCleanup(t *testing.T) {
repo := mock.NewClusterRepositoryMock()
svc := service.NewClusterService(repo)
ctx := context.Background()
cluster, _ := svc.CreateCluster(ctx, "Test", "https://k8s.test:6443", "", "", "", "")
// 确保清理
t.Cleanup(func() {
svc.DeleteCluster(ctx, cluster.ID)
})
// 测试逻辑
// ...
}
```
### 6. 并行测试
```go
func TestParallel(t *testing.T) {
t.Parallel() // 标记为可并行
// 测试逻辑
}
```
### 7. 跳过测试
```go
func TestSomething(t *testing.T) {
if testing.Short() {
t.Skip("Skipping test in short mode")
}
// 长时间运行的测试
}
// 运行: go test -short
```
---
## CI/CD 集成
### GitHub Actions 示例
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ocdp_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Install dependencies
run: go mod download
- name: Run unit tests
run: go test -short -cover ./internal/domain/...
- name: Run integration tests (Mock)
env:
ADAPTER_MODE: mock
run: go test -short ./...
- name: Run integration tests (Production)
env:
ADAPTER_MODE: production
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ocdp_test?sslmode=disable
run: go test ./...
- name: Generate coverage report
run: |
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
```
---
## 相关文档
- [架构文档](architecture.md)
- [部署文档](deployment.md)
- [主 README](../README.md)
---
**Last Updated**: 2025-11-09
**API Version**: v1