Files
sub2api-cn-relay-manager/internal/app/app_test.go

1260 lines
50 KiB
Go

package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/provision"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
func TestServeExposesHealthz(t *testing.T) {
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), nil)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(ctx, listener)
}()
response := waitForHealthz(t, "http://"+listener.Addr().String()+"/healthz")
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if string(body) != "ok" {
t.Fatalf("healthz body = %q, want %q", string(body), "ok")
}
cancel()
if err := <-errCh; err != nil {
t.Fatalf("Serve() error = %v, want nil", err)
}
}
func TestRunReturnsAfterContextCancellation(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), func(string, string) (net.Listener, error) {
return listener, nil
})
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
response := waitForHealthz(t, "http://"+listener.Addr().String()+"/healthz")
response.Body.Close()
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run() did not return after context cancellation")
}
}
func TestRunReturnsListenError(t *testing.T) {
wantErr := errors.New("listen failed")
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), func(string, string) (net.Listener, error) {
return nil, wantErr
})
err := server.Run(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Run() error = %v, want %v", err, wantErr)
}
}
func TestServeReturnsListenerError(t *testing.T) {
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), nil)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
if err := listener.Close(); err != nil {
t.Fatalf("listener.Close() error = %v", err)
}
err = server.Serve(context.Background(), listener)
if err == nil {
t.Fatal("Serve() error = nil, want listener startup error")
}
}
func TestAPIRejectsMissingAdminToken(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/pack.zip"}, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusUnauthorized)
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
}
func TestAPIInstallPackReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
InstallPack: func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) {
return provision.PackInstallResult{
Pack: sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0"},
HostVersion: "0.1.126",
Providers: []sqlite.Provider{{ProviderID: "deepseek", DisplayName: "DeepSeek"}},
}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "pack_id", "openai-cn-pack")
assertJSONContains(t, response.Body().Bytes(), "host_version", "0.1.126")
}
func TestAPIPreviewProviderReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
PreviewProvider: func(_ context.Context, req PreviewProviderRequest) (provision.PreviewReport, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", req.ProviderID)
}
return provision.PreviewReport{
AcceptedKeys: []string{"k1", "k2"},
Names: provision.ResourceNames{Group: "g", Channel: "c", Plan: "p"},
Decisions: map[string]provision.PreviewDecision{
"group": {Action: provision.PreviewActionCreate},
},
}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/preview-import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "keys": []string{"k1", "k2"}, "mode": "partial"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "accepted_keys_count", float64(2))
}
func TestAPIImportProviderReturnsConflictWithBatchStatus(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ImportProvider: func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) {
return provision.RuntimeImportResult{
BatchID: 12,
Report: provision.ImportReport{
BatchStatus: provision.BatchStatusFailed,
ProviderStatus: provision.ProviderStatusFailed,
AccessStatus: provision.AccessStatusBroken,
Accounts: []provision.AccountImportResult{{}},
},
}, errors.New("strict import failed")
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "keys": []string{"k1"}, "mode": "strict", "access_mode": "self_service", "access_api_key": "user-key"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusConflict)
assertJSONContains(t, response.Body().Bytes(), "batch_id", float64(12))
assertJSONContains(t, response.Body().Bytes(), "batch_status", provision.BatchStatusFailed)
}
func TestAPIBatchDetailReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
BatchDetail: func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error) {
return provision.BatchDetailResult{
Batch: sqlite.ImportBatch{ID: 7, BatchStatus: "running", AccessStatus: "pending"},
Items: []sqlite.ImportBatchItem{{ID: 1, KeyFingerprint: "sha256:abc", AccountStatus: "passed"}},
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/import-batches/7", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "batch.batch_status", "running")
assertJSONContains(t, response.Body().Bytes(), "items_count", float64(1))
}
func TestAPIProviderStatusReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderStatus: func(_ context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", req.ProviderID)
}
if req.PackID != "openai-cn-pack" {
t.Fatalf("PackID = %q, want openai-cn-pack", req.PackID)
}
return provision.ProviderSnapshot{
Host: sqlite.Host{HostID: "host-1", BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126"},
Pack: sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0"},
Provider: sqlite.Provider{ProviderID: "deepseek", DisplayName: "DeepSeek", Platform: "openai"},
Batch: sqlite.ImportBatch{ID: 7, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady, Mode: provision.ImportModeStrict},
ProviderStatus: "drifted",
LatestAccessStatus: provision.AccessStatusSelfServiceReady,
LatestReconcileStatus: "drifted",
LatestReconcileSummary: map[string]any{"missing_count": 1},
ManagedResources: []sqlite.ManagedResource{{}, {}},
AccessClosures: []sqlite.AccessClosureRecord{{}},
ReconcileRuns: []sqlite.ReconcileRun{{}},
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/status?pack_id=openai-cn-pack", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "provider_status", "drifted")
assertJSONContains(t, response.Body().Bytes(), "managed_resources_count", float64(2))
assertJSONContains(t, response.Body().Bytes(), "latest_reconcile_summary.missing_count", float64(1))
}
func TestAPIProviderAccessStatusReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderAccessStatus: func(_ context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", req.ProviderID)
}
return provision.ProviderSnapshot{
Pack: sqlite.Pack{PackID: "openai-cn-pack"},
Provider: sqlite.Provider{ProviderID: "deepseek"},
Batch: sqlite.ImportBatch{ID: 7, AccessStatus: provision.AccessStatusSelfServiceReady},
LatestAccessStatus: provision.AccessStatusSelfServiceReady,
AccessClosures: []sqlite.AccessClosureRecord{{ID: 2, ClosureType: provision.AccessModeSelfService, Status: provision.AccessStatusSelfServiceReady, DetailsJSON: `{"ok":true}`}},
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/access/status", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "latest_access_status", provision.AccessStatusSelfServiceReady)
assertJSONContains(t, response.Body().Bytes(), "closures_count", float64(1))
if !strings.Contains(response.Body().String(), `"closure_type":"self_service"`) {
t.Fatalf("access status payload missing closure type: %s", response.Body().String())
}
}
func TestAPIProviderResourcesReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderResources: func(_ context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", req.ProviderID)
}
return provision.ProviderSnapshot{
Pack: sqlite.Pack{PackID: "openai-cn-pack"},
Provider: sqlite.Provider{ProviderID: "deepseek"},
Batch: sqlite.ImportBatch{ID: 7},
ManagedResources: []sqlite.ManagedResource{{ID: 1, ResourceType: "group", HostResourceID: "group-1", ResourceName: "deepseek-group"}},
AccessClosures: []sqlite.AccessClosureRecord{{ID: 2, ClosureType: provision.AccessModeSelfService, Status: provision.AccessStatusSelfServiceReady, DetailsJSON: `{"ok":true}`}},
ReconcileRuns: []sqlite.ReconcileRun{{ID: 3, Status: "active", SummaryJSON: `{"missing_count":0}`}},
}, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/resources", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "provider_id", "deepseek")
assertJSONContains(t, response.Body().Bytes(), "pack_id", "openai-cn-pack")
if !strings.Contains(response.Body().String(), `"resource_type":"group"`) {
t.Fatalf("resources payload missing group resource: %s", response.Body().String())
}
if !strings.Contains(response.Body().String(), `"status":"self_service_ready"`) {
t.Fatalf("resources payload missing access closure status: %s", response.Body().String())
}
if !strings.Contains(response.Body().String(), `"summary_json":"{\"missing_count\":0}"`) {
t.Fatalf("resources payload missing reconcile summary: %s", response.Body().String())
}
}
func TestAPIRollbackProviderReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) {
return provision.RollbackReport{AccountsDeleted: 2, PlansDeleted: 1, ChannelsDeleted: 1, GroupsDeleted: 1}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/rollback", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "deleted_accounts", float64(2))
assertJSONContains(t, response.Body().Bytes(), "provider_id", "deepseek")
}
func TestAPIReconcileProviderReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ReconcileProvider: func(_ context.Context, req ReconcileProviderRequest) (provision.ReconcileResult, error) {
if req.AccessAPIKey != "user-key" {
t.Fatalf("AccessAPIKey = %q, want user-key", req.AccessAPIKey)
}
return provision.ReconcileResult{BatchID: 7, Status: "drifted", MissingCount: 1, ExtraCount: 2, ProbeFailureCount: 1, AccessStatus: provision.AccessStatusBroken, Summary: map[string]any{"probe_failures": 1}}, nil
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/reconcile", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "access_api_key": "user-key"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "status", "drifted")
assertJSONContains(t, response.Body().Bytes(), "missing_count", float64(1))
assertJSONContains(t, response.Body().Bytes(), "summary.probe_failures", float64(1))
}
func waitForHealthz(t *testing.T, url string) *http.Response {
t.Helper()
client := &http.Client{Timeout: 100 * time.Millisecond}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
response, err := client.Get(url)
if err == nil && response.StatusCode == http.StatusOK {
return response
}
if response != nil {
response.Body.Close()
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("health endpoint %q was not reachable before deadline", url)
return nil
}
func httptestRequest(t *testing.T, method, path string, body any, token string) *http.Request {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
request, err := http.NewRequest(method, path, bytes.NewReader(payload))
if err != nil {
t.Fatalf("http.NewRequest() error = %v", err)
}
request.Header.Set("Content-Type", "application/json")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
return request
}
func httptestRecorder(handler http.Handler, request *http.Request) *responseRecorder {
recorder := &responseRecorder{header: make(http.Header)}
handler.ServeHTTP(recorder, request)
return recorder
}
type responseRecorder struct {
header http.Header
body bytes.Buffer
code int
}
func (r *responseRecorder) Header() http.Header { return r.header }
func (r *responseRecorder) Write(body []byte) (int, error) { return r.body.Write(body) }
func (r *responseRecorder) WriteHeader(statusCode int) { r.code = statusCode }
func (r *responseRecorder) Body() *bytes.Buffer { return &r.body }
func assertStatusCode(t *testing.T, recorder *responseRecorder, want int) {
t.Helper()
if recorder.code != want {
t.Fatalf("status code = %d, want %d; body=%s", recorder.code, want, recorder.body.String())
}
}
func TestServerAddrReturnsConfiguredAddress(t *testing.T) {
server := NewServer("127.0.0.1:9999", nil, nil)
if got := server.Addr(); got != "127.0.0.1:9999" {
t.Fatalf("Addr() = %q, want %q", got, "127.0.0.1:9999")
}
}
func TestClassifyError(t *testing.T) {
tests := []struct {
name string
err error
wantStatusCode int
wantCode string
wantUpstream int
}{
{name: "nil", err: nil},
{name: "http error passthrough", err: &httpError{StatusCode: http.StatusTeapot, Code: "teapot", Message: "brew"}, wantStatusCode: http.StatusTeapot, wantCode: "teapot"},
{name: "upstream error", err: &sub2api.HTTPError{Method: http.MethodGet, Path: "/x", StatusCode: http.StatusForbidden, Body: "nope"}, wantStatusCode: http.StatusBadGateway, wantCode: "host_request_failed", wantUpstream: http.StatusForbidden},
{name: "pack conflict already installed", err: errors.New("pack already installed"), wantStatusCode: http.StatusConflict, wantCode: "pack_conflict"},
{name: "pack conflict checksum drift", err: errors.New("checksum drift detected"), wantStatusCode: http.StatusConflict, wantCode: "pack_conflict"},
{name: "reconcile blocked rolled_back", err: errors.New("latest import batch is rolled_back; run import again before reconcile"), wantStatusCode: http.StatusConflict, wantCode: "batch_not_reconcilable"},
{name: "not found generic", err: errors.New("host x not found"), wantStatusCode: http.StatusNotFound, wantCode: "not_found"},
{name: "provider not found", err: errors.New("provider \"deepseek\" not found in pack \"openai\""), wantStatusCode: http.StatusBadRequest, wantCode: "provider_not_found"},
{name: "bad request pack path", err: errors.New("pack path is required"), wantStatusCode: http.StatusBadRequest, wantCode: "bad_request"},
{name: "bad request decode", err: errors.New("decode pack.json failed"), wantStatusCode: http.StatusBadRequest, wantCode: "bad_request"},
{name: "internal error", err: errors.New("boom"), wantStatusCode: http.StatusInternalServerError, wantCode: "internal_error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyError(tt.err)
if tt.err == nil {
if got != nil {
t.Fatalf("classifyError(nil) = %#v, want nil", got)
}
return
}
if got == nil {
t.Fatal("classifyError() = nil, want error")
}
if got.StatusCode != tt.wantStatusCode {
t.Fatalf("StatusCode = %d, want %d", got.StatusCode, tt.wantStatusCode)
}
if got.Code != tt.wantCode {
t.Fatalf("Code = %q, want %q", got.Code, tt.wantCode)
}
if got.UpstreamStatus != tt.wantUpstream {
t.Fatalf("UpstreamStatus = %d, want %d", got.UpstreamStatus, tt.wantUpstream)
}
})
}
}
func TestWriteHTTPError(t *testing.T) {
t.Run("default error when nil", func(t *testing.T) {
recorder := &responseRecorder{header: make(http.Header)}
writeHTTPError(recorder, nil)
assertStatusCode(t, recorder, http.StatusInternalServerError)
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", got)
}
assertJSONContains(t, recorder.Body().Bytes(), "error.code", "internal_error")
assertJSONContains(t, recorder.Body().Bytes(), "error.message", "internal server error")
})
t.Run("writes provided error", func(t *testing.T) {
recorder := &responseRecorder{header: make(http.Header)}
writeHTTPError(recorder, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "invalid input", UpstreamStatus: http.StatusConflict})
assertStatusCode(t, recorder, http.StatusBadRequest)
assertJSONContains(t, recorder.Body().Bytes(), "error.code", "bad_request")
assertJSONContains(t, recorder.Body().Bytes(), "error.upstream_status", float64(http.StatusConflict))
})
}
func TestDecodeJSON(t *testing.T) {
t.Run("success", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"host_base_url":"https://example.com","pack_path":"/tmp/pack.zip"}`))
var got InstallPackRequest
if err := decodeJSON(request, &got); err != nil {
t.Fatalf("decodeJSON() error = %v, want nil", err)
}
if got.HostBaseURL != "https://example.com" || got.PackPath != "/tmp/pack.zip" {
t.Fatalf("decoded request = %#v, want expected fields", got)
}
})
t.Run("rejects unknown fields", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"host_base_url":"https://example.com","unknown":true}`))
var got InstallPackRequest
err := decodeJSON(request, &got)
if err == nil {
t.Fatal("decodeJSON() error = nil, want error")
}
if err.StatusCode != http.StatusBadRequest || err.Code != "bad_request" {
t.Fatalf("decodeJSON() = %#v, want bad_request", err)
}
if !strings.Contains(err.Message, "unknown field") {
t.Fatalf("Message = %q, want unknown field", err.Message)
}
})
t.Run("rejects trailing non-object payload", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"host_base_url":"https://example.com"}[]`))
var got InstallPackRequest
err := decodeJSON(request, &got)
if err == nil {
t.Fatal("decodeJSON() error = nil, want error")
}
if err.Message != "request body must contain a single JSON object" {
t.Fatalf("Message = %q, want single object error", err.Message)
}
})
}
func TestWriteJSON(t *testing.T) {
recorder := &responseRecorder{header: make(http.Header)}
writeJSON(recorder, http.StatusCreated, map[string]any{"ok": true, "count": 2})
assertStatusCode(t, recorder, http.StatusCreated)
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", got)
}
assertJSONContains(t, recorder.Body().Bytes(), "ok", true)
assertJSONContains(t, recorder.Body().Bytes(), "count", float64(2))
}
func TestFindProvider(t *testing.T) {
loaded := pack.LoadedPack{
Manifest: pack.Manifest{PackID: "openai-cn-pack"},
Providers: []pack.ProviderManifest{
{ProviderID: "deepseek", DisplayName: "DeepSeek"},
{ProviderID: "openai", DisplayName: "OpenAI"},
},
}
provider, err := findProvider(loaded, " deepseek ")
if err != nil {
t.Fatalf("findProvider() error = %v, want nil", err)
}
if provider.ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want deepseek", provider.ProviderID)
}
_, err = findProvider(loaded, "missing")
if err == nil {
t.Fatal("findProvider() error = nil, want error")
}
if !strings.Contains(err.Error(), `provider "missing" not found in pack "openai-cn-pack"`) {
t.Fatalf("findProvider() error = %v, want provider not found message", err)
}
}
func TestAPIRequiresConfiguredAdminToken(t *testing.T) {
handler := NewAPIHandler("", ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com"}, "any-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusInternalServerError)
assertJSONContains(t, response.Body().Bytes(), "error.code", "server_misconfigured")
}
func TestAPIBatchDetailRejectsInvalidBatchID(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{BatchDetail: func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error) {
t.Fatal("BatchDetail should not be called for invalid batch id")
return provision.BatchDetailResult{}, nil
}})
request := httptestRequest(t, http.MethodGet, "/api/import-batches/not-a-number", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.code", "bad_request")
assertJSONContains(t, response.Body().Bytes(), "error.message", "batch_id must be a positive integer")
}
func TestAPIInstallPackRejectsInvalidJSON(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{InstallPack: func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) {
t.Fatal("InstallPack should not be called for invalid JSON")
return provision.PackInstallResult{}, nil
}})
request, err := http.NewRequest(http.MethodPost, "/api/packs/install", strings.NewReader(`{"host_base_url":"https://sub2api.example.com","unknown":true}`))
if err != nil {
t.Fatalf("http.NewRequest() error = %v", err)
}
request.Header.Set("Authorization", "Bearer secret-token")
request.Header.Set("Content-Type", "application/json")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.code", "bad_request")
}
func TestAPIImportProviderReturnsClassifiedErrorWithoutBatch(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ImportProvider: func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) {
return provision.RuntimeImportResult{}, errors.New("pack path is required")
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.code", "bad_request")
assertJSONContains(t, response.Body().Bytes(), "batch_id", float64(0))
}
func TestAPIPreviewProviderReturnsUpstreamError(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
PreviewProvider: func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error) {
return provision.PreviewReport{}, &sub2api.HTTPError{Method: http.MethodPost, Path: "/preview", StatusCode: http.StatusTooManyRequests, Body: "rate limited"}
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/preview-import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadGateway)
assertJSONContains(t, response.Body().Bytes(), "error.code", "host_request_failed")
assertJSONContains(t, response.Body().Bytes(), "error.upstream_status", float64(http.StatusTooManyRequests))
}
func TestAPIRollbackProviderReturnsConfiguredError(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) {
return provision.RollbackReport{}, &httpError{StatusCode: http.StatusGone, Code: "rolled_back", Message: "already removed"}
},
})
request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/rollback", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusGone)
assertJSONContains(t, response.Body().Bytes(), "error.code", "rolled_back")
}
func TestAPIReconcileProviderRejectsTrailingNonObjectPayload(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{ReconcileProvider: func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error) {
t.Fatal("ReconcileProvider should not be called for invalid JSON")
return provision.ReconcileResult{}, nil
}})
request, err := http.NewRequest(http.MethodPost, "/api/providers/deepseek/reconcile", strings.NewReader(`{"host_base_url":"https://sub2api.example.com"}[]`))
if err != nil {
t.Fatalf("http.NewRequest() error = %v", err)
}
request.Header.Set("Authorization", "Bearer secret-token")
request.Header.Set("Content-Type", "application/json")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.message", "request body must contain a single JSON object")
}
// --- Coverage edge cases ---
func TestHTTPErrorError(t *testing.T) {
e := &httpError{StatusCode: http.StatusTeapot, Code: "teapot", Message: "i'm a teapot"}
if got := e.Error(); got != "i'm a teapot" {
t.Fatalf("httpError.Error() = %q, want %q", got, "i'm a teapot")
}
}
func TestProviderStatusFnNil(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req := httptestRequest(t, http.MethodGet, "/api/providers/x/status", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
}
func TestProviderAccessStatusFnNil(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req := httptestRequest(t, http.MethodGet, "/api/providers/x/access/status", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
}
func TestProviderResourcesFnNil(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req := httptestRequest(t, http.MethodGet, "/api/providers/x/resources", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
}
func TestProviderStatusReturnsError(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{
GetProviderStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) {
return provision.ProviderSnapshot{}, errors.New(`provider "x" not found in pack "p"`)
},
})
req := httptestRequest(t, http.MethodGet, "/api/providers/x/status", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusBadRequest)
assertJSONContains(t, res.Body().Bytes(), "error.code", "provider_not_found")
}
func TestPostHandlersFnNil(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
}{
{name: "install-pack", method: http.MethodPost, path: "/api/packs/install", body: `{}`},
{name: "preview", method: http.MethodPost, path: "/api/providers/x/preview-import", body: `{}`},
{name: "import", method: http.MethodPost, path: "/api/providers/x/import", body: `{}`},
{name: "rollback", method: http.MethodPost, path: "/api/providers/x/rollback", body: `{}`},
{name: "reconcile", method: http.MethodPost, path: "/api/providers/x/reconcile", body: `{}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req, _ := http.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Authorization", "Bearer t")
req.Header.Set("Content-Type", "application/json")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
})
}
}
func TestGetHandlersFnNil(t *testing.T) {
tests := []struct {
name string
path string
}{
{name: "list-hosts", path: "/api/hosts"},
{name: "get-host", path: "/api/hosts/my-host"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req, _ := http.NewRequest(http.MethodGet, tt.path, nil)
req.Header.Set("Authorization", "Bearer t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
})
}
}
func TestDeleteHandlersFnNil(t *testing.T) {
tests := []struct {
name string
path string
}{
{name: "delete-host", path: "/api/hosts/my-host"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{})
req, _ := http.NewRequest(http.MethodDelete, tt.path, nil)
req.Header.Set("Authorization", "Bearer t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusInternalServerError)
assertJSONContains(t, res.Body().Bytes(), "error.code", "server_misconfigured")
})
}
}
func TestHandlerErrorPaths(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
actionSet ActionSet
wantStatus int
wantCode string
}{
{
name: "access-status-error",
method: http.MethodGet,
path: "/api/providers/x/access/status",
actionSet: ActionSet{
GetProviderAccessStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) {
return provision.ProviderSnapshot{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "preview-error",
method: http.MethodPost,
path: "/api/providers/x/preview-import",
body: `{}`,
actionSet: ActionSet{
PreviewProvider: func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error) {
return provision.PreviewReport{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "rollback-error",
method: http.MethodPost,
path: "/api/providers/x/rollback",
body: `{}`,
actionSet: ActionSet{
RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) {
return provision.RollbackReport{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "reconcile-error",
method: http.MethodPost,
path: "/api/providers/x/reconcile",
body: `{}`,
actionSet: ActionSet{
ReconcileProvider: func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error) {
return provision.ReconcileResult{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "list-hosts-error",
method: http.MethodGet,
path: "/api/hosts",
actionSet: ActionSet{
ListHosts: func(context.Context) ([]HostInfo, error) {
return nil, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "get-host-error",
method: http.MethodGet,
path: "/api/hosts/my-host",
actionSet: ActionSet{
GetHost: func(context.Context, string) (HostInfo, error) {
return HostInfo{}, errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "get-host-not-found",
method: http.MethodGet,
path: "/api/hosts/unknown",
actionSet: ActionSet{
GetHost: func(context.Context, string) (HostInfo, error) {
return HostInfo{}, errors.New("host unknown not found")
},
},
wantStatus: http.StatusNotFound,
wantCode: "not_found",
},
{
name: "delete-host-error",
method: http.MethodDelete,
path: "/api/hosts/my-host",
actionSet: ActionSet{
DeleteHost: func(context.Context, string) error {
return errors.New("boom")
},
},
wantStatus: http.StatusInternalServerError,
wantCode: "internal_error",
},
{
name: "delete-host-not-found",
method: http.MethodDelete,
path: "/api/hosts/unknown",
actionSet: ActionSet{
DeleteHost: func(context.Context, string) error {
return errors.New("host unknown not found")
},
},
wantStatus: http.StatusNotFound,
wantCode: "not_found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewAPIHandler("t", tt.actionSet)
var req *http.Request
if tt.body != "" {
req, _ = http.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
} else {
var err error
req, err = http.NewRequest(tt.method, tt.path, nil)
if err != nil {
t.Fatal(err)
}
}
req.Header.Set("Authorization", "Bearer t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, tt.wantStatus)
assertJSONContains(t, res.Body().Bytes(), "error.code", tt.wantCode)
})
}
}
func TestResolveLatestAccessStatusAggregatesAcrossModeBatches(t *testing.T) {
store := openAppTestStore(t)
defer closeAppTestStore(t, store)
ctx := context.Background()
hostID, err := store.Hosts().Create(ctx, sqlite.Host{HostID: "host-1", BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126", AuthType: "apikey", AuthToken: "token"})
if err != nil {
t.Fatalf("Hosts().Create() error = %v", err)
}
packID, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", Checksum: "checksum-1"})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
providerID, err := store.Providers().Create(ctx, sqlite.Provider{PackID: packID, ProviderID: "deepseek", DisplayName: "DeepSeek", BaseURL: "https://api.deepseek.com", Platform: "openai"})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
batchSubscription, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: provision.ImportModePartial, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSubscriptionReady})
if err != nil {
t.Fatalf("ImportBatches().Create(subscription) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSubscription, ClosureType: provision.AccessModeSubscription, Status: provision.AccessStatusSubscriptionReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(subscription) error = %v", err)
}
batchSelf, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: provision.ImportModePartial, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady})
if err != nil {
t.Fatalf("ImportBatches().Create(self_service) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchSelf, ClosureType: provision.AccessModeSelfService, Status: provision.AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil {
t.Fatalf("AccessClosures().Create(self_service) error = %v", err)
}
got, err := resolveLatestAccessStatus(ctx, store, sqlite.Provider{ID: providerID, ProviderID: "deepseek"}, "host-1")
if err != nil {
t.Fatalf("resolveLatestAccessStatus() error = %v", err)
}
if got != provision.AccessStatusFullyReady {
t.Fatalf("resolveLatestAccessStatus() = %q, want %q", got, provision.AccessStatusFullyReady)
}
}
func TestProviderAccessStatusMultipleClosures(t *testing.T) {
handler := NewAPIHandler("t", ActionSet{
GetProviderAccessStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) {
return provision.ProviderSnapshot{
Pack: sqlite.Pack{PackID: "p"},
Provider: sqlite.Provider{ProviderID: "dp"},
Batch: sqlite.ImportBatch{ID: 1},
LatestAccessStatus: "ready",
AccessClosures: []sqlite.AccessClosureRecord{
{ID: 1, ClosureType: "preview", Status: "done", DetailsJSON: `{"v":1}`},
{ID: 2, ClosureType: "self_service", Status: "active", DetailsJSON: `{"v":2}`},
},
}, nil
},
})
req := httptestRequest(t, http.MethodGet, "/api/providers/dp/access/status", nil, "t")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusOK)
// Should report the last closure (index n-1)
if !strings.Contains(res.Body().String(), `"closure_type":"self_service"`) {
t.Fatalf("expected latest closure to be self_service, got: %s", res.Body().String())
}
}
func TestAccessStatusSupportsMode(t *testing.T) {
tests := []struct {
name string
status string
mode string
want bool
}{
{name: "subscription ready supports subscription", status: provision.AccessStatusSubscriptionReady, mode: provision.AccessModeSubscription, want: true},
{name: "subscription ready does not support self service", status: provision.AccessStatusSubscriptionReady, mode: provision.AccessModeSelfService, want: false},
{name: "fully ready supports self service", status: provision.AccessStatusFullyReady, mode: provision.AccessModeSelfService, want: true},
{name: "broken does not support any", status: provision.AccessStatusBroken, mode: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := accessStatusSupportsMode(tt.status, tt.mode); got != tt.want {
t.Fatalf("accessStatusSupportsMode(%q, %q) = %v, want %v", tt.status, tt.mode, got, tt.want)
}
})
}
}
func TestHostSupportStatusRequiresPlansCapability(t *testing.T) {
status := hostSupportStatus(sub2api.HostCapabilities{Groups: true, Channels: true, Plans: false, Accounts: true, AccountTest: true, AccountModels: true, Subscriptions: true})
if status != "unsupported" {
t.Fatalf("hostSupportStatus() = %q, want unsupported when plans capability is missing", status)
}
}
func openAppTestStore(t *testing.T) *sqlite.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "state.db")
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_pragma=foreign_keys(0)", filepath.ToSlash(dbPath))
store, err := sqlite.Open(context.Background(), dsn)
if err != nil {
t.Fatalf("sqlite.Open() error = %v", err)
}
return store
}
func closeAppTestStore(t *testing.T, store *sqlite.DB) {
t.Helper()
if err := store.Close(); err != nil {
t.Fatalf("store.Close() error = %v", err)
}
}
func assertJSONContains(t *testing.T, payload []byte, key string, want any) {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("json.Unmarshal() error = %v; payload=%s", err, string(payload))
}
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := any(decoded)
for _, part := range parts {
object, ok := current.(map[string]any)
if !ok {
t.Fatalf("key %q not found in payload %s", key, string(payload))
}
current = object[part]
}
if current != want {
t.Fatalf("json key %q = %#v, want %#v; payload=%s", key, current, want, string(payload))
}
return
}
if decoded[key] != want {
t.Fatalf("json key %q = %#v, want %#v; payload=%s", key, decoded[key], want, string(payload))
}
}
func TestNewActionSetReturnsNonNil(t *testing.T) {
as := NewActionSet("file::memory:?cache=shared")
t.Run("InstallPack", func(t *testing.T) {
if as.InstallPack == nil {
t.Fatal("is nil")
}
})
t.Run("BatchDetail", func(t *testing.T) {
if as.BatchDetail == nil {
t.Fatal("is nil")
}
})
t.Run("GetProviderStatus", func(t *testing.T) {
if as.GetProviderStatus == nil {
t.Fatal("is nil")
}
})
t.Run("GetProviderResources", func(t *testing.T) {
if as.GetProviderResources == nil {
t.Fatal("is nil")
}
})
t.Run("GetProviderAccessStatus", func(t *testing.T) {
if as.GetProviderAccessStatus == nil {
t.Fatal("is nil")
}
})
t.Run("PreviewProvider", func(t *testing.T) {
if as.PreviewProvider == nil {
t.Fatal("is nil")
}
})
t.Run("ImportProvider", func(t *testing.T) {
if as.ImportProvider == nil {
t.Fatal("is nil")
}
})
t.Run("RollbackProvider", func(t *testing.T) {
if as.RollbackProvider == nil {
t.Fatal("is nil")
}
})
t.Run("ReconcileProvider", func(t *testing.T) {
if as.ReconcileProvider == nil {
t.Fatal("is nil")
}
})
t.Run("ListHosts", func(t *testing.T) {
if as.ListHosts == nil {
t.Fatal("is nil")
}
})
t.Run("GetHost", func(t *testing.T) {
if as.GetHost == nil {
t.Fatal("is nil")
}
})
t.Run("DeleteHost", func(t *testing.T) {
if as.DeleteHost == nil {
t.Fatal("is nil")
}
})
t.Run("ProbeHost", func(t *testing.T) {
if as.ProbeHost == nil {
t.Fatal("is nil")
}
})
t.Run("ListProviderImportBatches", func(t *testing.T) {
if as.ListProviderImportBatches == nil {
t.Fatal("is nil")
}
})
t.Run("RollbackBatch", func(t *testing.T) {
if as.RollbackBatch == nil {
t.Fatal("is nil")
}
})
}
func TestBatchDetailReturnsNotFoundForMissingBatch(t *testing.T) {
as := NewActionSet("file::memory:?cache=shared")
_, err := as.BatchDetail(context.Background(), BatchDetailRequest{BatchID: 999})
if err == nil {
t.Fatal("BatchDetail() error = nil for missing batch, want error")
}
}
func TestNewActionSetSQLiteClosures(t *testing.T) {
dsn := "file::memory:?cache=shared"
as := NewActionSet(dsn)
ctx := context.Background()
t.Run("GetProviderStatus on empty DB", func(t *testing.T) {
_, err := as.GetProviderStatus(ctx, ProviderQueryRequest{ProviderID: "x", PackID: "p"})
if err == nil {
t.Fatal("expected error from empty DB, got nil")
}
})
t.Run("GetProviderResources on empty DB", func(t *testing.T) {
_, err := as.GetProviderResources(ctx, ProviderQueryRequest{ProviderID: "x", PackID: "p"})
if err == nil {
t.Fatal("expected error from empty DB, got nil")
}
})
t.Run("GetProviderAccessStatus on empty DB", func(t *testing.T) {
_, err := as.GetProviderAccessStatus(ctx, ProviderQueryRequest{ProviderID: "x", PackID: "p"})
if err == nil {
t.Fatal("expected error from empty DB, got nil")
}
})
t.Run("ListHosts on empty DB", func(t *testing.T) {
hosts, err := as.ListHosts(ctx)
if err != nil {
t.Fatalf("ListHosts() on empty DB error = %v, want nil", err)
}
if len(hosts) != 0 {
t.Fatalf("ListHosts() len = %d, want 0", len(hosts))
}
})
t.Run("GetHost on empty DB", func(t *testing.T) {
_, err := as.GetHost(ctx, "nonexistent")
if err == nil {
t.Fatal("expected error from empty DB, got nil")
}
})
t.Run("ListProviderImportBatches on empty DB", func(t *testing.T) {
batches, err := as.ListProviderImportBatches(ctx, ProviderQueryRequest{ProviderID: "x"})
if err != nil {
t.Fatalf("ListProviderImportBatches() on empty DB error = %v, want nil", err)
}
if len(batches) != 0 {
t.Fatalf("ListProviderImportBatches() len = %d, want 0", len(batches))
}
})
}
func TestAPIProbeHostReturnsHostSnapshot(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ProbeHost: func(_ context.Context, req ProbeHostRequest) (HostInfo, error) {
if req.HostID != "prod-sub2api" {
t.Fatalf("ProbeHost hostID = %q, want prod-sub2api", req.HostID)
}
if req.Auth.Type != "bearer" || req.Auth.Token != "probe-token" {
t.Fatalf("ProbeHost auth = %#v, want bearer/probe-token", req.Auth)
}
return HostInfo{HostID: req.HostID, BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126", Status: "supported"}, nil
},
})
req := httptestRequest(t, http.MethodPost, "/api/hosts/prod-sub2api/probe", map[string]any{
"auth": map[string]any{"type": "bearer", "token": "probe-token"},
}, "secret-token")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusOK)
assertJSONContains(t, res.Body().Bytes(), "host_id", "prod-sub2api")
assertJSONContains(t, res.Body().Bytes(), "host_version", "0.1.126")
assertJSONContains(t, res.Body().Bytes(), "status", "supported")
}
func TestAPIListProviderImportBatchesReturnsItems(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ListProviderImportBatches: func(_ context.Context, req ProviderQueryRequest) ([]ImportBatchInfo, error) {
if req.ProviderID != "deepseek" {
t.Fatalf("ListProviderImportBatches providerID = %q, want deepseek", req.ProviderID)
}
return []ImportBatchInfo{{BatchID: 7, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady}}, nil
},
})
req := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/import-batches", nil, "secret-token")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusOK)
body := res.Body().String()
if !strings.Contains(body, `"batch_id":7`) || !strings.Contains(body, `"batch_status":"succeeded"`) || !strings.Contains(body, `"access_status":"self_service_ready"`) {
t.Fatalf("unexpected import batch payload: %s", body)
}
}
func TestAPIRollbackBatchReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
RollbackBatch: func(_ context.Context, req RollbackBatchRequest) (provision.RollbackReport, error) {
if req.BatchID != 11 {
t.Fatalf("RollbackBatch batchID = %d, want 11", req.BatchID)
}
if req.Auth.Type != "apikey" || req.Auth.Token != "admin-key" {
t.Fatalf("RollbackBatch auth = %#v, want apikey/admin-key", req.Auth)
}
return provision.RollbackReport{AccountsDeleted: 2, PlansDeleted: 1, ChannelsDeleted: 1, GroupsDeleted: 1}, nil
},
})
req := httptestRequest(t, http.MethodPost, "/api/import-batches/11/rollback", map[string]any{
"auth": map[string]any{"type": "apikey", "token": "admin-key"},
}, "secret-token")
res := httptestRecorder(handler, req)
assertStatusCode(t, res, http.StatusOK)
assertJSONContains(t, res.Body().Bytes(), "deleted_accounts", float64(2))
assertJSONContains(t, res.Body().Bytes(), "deleted_plans", float64(1))
assertJSONContains(t, res.Body().Bytes(), "deleted_channels", float64(1))
assertJSONContains(t, res.Body().Bytes(), "deleted_groups", float64(1))
}
func TestNewActionSetPackErrorPaths(t *testing.T) {
dsn := "file::memory:?cache=shared"
as := NewActionSet(dsn)
ctx := context.Background()
t.Run("InstallPack bad path", func(t *testing.T) {
_, err := as.InstallPack(ctx, InstallPackRequest{PackPath: "/nonexistent/pack"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
t.Run("PreviewProvider bad path", func(t *testing.T) {
_, err := as.PreviewProvider(ctx, PreviewProviderRequest{PackPath: "/nonexistent/pack", ProviderID: "x"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
t.Run("ImportProvider bad path", func(t *testing.T) {
_, err := as.ImportProvider(ctx, ImportProviderRequest{PackPath: "/nonexistent/pack", ProviderID: "x", HostBaseURL: "http://h:8080"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
t.Run("RollbackProvider bad path", func(t *testing.T) {
_, err := as.RollbackProvider(ctx, RollbackProviderRequest{PackPath: "/nonexistent/pack", ProviderID: "x", HostBaseURL: "http://h:8080"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
t.Run("ReconcileProvider bad path", func(t *testing.T) {
_, err := as.ReconcileProvider(ctx, ReconcileProviderRequest{PackPath: "/nonexistent/pack", ProviderID: "x", HostBaseURL: "http://h:8080"})
if err == nil {
t.Fatal("expected error from bad pack path")
}
})
}