From 1ccffc85daf324bf2c11f5341d0d5a252bbce5d8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 12 Jun 2026 13:32:01 +0700 Subject: [PATCH] test(maintenance): add policy executor and handler tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Табличные тесты PolicyExecutor, ConfigProvider reload, memory CRUD политик и 503 для /v1/maintenance/* на memory-бэкенде без PostgreSQL. Co-authored-by: Cursor --- internal/httpapi/routes_maintenance_test.go | 46 +++++ internal/maintenance/config_provider_test.go | 43 +++++ internal/maintenance/policy_executor_test.go | 161 ++++++++++++++++++ .../store/memory_maintenance_policy_test.go | 96 +++++++++++ 4 files changed, 346 insertions(+) create mode 100644 internal/httpapi/routes_maintenance_test.go create mode 100644 internal/maintenance/config_provider_test.go create mode 100644 internal/maintenance/policy_executor_test.go create mode 100644 internal/store/memory_maintenance_policy_test.go diff --git a/internal/httpapi/routes_maintenance_test.go b/internal/httpapi/routes_maintenance_test.go new file mode 100644 index 0000000..7adc17c --- /dev/null +++ b/internal/httpapi/routes_maintenance_test.go @@ -0,0 +1,46 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMaintenancePoliciesMemoryBackend503(t *testing.T) { + srv, err := New(Options{SeedDemo: true, InsecureDev: true}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + handler := srv.Handler() + + tests := []struct { + method string + path string + body string + }{ + {http.MethodGet, "/v1/maintenance/policies", ""}, + {http.MethodPost, "/v1/maintenance/policies", `{"name":"x","table_name":"job_audit","schedule":"0 3 * * *"}`}, + {http.MethodPost, "/v1/maintenance/run", `{"policy_id":"00000000-0000-0000-0000-000000000001"}`}, + {http.MethodGet, "/v1/maintenance/config-audit", ""}, + } + + for _, tc := range tests { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + var req *http.Request + if tc.body != "" { + req = httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(tc.method, tc.path, nil) + } + req.Header.Set("Authorization", "Bearer dev") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + } +} diff --git a/internal/maintenance/config_provider_test.go b/internal/maintenance/config_provider_test.go new file mode 100644 index 0000000..be4b7f8 --- /dev/null +++ b/internal/maintenance/config_provider_test.go @@ -0,0 +1,43 @@ +package maintenance + +import ( + "context" + "testing" + + "evobgp/internal/store" +) + +func TestConfigProviderReloadAndSnapshot(t *testing.T) { + mem := store.NewMemory() + ret := 3600 + if _, err := mem.CreateMaintenancePolicy(&store.MaintenancePolicy{ + Name: "p1", + TableName: "job_audit", + Schedule: "0 3 * * *", + RetentionPeriodSec: &ret, + Enabled: true, + }); err != nil { + t.Fatal(err) + } + + cp := NewConfigProvider(mem) + if err := cp.Reload(context.Background()); err != nil { + t.Fatal(err) + } + snap := cp.Snapshot() + if len(snap) != 1 || snap[0].Name != "p1" { + t.Fatalf("snapshot: %+v", snap) + } + + newName := "p1-updated" + if _, err := mem.UpdateMaintenancePolicy(snap[0].ID, &store.MaintenancePolicyPatch{Name: &newName}); err != nil { + t.Fatal(err) + } + if err := cp.Reload(context.Background()); err != nil { + t.Fatal(err) + } + snap2 := cp.Snapshot() + if len(snap2) != 1 || snap2[0].Name != newName { + t.Fatalf("after reload: %+v", snap2) + } +} diff --git a/internal/maintenance/policy_executor_test.go b/internal/maintenance/policy_executor_test.go new file mode 100644 index 0000000..cc6b27e --- /dev/null +++ b/internal/maintenance/policy_executor_test.go @@ -0,0 +1,161 @@ +package maintenance + +import ( + "context" + "errors" + "strings" + "testing" + + "evobgp/internal/store" +) + +func validPolicy() *store.MaintenancePolicy { + ret := 86400 + return &store.MaintenancePolicy{ + ID: "11111111-1111-1111-1111-111111111111", + Name: "job audit", + TableName: "job_audit", + Condition: "true", + RetentionPeriodSec: &ret, + VacuumStrategy: store.VacuumStrategyNone, + Schedule: "0 3 * * *", + Enabled: true, + } +} + +func TestPolicyExecutorExecuteValidation(t *testing.T) { + ctx := context.Background() + mem := store.NewMemory() + base := validPolicy() + + tests := []struct { + name string + exec *PolicyExecutor + policy *store.MaintenancePolicy + wantErr error + contains string + }{ + { + name: "nil policy", + exec: &PolicyExecutor{Store: mem}, + policy: nil, + wantErr: store.ErrInvalidInput, + }, + { + name: "nil pool", + exec: &PolicyExecutor{Store: mem, Pool: nil}, + policy: base, + contains: "postgres not configured", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.exec.Execute(ctx, tc.policy, false) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("Execute() err=%v want %v", err, tc.wantErr) + } + return + } + if err == nil { + t.Fatal("Execute() expected error") + } + if tc.contains != "" && !strings.Contains(err.Error(), tc.contains) { + t.Fatalf("Execute() err=%q want substring %q", err, tc.contains) + } + }) + } +} + +func TestPolicyExecutorPreExecuteValidation(t *testing.T) { + p := validPolicy() + p.TableName = "tenant" + if err := ValidateTableName(p.TableName); err == nil { + t.Fatal("expected blocked table error") + } + p = validPolicy() + p.Condition = "1=1; DROP TABLE job_audit" + if err := ValidateCondition(p.Condition); err == nil { + t.Fatal("expected unsafe condition error") + } + p = validPolicy() + p.VacuumStrategy = "invalid" + if !store.ValidVacuumStrategy(p.VacuumStrategy) { + return + } + t.Fatal("expected invalid vacuum strategy") +} + +func TestPolicyAction(t *testing.T) { + ret := 3600 + tests := []struct { + name string + p *store.MaintenancePolicy + want string + }{ + {"nil", nil, "run"}, + {"cleanup only", &store.MaintenancePolicy{RetentionPeriodSec: &ret, VacuumStrategy: store.VacuumStrategyNone}, "cleanup"}, + {"vacuum only", &store.MaintenancePolicy{VacuumStrategy: store.VacuumStrategyVacuum}, "vacuum"}, + {"cleanup+vacuum", &store.MaintenancePolicy{MaxRows: &ret, VacuumStrategy: store.VacuumStrategyAnalyze}, "cleanup_vacuum"}, + {"noop run", &store.MaintenancePolicy{VacuumStrategy: store.VacuumStrategyNone}, "run"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := policyAction(tc.p); got != tc.want { + t.Fatalf("policyAction()=%q want %q", got, tc.want) + } + }) + } +} + +func TestVacuumKind(t *testing.T) { + tests := []struct { + strategy string + want string + }{ + {store.VacuumStrategyVacuum, "vacuum"}, + {store.VacuumStrategyAnalyze, "analyze"}, + {store.VacuumStrategyVacuumAnalyze, "vacuum_analyze"}, + {store.VacuumStrategyReindex, "reindex"}, + {"unknown", "vacuum"}, + } + for _, tc := range tests { + if got := vacuumKind(tc.strategy); got != tc.want { + t.Fatalf("vacuumKind(%q)=%q want %q", tc.strategy, got, tc.want) + } + } +} + +func TestRowsDeletedFromDetail(t *testing.T) { + tests := []struct { + name string + detail map[string]any + want int64 + }{ + {"nil", nil, 0}, + {"int64", map[string]any{"deleted": int64(42)}, 42}, + {"int", map[string]any{"deleted": 7}, 7}, + {"float64", map[string]any{"deleted": float64(3)}, 3}, + {"missing", map[string]any{"other": 1}, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := rowsDeletedFromDetail(tc.detail); got != tc.want { + t.Fatalf("rowsDeletedFromDetail()=%d want %d", got, tc.want) + } + }) + } +} + +func TestAdvisoryKeyStable(t *testing.T) { + a := advisoryKey("policy-a") + b := advisoryKey("policy-a") + c := advisoryKey("policy-b") + if a != b { + t.Fatal("advisory key not stable for same id") + } + if a == c { + t.Fatal("advisory key collision for different ids") + } +} diff --git a/internal/store/memory_maintenance_policy_test.go b/internal/store/memory_maintenance_policy_test.go new file mode 100644 index 0000000..3aaff6d --- /dev/null +++ b/internal/store/memory_maintenance_policy_test.go @@ -0,0 +1,96 @@ +package store + +import "testing" + +func TestMemoryMaintenancePolicyCRUD(t *testing.T) { + m := NewMemory() + ret := int(86400) + max := 5000 + created, err := m.CreateMaintenancePolicy(&MaintenancePolicy{ + Name: "audit cleanup", + TableName: "job_audit", + Condition: "status = 'succeeded'", + RetentionPeriodSec: &ret, + MaxRows: &max, + VacuumStrategy: VacuumStrategyVacuumAnalyze, + Schedule: "0 4 * * *", + Enabled: true, + DryRunEnabled: true, + }) + if err != nil { + t.Fatal(err) + } + if created.ID == "" { + t.Fatal("missing id") + } + + items, _, hasMore, err := m.ListMaintenancePolicies("", 10) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || hasMore { + t.Fatalf("list: len=%d hasMore=%v", len(items), hasMore) + } + + got, err := m.GetMaintenancePolicy(created.ID) + if err != nil { + t.Fatal(err) + } + if got.Name != "audit cleanup" || got.VacuumStrategy != VacuumStrategyVacuumAnalyze { + t.Fatalf("get: %+v", got) + } + + newName := "renamed" + disabled := false + updated, err := m.UpdateMaintenancePolicy(created.ID, &MaintenancePolicyPatch{ + Name: &newName, + Enabled: &disabled, + }) + if err != nil { + t.Fatal(err) + } + if updated.Name != newName || updated.Enabled { + t.Fatalf("update: %+v", updated) + } + + if err := m.TouchMaintenancePolicyRun(created.ID, "succeeded", ""); err != nil { + t.Fatal(err) + } + afterTouch, err := m.GetMaintenancePolicy(created.ID) + if err != nil { + t.Fatal(err) + } + if afterTouch.LastStatus != "succeeded" || afterTouch.LastRunAt == nil { + t.Fatalf("touch: %+v", afterTouch) + } + + if err := m.AppendMaintenancePolicyConfigAudit("op:test", created.ID, "update", map[string]any{"name": "old"}, map[string]any{"name": newName}); err != nil { + t.Fatal(err) + } + audit, next, hasMore, err := m.ListMaintenancePolicyConfigAudit("", 10) + if err != nil { + t.Fatal(err) + } + if len(audit) != 1 || audit[0].Action != "update" || next != "" || hasMore { + t.Fatalf("audit: %+v next=%q hasMore=%v", audit, next, hasMore) + } + + if err := m.DeleteMaintenancePolicy(created.ID); err != nil { + t.Fatal(err) + } + if _, err := m.GetMaintenancePolicy(created.ID); err != ErrNotFound { + t.Fatalf("after delete: %v", err) + } +} + +func TestCreateMaintenancePolicyInvalid(t *testing.T) { + m := NewMemory() + _, err := m.CreateMaintenancePolicy(&MaintenancePolicy{Name: "", TableName: "job_audit", Schedule: "0 3 * * *"}) + if err != ErrInvalidInput { + t.Fatalf("want ErrInvalidInput got %v", err) + } + _, err = m.CreateMaintenancePolicy(&MaintenancePolicy{Name: "x", TableName: "job_audit", Schedule: "0 3 * * *", VacuumStrategy: "bad"}) + if err != ErrInvalidInput { + t.Fatalf("want ErrInvalidInput got %v", err) + } +}