Phase 0 (项目骨架):
- main.go 改为最小骨架 (config + logging + gin + /healthz + 优雅退出)
- internal/{config,logging,storage,cluster,...}/ 目录占位
Phase 1 (配置 + 日志):
- internal/config: env 解析 + 必填校验 + token 隐藏的 String()
- internal/logging: slog multi-handler 双输出 (终端 text + 主文件 JSON)
+ StartToolCall per-tool 独立文件 (0600), tools 目录 0700
Phase 2 续 (存储层):
- internal/cluster: 16 字段 Cluster struct (AuthPassword json:"-")
- internal/storage: modernc.org/sqlite 接入, WAL 模式, schema 自动迁移
- ClusterRepo CRUD: Create/Get/List/Update/Delete + ErrNotFound
+ AuthPassword 空字符串 = 保留旧密码 (核心约定)
- 7 个单元测试全绿 (:memory: DB)
- created_at/updated_at 改纳秒精度, 消除 List 测试的 sleep 特殊 case
Co-Authored-By: Claude <noreply@anthropic.com>
307 lines
8.3 KiB
Go
307 lines
8.3 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"spark-mcp-go/internal/cluster"
|
|
)
|
|
|
|
func newClusterRepo(t *testing.T) *ClusterRepo {
|
|
t.Helper()
|
|
db, err := Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Open in-memory db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db.Clusters()
|
|
}
|
|
|
|
func testClusterA() *cluster.Cluster {
|
|
return &cluster.Cluster{
|
|
ID: "cluster-a",
|
|
Name: "Cluster Alpha",
|
|
RMURL: "http://rm-a.example.com:8088",
|
|
SHSURL: "http://shs-a.example.com:18080",
|
|
SparkSubmitExecuteBin: "/usr/bin/spark-submit",
|
|
IsActive: true,
|
|
AuthType: cluster.AuthBasic,
|
|
AuthUsername: "admin-a",
|
|
AuthPassword: "secret-1",
|
|
SSLVerify: true,
|
|
SSLCABundle: "/etc/ssl/ca-bundle-a.pem",
|
|
URLAllowlist: []string{"*.a.example.com", "http://hist-a.example.com/*"},
|
|
DefaultSubmitArgs: []string{"--master=yarn", "--deploy-mode=cluster"},
|
|
RateLimitPerMin: 10,
|
|
}
|
|
}
|
|
|
|
func assertClusterEqual(t *testing.T, got, want *cluster.Cluster) {
|
|
t.Helper()
|
|
|
|
if got.ID != want.ID {
|
|
t.Errorf("ID: got %q, want %q", got.ID, want.ID)
|
|
}
|
|
if got.Name != want.Name {
|
|
t.Errorf("Name: got %q, want %q", got.Name, want.Name)
|
|
}
|
|
if got.RMURL != want.RMURL {
|
|
t.Errorf("RMURL: got %q, want %q", got.RMURL, want.RMURL)
|
|
}
|
|
if got.SHSURL != want.SHSURL {
|
|
t.Errorf("SHSURL: got %q, want %q", got.SHSURL, want.SHSURL)
|
|
}
|
|
if got.SparkSubmitExecuteBin != want.SparkSubmitExecuteBin {
|
|
t.Errorf("SparkSubmitExecuteBin: got %q, want %q", got.SparkSubmitExecuteBin, want.SparkSubmitExecuteBin)
|
|
}
|
|
if got.IsActive != want.IsActive {
|
|
t.Errorf("IsActive: got %v, want %v", got.IsActive, want.IsActive)
|
|
}
|
|
if got.AuthType != want.AuthType {
|
|
t.Errorf("AuthType: got %q, want %q", got.AuthType, want.AuthType)
|
|
}
|
|
if got.AuthUsername != want.AuthUsername {
|
|
t.Errorf("AuthUsername: got %q, want %q", got.AuthUsername, want.AuthUsername)
|
|
}
|
|
if got.AuthPassword != want.AuthPassword {
|
|
t.Errorf("AuthPassword: got %q, want %q", got.AuthPassword, want.AuthPassword)
|
|
}
|
|
if got.SSLVerify != want.SSLVerify {
|
|
t.Errorf("SSLVerify: got %v, want %v", got.SSLVerify, want.SSLVerify)
|
|
}
|
|
if got.SSLCABundle != want.SSLCABundle {
|
|
t.Errorf("SSLCABundle: got %q, want %q", got.SSLCABundle, want.SSLCABundle)
|
|
}
|
|
if !reflect.DeepEqual(got.URLAllowlist, want.URLAllowlist) {
|
|
t.Errorf("URLAllowlist: got %v, want %v", got.URLAllowlist, want.URLAllowlist)
|
|
}
|
|
if !reflect.DeepEqual(got.DefaultSubmitArgs, want.DefaultSubmitArgs) {
|
|
t.Errorf("DefaultSubmitArgs: got %v, want %v", got.DefaultSubmitArgs, want.DefaultSubmitArgs)
|
|
}
|
|
if got.RateLimitPerMin != want.RateLimitPerMin {
|
|
t.Errorf("RateLimitPerMin: got %d, want %d", got.RateLimitPerMin, want.RateLimitPerMin)
|
|
}
|
|
|
|
if !got.CreatedAt.Equal(want.CreatedAt) {
|
|
if got.CreatedAt.Unix() != want.CreatedAt.Unix() {
|
|
t.Errorf("CreatedAt: got %v, want %v", got.CreatedAt, want.CreatedAt)
|
|
}
|
|
}
|
|
if !got.UpdatedAt.Equal(want.UpdatedAt) {
|
|
if got.UpdatedAt.Unix() != want.UpdatedAt.Unix() {
|
|
t.Errorf("UpdatedAt: got %v, want %v", got.UpdatedAt, want.UpdatedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterRepo_Create(t *testing.T) {
|
|
repo := newClusterRepo(t)
|
|
ctx := context.Background()
|
|
|
|
want := testClusterA()
|
|
if err := repo.Create(ctx, want); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, want.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get after Create: %v", err)
|
|
}
|
|
assertClusterEqual(t, got, want)
|
|
}
|
|
|
|
func TestClusterRepo_Get(t *testing.T) {
|
|
repo := newClusterRepo(t)
|
|
ctx := context.Background()
|
|
|
|
want := testClusterA()
|
|
if err := repo.Create(ctx, want); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, want.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
assertClusterEqual(t, got, want)
|
|
|
|
if _, err := repo.Get(ctx, "nope"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Get missing cluster: got %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestClusterRepo_List(t *testing.T) {
|
|
repo := newClusterRepo(t)
|
|
ctx := context.Background()
|
|
|
|
ids := []string{"cluster-a", "cluster-b", "cluster-c"}
|
|
for _, id := range ids {
|
|
c := testClusterA()
|
|
c.ID = id
|
|
c.Name = id
|
|
if err := repo.Create(ctx, c); err != nil {
|
|
t.Fatalf("Create %s: %v", id, err)
|
|
}
|
|
// Sub-second precision in stored timestamps gives us a stable
|
|
// ORDER BY DESC without artificial delays.
|
|
}
|
|
|
|
got, err := repo.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(got) != len(ids) {
|
|
t.Fatalf("List len: got %d, want %d", len(got), len(ids))
|
|
}
|
|
|
|
// List must be ordered by created_at DESC: last created first.
|
|
wantOrder := []string{"cluster-c", "cluster-b", "cluster-a"}
|
|
for i, c := range got {
|
|
if c.ID != wantOrder[i] {
|
|
t.Errorf("List order[%d]: got %q, want %q", i, c.ID, wantOrder[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterRepo_Update(t *testing.T) {
|
|
repo := newClusterRepo(t)
|
|
ctx := context.Background()
|
|
|
|
c := testClusterA()
|
|
if err := repo.Create(ctx, c); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
stored, err := repo.Get(ctx, c.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get before Update: %v", err)
|
|
}
|
|
|
|
stored.Name = "Cluster Alpha Updated"
|
|
stored.RMURL = "http://rm-a-new.example.com:8088"
|
|
stored.IsActive = false
|
|
stored.RateLimitPerMin = 20
|
|
// Intentionally leave AuthPassword empty: must preserve existing password.
|
|
stored.AuthPassword = ""
|
|
|
|
if err := repo.Update(ctx, stored); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, c.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get after Update: %v", err)
|
|
}
|
|
|
|
if got.Name != stored.Name {
|
|
t.Errorf("Name: got %q, want %q", got.Name, stored.Name)
|
|
}
|
|
if got.RMURL != stored.RMURL {
|
|
t.Errorf("RMURL: got %q, want %q", got.RMURL, stored.RMURL)
|
|
}
|
|
if got.IsActive != false {
|
|
t.Errorf("IsActive: got %v, want false", got.IsActive)
|
|
}
|
|
if got.RateLimitPerMin != 20 {
|
|
t.Errorf("RateLimitPerMin: got %d, want 20", got.RateLimitPerMin)
|
|
}
|
|
if got.AuthPassword != c.AuthPassword {
|
|
t.Errorf("AuthPassword should be preserved: got %q, want %q", got.AuthPassword, c.AuthPassword)
|
|
}
|
|
|
|
// Update a non-existing cluster must return ErrNotFound.
|
|
missing := testClusterA()
|
|
missing.ID = "missing"
|
|
if err := repo.Update(ctx, missing); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Update missing cluster: got %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestClusterRepo_UpdatePassword(t *testing.T) {
|
|
repo := newClusterRepo(t)
|
|
ctx := context.Background()
|
|
|
|
c := testClusterA()
|
|
c.AuthPassword = "old"
|
|
if err := repo.Create(ctx, c); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
stored, err := repo.Get(ctx, c.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get before Update: %v", err)
|
|
}
|
|
if stored.AuthPassword != "old" {
|
|
t.Fatalf("initial password: got %q, want %q", stored.AuthPassword, "old")
|
|
}
|
|
|
|
stored.AuthPassword = "new"
|
|
if err := repo.Update(ctx, stored); err != nil {
|
|
t.Fatalf("Update password: %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, c.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get after Update: %v", err)
|
|
}
|
|
if got.AuthPassword != "new" {
|
|
t.Errorf("password: got %q, want %q", got.AuthPassword, "new")
|
|
}
|
|
}
|
|
|
|
func TestClusterRepo_UpdateEmptyPasswordKeepsOldPassword(t *testing.T) {
|
|
repo := newClusterRepo(t)
|
|
ctx := context.Background()
|
|
|
|
c := testClusterA()
|
|
c.AuthPassword = "keep-me"
|
|
if err := repo.Create(ctx, c); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
stored, err := repo.Get(ctx, c.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get before Update: %v", err)
|
|
}
|
|
stored.AuthPassword = ""
|
|
if err := repo.Update(ctx, stored); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, c.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get after Update: %v", err)
|
|
}
|
|
if got.AuthPassword != "keep-me" {
|
|
t.Errorf("empty password should keep old value: got %q, want %q", got.AuthPassword, "keep-me")
|
|
}
|
|
}
|
|
|
|
func TestClusterRepo_Delete(t *testing.T) {
|
|
repo := newClusterRepo(t)
|
|
ctx := context.Background()
|
|
|
|
c := testClusterA()
|
|
if err := repo.Create(ctx, c); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
if err := repo.Delete(ctx, c.ID); err != nil {
|
|
t.Fatalf("Delete existing: %v", err)
|
|
}
|
|
|
|
if _, err := repo.Get(ctx, c.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Get after Delete: got %v, want ErrNotFound", err)
|
|
}
|
|
|
|
if err := repo.Delete(ctx, c.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Delete again: got %v, want ErrNotFound", err)
|
|
}
|
|
|
|
if err := repo.Delete(ctx, "nope"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Delete missing: got %v, want ErrNotFound", err)
|
|
}
|
|
}
|