obm/internal/validation/validation_test.go
MermaidMan 33d9a2cb2e deploy walkthrough, API validation, inference client, Hetzner provider
- Interactive deploy command with 8-step walkthrough:
  framework → provider → token → SSH → server → inference → tailscale → discord
- .env file generation from walkthrough config
- DeploymentConfig struct with framework-aware defaults
- Inference API client with validation for Venice, OpenRouter, OpenAI, Anthropic
- Hetzner Cloud provider: token validation, SSH key listing
- DotEnv parser/writer with schema validation
- Destroy command with confirmation prompt
- Validation subcommand for checking existing .env files
- All tests passing, go vet clean
2026-05-22 15:29:27 +00:00

547 lines
No EOL
14 KiB
Go

package validation
import (
"context"
"fmt"
"strings"
"testing"
"time"
)
// mockProvider implements Validatable for testing.
type mockProvider struct {
name string
checks []Check
}
func (m *mockProvider) ProviderName() string { return m.name }
func (m *mockProvider) Checks(ctx context.Context) []Check { return m.checks }
// mockCheck implements Check for testing.
type mockCheck struct {
name string
category CheckCategory
result CheckResult
}
func (m *mockCheck) Name() string { return m.name }
func (m *mockCheck) Category() CheckCategory { return m.category }
func (m *mockCheck) Run(ctx context.Context) CheckResult { return m.result }
func TestCheckResultPassed(t *testing.T) {
tests := []struct {
status Status
expected bool
}{
{Pass, true},
{Warn, true},
{Fail, false},
{Skip, false},
{Error, false},
}
for _, tc := range tests {
r := CheckResult{Status: tc.status}
if r.Passed() != tc.expected {
t.Errorf("CheckResult{Status: %s}.Passed() = %v, want %v", tc.status, r.Passed(), tc.expected)
}
}
}
func TestCheckResultIcon(t *testing.T) {
tests := []struct {
status Status
expected string
}{
{Pass, "✓"},
{Fail, "✗"},
{Warn, "!"},
{Skip, "—"},
{Error, "⚠"},
{Status("unknown"), "?"},
}
for _, tc := range tests {
r := CheckResult{Status: tc.status}
if r.Icon() != tc.expected {
t.Errorf("CheckResult{Status: %s}.Icon() = %q, want %q", tc.status, r.Icon(), tc.expected)
}
}
}
func TestCheckFunc(t *testing.T) {
ctx := context.Background()
cf := CheckFunc{
CategoryField: CategoryCredentials,
NameField: "test-check",
RunFunc: func(ctx context.Context) CheckResult {
return CheckResult{
Name: "test-check",
Category: CategoryCredentials,
Status: Pass,
Message: "check passed",
}
},
}
if cf.Name() != "test-check" {
t.Errorf("CheckFunc.Name() = %q, want %q", cf.Name(), "test-check")
}
if cf.Category() != CategoryCredentials {
t.Errorf("CheckFunc.Category() = %q, want %q", cf.Category(), CategoryCredentials)
}
result := cf.Run(ctx)
if result.Status != Pass {
t.Errorf("CheckFunc.Run().Status = %v, want %v", result.Status, Pass)
}
}
func TestReportSummary(t *testing.T) {
report := &Report{
Provider: "TestProvider",
Results: []CheckResult{
{Status: Pass, Name: "check1"},
{Status: Pass, Name: "check2"},
{Status: Fail, Name: "check3"},
{Status: Warn, Name: "check4"},
{Status: Skip, Name: "check5"},
},
}
summary := report.Summary()
if summary[Pass] != 2 {
t.Errorf("Summary[Pass] = %d, want 2", summary[Pass])
}
if summary[Fail] != 1 {
t.Errorf("Summary[Fail] = %d, want 1", summary[Fail])
}
if summary[Warn] != 1 {
t.Errorf("Summary[Warn] = %d, want 1", summary[Warn])
}
if summary[Skip] != 1 {
t.Errorf("Summary[Skip] = %d, want 1", summary[Skip])
}
}
func TestReportHasFailures(t *testing.T) {
tests := []struct {
name string
results []CheckResult
expected bool
}{
{
name: "all_pass",
results: []CheckResult{{Status: Pass}, {Status: Pass}},
expected: false,
},
{
name: "one_fail",
results: []CheckResult{{Status: Pass}, {Status: Fail}},
expected: true,
},
{
name: "one_error",
results: []CheckResult{{Status: Pass}, {Status: Pass}, {Status: Error}},
expected: true,
},
{
name: "with_warn_and_skip",
results: []CheckResult{{Status: Pass}, {Status: Warn}, {Status: Skip}},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
report := &Report{Results: tc.results}
if report.HasFailures() != tc.expected {
t.Errorf("HasFailures() = %v, want %v", report.HasFailures(), tc.expected)
}
})
}
}
func TestReportByCategory(t *testing.T) {
report := &Report{
Provider: "TestProvider",
Results: []CheckResult{
{Name: "token-auth", Category: CategoryCredentials, Status: Pass, Message: "token valid"},
{Name: "api-reach", Category: CategoryConnectivity, Status: Pass, Message: "API reachable"},
{Name: "ssh-keys", Category: CategorySSH, Status: Fail, Message: "no SSH keys"},
{Name: "token-format", Category: CategoryCredentials, Status: Pass, Message: "format OK"},
},
}
byCat := report.ByCategory()
if len(byCat[CategoryCredentials]) != 2 {
t.Errorf("ByCategory[Credentials] has %d items, want 2", len(byCat[CategoryCredentials]))
}
if len(byCat[CategorySSH]) != 1 {
t.Errorf("ByCategory[SSH] has %d items, want 1", len(byCat[CategorySSH]))
}
if len(byCat[CategoryQuota]) != 0 {
t.Errorf("ByCategory[Quota] has %d items, want 0", len(byCat[CategoryQuota]))
}
}
func TestReportFormat(t *testing.T) {
report := &Report{
Provider: "TestProvider",
Results: []CheckResult{
{Name: "token-auth", Category: CategoryCredentials, Status: Pass, Message: "Token authenticated"},
{Name: "ssh-keys", Category: CategorySSH, Status: Fail, Message: "No SSH keys configured"},
{Name: "regions", Category: CategoryServer, Status: Warn, Message: "Region not set, using default"},
},
TotalDuration: 150 * time.Millisecond,
}
output := report.Format()
// Verify key elements appear in output
if !containsAll(output, "TestProvider", "token-auth", "Token authenticated", "✓") {
t.Errorf("Format() missing expected output elements")
}
if !containsAll(output, "No SSH keys configured", "✗") {
t.Errorf("Format() missing FAIL elements")
}
if !containsAll(output, "Region not set", "!") {
t.Errorf("Format() missing WARN elements (icon '!')")
}
// Check for summary line
if !strings.Contains(output, "Total:") {
t.Errorf("Format() missing 'Total:' in output")
}
}
func containsAll(s string, substrs ...string) bool {
for _, sub := range substrs {
if !contains(s, sub) {
return false
}
}
return true
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || containsSubstring(s, sub))
}
func containsSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func TestRunner(t *testing.T) {
ctx := context.Background()
provider := &mockProvider{
name: "MockProvider",
checks: []Check{
&mockCheck{
name: "token-format",
category: CategoryCredentials,
result: CheckResult{Status: Pass, Message: "Token format valid"},
},
&mockCheck{
name: "token-auth",
category: CategoryCredentials,
result: CheckResult{Status: Pass, Message: "Token authenticated"},
},
&mockCheck{
name: "ssh-keys",
category: CategorySSH,
result: CheckResult{Status: Fail, Message: "No SSH keys found"},
},
},
}
runner := NewRunner(provider)
report := runner.Run(ctx)
if report.Provider != "MockProvider" {
t.Errorf("Report.Provider = %q, want %q", report.Provider, "MockProvider")
}
if len(report.Results) != 3 {
t.Errorf("Report has %d results, want 3", len(report.Results))
}
if !report.HasFailures() {
t.Error("Report.HasFailures() = false, expected true (one check failed)")
}
// Verify check names are set on results
for i, r := range report.Results {
if r.Name == "" {
t.Errorf("Result[%d].Name is empty, should be set from Check", i)
}
if r.Category == "" {
t.Errorf("Result[%d].Category is empty, should be set from Check", i)
}
}
}
func TestRunnerTimeout(t *testing.T) {
ctx := context.Background()
// Check that times out
timeoutCheck := &mockCheck{
name: "slow-check",
category: CategoryConnectivity,
result: CheckResult{Status: Error, Message: "context deadline exceeded"},
}
provider := &mockProvider{
name: "TimeoutProvider",
checks: []Check{timeoutCheck},
}
runner := NewRunner(provider)
runner.SetTimeout(50 * time.Millisecond)
report := runner.Run(ctx)
if len(report.Results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(report.Results))
}
if report.Results[0].Status != Error {
t.Errorf("Expected Error status, got %s", report.Results[0].Status)
}
}
func TestRunnerEmptyChecks(t *testing.T) {
ctx := context.Background()
provider := &mockProvider{
name: "EmptyProvider",
checks: []Check{},
}
runner := NewRunner(provider)
report := runner.Run(ctx)
if len(report.Results) != 0 {
t.Errorf("Expected 0 results, got %d", len(report.Results))
}
if report.HasFailures() {
t.Error("Empty report should not have failures")
}
}
func TestContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
// A check that would fail if context wasn't properly handled
check := CheckFunc{
NameField: "canceled-check",
CategoryField: CategoryCredentials,
RunFunc: func(ctx context.Context) CheckResult {
select {
case <-ctx.Done():
return CheckResult{Status: Error, Message: "context canceled"}
default:
return CheckResult{Status: Pass, Message: "check passed"}
}
},
}
provider := &mockProvider{
name: "CanceledProvider",
checks: []Check{&check},
}
runner := NewRunner(provider)
report := runner.Run(ctx)
if len(report.Results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(report.Results))
}
// The result depends on when the check runs — if after cancellation, it's Error
// This test verifies the context is properly propagated
if report.Results[0].Name != "canceled-check" {
t.Errorf("Expected name 'canceled-check', got %q", report.Results[0].Name)
}
}
func TestReportAllPassed(t *testing.T) {
tests := []struct {
name string
results []CheckResult
expected bool
}{
{
name: "all_pass",
results: []CheckResult{{Status: Pass}, {Status: Pass}, {Status: Pass}},
expected: true,
},
{
name: "pass_with_warns",
results: []CheckResult{{Status: Pass}, {Status: Warn}, {Status: Pass}},
expected: true,
},
{
name: "pass_with_skips",
results: []CheckResult{{Status: Pass}, {Status: Skip}, {Status: Pass}},
expected: true,
},
{
name: "pass_with_one_fail",
results: []CheckResult{{Status: Pass}, {Status: Fail}, {Status: Pass}},
expected: false,
},
{
name: "pass_with_one_error",
results: []CheckResult{{Status: Pass}, {Status: Error}, {Status: Pass}},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
report := &Report{Results: tc.results}
if report.AllPassed() != tc.expected {
t.Errorf("AllPassed() = %v, want %v", report.AllPassed(), tc.expected)
}
})
}
}
// ExampleCheck demonstrates creating a custom check.
func ExampleCheckFunc() {
ctx := context.Background()
tokenFormatCheck := CheckFunc{
CategoryField: CategoryCredentials,
NameField: "token-format",
RunFunc: func(ctx context.Context) CheckResult {
// In a real check, you'd validate the token format here
return CheckResult{
Name: "token-format",
Category: CategoryCredentials,
Status: Pass,
Message: "Token format is valid",
Detail: "32 characters, alphanumeric",
}
},
}
// Run the check
result := tokenFormatCheck.Run(ctx)
fmt.Printf("%s: %s\n", result.Status, result.Message)
// Output: PASS: Token format is valid
}
// Integration test: full runner with realistic mock
func TestRunnerIntegration(t *testing.T) {
ctx := context.Background()
// Simulate a realistic provider with multiple check categories
provider := &mockProvider{
name: "Hetzner Cloud",
checks: []Check{
// Credentials
&mockCheck{
name: "token-format",
category: CategoryCredentials,
result: CheckResult{Status: Pass, Message: "Token format valid"},
},
&mockCheck{
name: "token-auth",
category: CategoryCredentials,
result: CheckResult{Status: Pass, Message: "Token authenticated", Detail: "Account: acme-corp"},
},
// Connectivity
&mockCheck{
name: "api-reachability",
category: CategoryConnectivity,
result: CheckResult{Status: Pass, Message: "API endpoint reachable", Detail: "Latency: 45ms"},
},
// SSH Keys
&mockCheck{
name: "ssh-keys",
category: CategorySSH,
result: CheckResult{Status: Fail, Message: "No SSH keys registered in account"},
},
// Server Config
&mockCheck{
name: "location",
category: CategoryServer,
result: CheckResult{Status: Pass, Message: "Location 'fsn1' is valid"},
},
&mockCheck{
name: "server-type",
category: CategoryServer,
result: CheckResult{Status: Pass, Message: "Server type 'cpx21' is available"},
},
// Quota
&mockCheck{
name: "server-quota",
category: CategoryQuota,
result: CheckResult{Status: Warn, Message: "Near quota limit", Detail: "48/50 servers used"},
},
},
}
runner := NewRunner(provider)
report := runner.Run(ctx)
// Verify
if report.Provider != "Hetzner Cloud" {
t.Errorf("Provider name = %q, want 'Hetzner Cloud'", report.Provider)
}
if len(report.Results) != 7 {
t.Errorf("Expected 7 checks, got %d", len(report.Results))
}
if !report.HasFailures() {
t.Error("Expected failures (SSH check should fail)")
}
// Check each category is represented
cats := make(map[CheckCategory]bool)
for _, r := range report.Results {
cats[r.Category] = true
}
expectedCats := []CheckCategory{
CategoryCredentials, CategoryConnectivity, CategorySSH,
CategoryServer, CategoryQuota,
}
for _, cat := range expectedCats {
if !cats[cat] {
t.Errorf("Missing category: %s", cat)
}
}
// Verify output is not empty
output := report.Format()
if len(output) == 0 {
t.Error("Format() produced empty output")
}
// Verify specific elements in output
containsCheck(t, output, "Hetzner Cloud")
containsCheck(t, output, "token-auth")
containsCheck(t, output, "No SSH keys")
containsCheck(t, output, "Near quota")
containsCheck(t, output, "FAIL")
}
func containsCheck(t *testing.T, s, substr string) {
t.Helper()
if !strings.Contains(s, substr) {
t.Errorf("Expected substring %q not found in output", substr)
}
}