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, "stale_noise_count": 2}, 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)) assertJSONContains(t, response.Body().Bytes(), "latest_reconcile_summary.stale_noise_count", float64(2)) } 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, StaleNoiseCount: 3, ProbeFailureCount: 1, AccessStatus: provision.AccessStatusBroken, Summary: map[string]any{"probe_failures": 1, "stale_noise_count": 3}}, 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(), "stale_noise_count", float64(3)) assertJSONContains(t, response.Body().Bytes(), "summary.probe_failures", float64(1)) assertJSONContains(t, response.Body().Bytes(), "summary.stale_noise_count", float64(3)) } 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") } }) }