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

38 KiB
Raw Blame History

📚 API 与测试文档

💡 推荐使用 OpenAPI 规范

本项目现在提供标准的 OpenAPI 3.0 规范

  • 📖 交互式 API 文档: http://localhost:8080/api/docs (Swagger UI)
  • 📄 OpenAPI 规范文件: openapi.yaml
  • 🔧 在线测试: 在 Swagger UI 中直接测试所有 API
  • 🚀 客户端生成: 使用 OpenAPI 规范自动生成客户端代码

本文档 (Markdown 版本) 作为参考文档保留,内容与 OpenAPI 规范保持一致。 如需最新的 API 定义,请参考 openapi.yaml


目录

Part 1: API 文档

Part 2: 测试文档


Part 1: API 文档

基础信息

Base URL

http://localhost:8080/api/v1

健康检查

GET /health

# 响应
{
  "status": "healthy"
}

通用请求头

Content-Type: application/json
Authorization: Bearer <JWT_TOKEN>  # 部分接口需要

认证 API

用户注册

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"
}

用户登录

POST /api/v1/auth/login
Content-Type: application/json

{
  "username": "john",
  "password": "secret123"
}

# 响应 200
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
  "userId": "user-123",
  "username": "john"
}

刷新 Token

POST /api/v1/auth/refresh
Content-Type: application/json

{
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

# 响应 200
{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs...",
  "userId": "user-123",
  "username": "john"
}

集群管理 API

创建集群

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"
}

列出所有集群

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"
  }
]

获取集群详情

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"
}

更新集群

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"
}

删除集群

DELETE /api/v1/clusters/{clusterId}

# 响应 204 No Content

集群健康检查

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

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

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 详情

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

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

DELETE /api/v1/registries/{registryId}

# 响应 204 No Content

Registry 健康检查

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

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

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 详情

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.confighelm.chart
    • image: Docker/OCI ImagemediaType 包含 docker.container.imageoci.image
    • other: 其他类型
  • size: 总大小(字节)
  • createdAt: 创建时间ISO 8601 格式)

获取 Helm Chart Values Schema

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

安装应用

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"
}

列出应用实例

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"
  }
]

升级应用

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"
}

卸载应用

DELETE /api/v1/clusters/{clusterId}/instances/{instanceId}

# 响应 204 No Content

获取实例访问入口

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

列出集群监控信息

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"
  }
]

获取监控摘要

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"
}

响应格式

成功响应

单个资源:

{
  "id": "resource-123",
  "name": "Resource Name",
  "status": "active",
  "createdAt": "2025-11-09T10:00:00Z"
}

资源列表:

[
  {
    "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 服务不可用

错误处理

错误响应格式

{
  "error": "Error Title",
  "message": "Detailed error message",
  "code": "ERROR_CODE",
  "details": {...}
}

常见错误示例

400 Bad Request

{
  "error": "Invalid Request",
  "message": "Field 'name' is required"
}

401 Unauthorized

{
  "error": "Unauthorized",
  "message": "Invalid or expired token"
}

404 Not Found

{
  "error": "Not Found",
  "message": "Cluster with ID 'cluster-123' not found"
}

500 Internal Server Error

{
  "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

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

// 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)
}

运行单元测试

# 运行所有单元测试
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

// 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"])
}

运行集成测试

# 使用 Mock 模式运行所有测试
ADAPTER_MODE=mock go test ./...

# 测试特定模块
go test ./internal/adapter/input/http/rest/...

# 并行测试
go test -parallel 4 ./...

API 测试

使用 HTTP 客户端测试完整的 API 流程。

使用 cURL 测试

#!/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 接口

// 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 测试示例

// 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 测试

# 使用 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/

// 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
}

测试工具

推荐的测试库

import (
    "testing"
    
    "github.com/stretchr/testify/assert"     // 断言
    "github.com/stretchr/testify/require"    // 必需条件
    "github.com/stretchr/testify/mock"       // Mock 对象
    "github.com/stretchr/testify/suite"      // 测试套件
)

安装测试依赖

go get github.com/stretchr/testify

测试覆盖率

# 生成覆盖率报告
go test -coverprofile=coverage.out ./...

# 查看覆盖率
go tool cover -func=coverage.out

# HTML 报告
go tool cover -html=coverage.out -o coverage.html

性能测试

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. 测试命名规范

// ✅ 好的命名
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)

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. 表驱动测试

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. 使用测试辅助函数

// 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. 清理测试数据

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. 并行测试

func TestParallel(t *testing.T) {
    t.Parallel() // 标记为可并行
    
    // 测试逻辑
}

7. 跳过测试

func TestSomething(t *testing.T) {
    if testing.Short() {
        t.Skip("Skipping test in short mode")
    }
    
    // 长时间运行的测试
}

// 运行: go test -short

CI/CD 集成

GitHub Actions 示例

# .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

相关文档


Last Updated: 2025-11-09
API Version: v1