package app import ( "context" "encoding/json" "net/http" "testing" "sub2api-cn-relay-manager/internal/batch" "sub2api-cn-relay-manager/internal/probe" ) func TestBatchRunsHTTP(t *testing.T) { t.Parallel() t.Run("GET runs returns projected summaries", func(t *testing.T) { t.Parallel() handler := NewAPIHandler("secret-token", ActionSet{ ListBatchImportRuns: func(_ context.Context, req ListBatchImportRunsRequest) (ListBatchImportRunsResponse, error) { if req.State != "completed_with_warnings" { t.Fatalf("State = %q, want completed_with_warnings", req.State) } if req.AccessMode != "subscription" { t.Fatalf("AccessMode = %q, want subscription", req.AccessMode) } if req.Query != "kimi" { t.Fatalf("Query = %q, want kimi", req.Query) } if req.Cursor != "cursor-1" { t.Fatalf("Cursor = %q, want cursor-1", req.Cursor) } nextCursor := "cursor-2" return ListBatchImportRunsResponse{Runs: []batch.RunSummaryProjection{{ RunID: "run-1", State: "completed_with_warnings", Mode: "partial", AccessMode: "subscription", TotalItems: 2, WarningItems: 1, }}, NextCursor: &nextCursor}, nil }, }) req := httptestRequest(t, http.MethodGet, "/api/batch-import/runs?state=completed_with_warnings&access_mode=subscription&q=kimi&cursor=cursor-1", nil, "secret-token") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusOK) run := decodeJSONArrayObjectAt(t, res.Body().Bytes(), "runs", 0) assertJSONObjectValue(t, run, "run_id", "run-1") assertJSONObjectValue(t, run, "state", "completed_with_warnings") assertJSONObjectValue(t, run, "access_mode", "subscription") assertJSONObjectValue(t, run, "warning_items", float64(1)) assertJSONContains(t, res.Body().Bytes(), "next_cursor", "cursor-2") }) t.Run("GET run detail returns wrapped projection", func(t *testing.T) { t.Parallel() handler := NewAPIHandler("secret-token", ActionSet{ GetBatchImportRun: func(_ context.Context, runID string) (batch.RunSummaryProjection, error) { if runID != "run-1" { t.Fatalf("runID = %q, want run-1", runID) } return batch.RunSummaryProjection{ RunID: "run-1", State: "completed_with_warnings", Mode: "partial", AccessMode: "subscription", TotalItems: 2, CompletedItems: 2, ActiveItems: 1, DegradedItems: 1, WarningItems: 1, RecentWarnings: []string{"warning"}, }, nil }, }) req := httptestRequest(t, http.MethodGet, "/api/batch-import/runs/run-1", nil, "secret-token") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusOK) assertJSONContains(t, res.Body().Bytes(), "run.run_id", "run-1") assertJSONArrayValueAt(t, res.Body().Bytes(), "recent_warnings", 0, "warning") }) t.Run("GET items forwards matched account filters", func(t *testing.T) { t.Parallel() handler := NewAPIHandler("secret-token", ActionSet{ ListBatchImportRunItems: func(_ context.Context, req ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error) { if req.RunID != "run-1" { t.Fatalf("RunID = %q, want run-1", req.RunID) } if req.MatchedAccountState != "active" { t.Fatalf("MatchedAccountState = %q, want active", req.MatchedAccountState) } if req.AccountResolution != "reused" { t.Fatalf("AccountResolution = %q, want reused", req.AccountResolution) } if req.Cursor != "item-cursor-1" { t.Fatalf("Cursor = %q, want item-cursor-1", req.Cursor) } nextCursor := "item-cursor-2" return ListBatchImportRunItemsResponse{Items: []batch.ItemSummaryProjection{{ ItemID: "item-1", BaseURL: "https://kimi.example.com/v1", ProviderID: "kimi-a7m-1", APIKeyFingerprint: "sha256:1234", CurrentStage: "done", ConfirmationStatus: "advisory", AccessStatus: "active", MatchedAccountState: "active", AccountResolution: "reused", ProvisionReused: true, }}, NextCursor: &nextCursor}, nil }, }) req := httptestRequest(t, http.MethodGet, "/api/batch-import/runs/run-1/items?matched_account_state=active&account_resolution=reused&cursor=item-cursor-1", nil, "secret-token") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusOK) item := decodeJSONArrayObjectAt(t, res.Body().Bytes(), "items", 0) assertJSONObjectValue(t, item, "item_id", "item-1") assertJSONObjectValue(t, item, "matched_account_state", "active") assertJSONObjectValue(t, item, "account_resolution", "reused") assertJSONObjectValue(t, item, "provision_reused", true) assertJSONContains(t, res.Body().Bytes(), "next_cursor", "item-cursor-2") }) t.Run("GET item detail returns capability profile and events", func(t *testing.T) { t.Parallel() handler := NewAPIHandler("secret-token", ActionSet{ GetBatchImportRunItem: func(_ context.Context, req GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error) { if req.RunID != "run-1" || req.ItemID != "item-1" { t.Fatalf("request = %#v, want run-1/item-1", req) } return batch.ItemDetailProjection{ ItemSummaryProjection: batch.ItemSummaryProjection{ ItemID: "item-1", BaseURL: "https://kimi.example.com/v1", ProviderID: "kimi-a7m-1", APIKeyFingerprint: "sha256:1234", CanonicalModelFamilies: []string{"kimi-2.6"}, CurrentStage: "done", ConfirmationStatus: "advisory", AccessStatus: "active", MatchedAccountState: "deprecated", AccountResolution: "reactivated", ProvisionReused: true, }, ReusedFromProviderID: "provider-kimi-old", CapabilityProfile: batchProbeProfile(), Events: []batch.EventProjection{{ EventID: "evt-1", EventType: "retry_scheduled", Stage: "confirm", Message: "retry queued", }}, }, nil }, }) req := httptestRequest(t, http.MethodGet, "/api/batch-import/runs/run-1/items/item-1", nil, "secret-token") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusOK) assertJSONContains(t, res.Body().Bytes(), "item_id", "item-1") assertJSONContains(t, res.Body().Bytes(), "reused_from_provider_id", "provider-kimi-old") assertJSONContains(t, res.Body().Bytes(), "capability_profile.transport_profile.supports_openai_chat_completions", true) event := decodeJSONArrayObjectAt(t, res.Body().Bytes(), "events", 0) assertJSONObjectValue(t, event, "event_type", "retry_scheduled") assertJSONObjectValue(t, event, "stage", "confirm") }) } func TestBatchRunWrapperFunctions(t *testing.T) { t.Parallel() t.Run("handleGetBatchImportRun validates inputs", func(t *testing.T) { t.Parallel() req := httptestRequest(t, http.MethodGet, "/api/batch-import/runs/run-1", nil, "") rec := &responseRecorder{header: map[string][]string{}} handleGetBatchImportRun(rec, req, nil) assertStatusCode(t, rec, http.StatusInternalServerError) req = httptestRequest(t, http.MethodGet, "/api/batch-import/runs/", nil, "") rec = &responseRecorder{header: map[string][]string{}} handleGetBatchImportRun(rec, req, func(context.Context, string) (batch.RunSummaryProjection, error) { return batch.RunSummaryProjection{}, nil }) assertStatusCode(t, rec, http.StatusBadRequest) assertJSONContains(t, rec.Body().Bytes(), "error.message", "run_id is required") }) t.Run("handleListBatchImportRunItems validates run id and empty result", func(t *testing.T) { t.Parallel() req := httptestRequest(t, http.MethodGet, "/api/batch-import/runs/run-1/items?has_warning=true", nil, "") rec := &responseRecorder{header: map[string][]string{}} handleListBatchImportRunItems(rec, req, nil) assertStatusCode(t, rec, http.StatusInternalServerError) req = httptestRequest(t, http.MethodGet, "/api/batch-import/runs//items?has_warning=true", nil, "") rec = &responseRecorder{header: map[string][]string{}} handleListBatchImportRunItems(rec, req, func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error) { return ListBatchImportRunItemsResponse{}, nil }) assertStatusCode(t, rec, http.StatusBadRequest) req = httptestRequest(t, http.MethodGet, "/api/batch-import/runs/run-1/items?has_warning=true&limit=3", nil, "") req.SetPathValue("run_id", "run-1") rec = &responseRecorder{header: map[string][]string{}} handleListBatchImportRunItems(rec, req, func(_ context.Context, got ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error) { if got.RunID != "run-1" || got.Limit != 3 || got.HasWarning == nil || !*got.HasWarning { t.Fatalf("ListBatchImportRunItemsRequest = %+v, want parsed filters", got) } return ListBatchImportRunItemsResponse{}, nil }) assertStatusCode(t, rec, http.StatusOK) items, ok := decodeTopLevelArray(t, rec.Body().Bytes(), "items") if !ok || len(items) != 0 { t.Fatalf("items = %#v, want empty array", items) } }) t.Run("handleGetBatchImportRunItem validates ids", func(t *testing.T) { t.Parallel() req := httptestRequest(t, http.MethodGet, "/api/batch-import/runs/run-1/items/item-1", nil, "") rec := &responseRecorder{header: map[string][]string{}} handleGetBatchImportRunItem(rec, req, nil) assertStatusCode(t, rec, http.StatusInternalServerError) req = httptestRequest(t, http.MethodGet, "/api/batch-import/runs//items/", nil, "") rec = &responseRecorder{header: map[string][]string{}} handleGetBatchImportRunItem(rec, req, func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error) { return batch.ItemDetailProjection{}, nil }) assertStatusCode(t, rec, http.StatusBadRequest) assertJSONContains(t, rec.Body().Bytes(), "error.message", "run_id and item_id are required") }) } func batchProbeProfile() probe.CapabilityProfile { return probe.CapabilityProfile{ TransportProfile: probe.TransportProfile{ SupportsOpenAIChatCompletions: true, }, } } func decodeJSONArrayObjectAt(t *testing.T, payload []byte, key string, index int) map[string]any { t.Helper() values, ok := decodeTopLevelArray(t, payload, key) if !ok { t.Fatalf("json key %q is not an array; payload=%s", key, string(payload)) } if index < 0 || index >= len(values) { t.Fatalf("json key %q length = %d, want index %d present; payload=%s", key, len(values), index, string(payload)) } object, ok := values[index].(map[string]any) if !ok { t.Fatalf("json key %q[%d] is not an object; payload=%s", key, index, string(payload)) } return object } func assertJSONArrayValueAt(t *testing.T, payload []byte, key string, index int, want any) { t.Helper() values, ok := decodeTopLevelArray(t, payload, key) if !ok { t.Fatalf("json key %q is not an array; payload=%s", key, string(payload)) } if index < 0 || index >= len(values) { t.Fatalf("json key %q length = %d, want index %d present; payload=%s", key, len(values), index, string(payload)) } if got := values[index]; got != want { t.Fatalf("json key %q[%d] = %#v, want %#v; payload=%s", key, index, got, want, string(payload)) } } func assertJSONObjectValue(t *testing.T, object map[string]any, key string, want any) { t.Helper() if got := object[key]; got != want { t.Fatalf("json object key %q = %#v, want %#v", key, got, want) } } func decodeTopLevelArray(t *testing.T, payload []byte, key string) ([]any, bool) { 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)) } values, ok := decoded[key].([]any) return values, ok }