diff --git a/internal/app/coverage_helpers_test.go b/internal/app/coverage_helpers_test.go new file mode 100644 index 00000000..9150b65d --- /dev/null +++ b/internal/app/coverage_helpers_test.go @@ -0,0 +1,785 @@ +package app + +import ( + "context" + "database/sql" + "strings" + "testing" + + "sub2api-cn-relay-manager/internal/batch" + "sub2api-cn-relay-manager/internal/host/sub2api" + "sub2api-cn-relay-manager/internal/provision" + "sub2api-cn-relay-manager/internal/store/sqlite" +) + +func TestBatchImportResumeJobNameAndParseJSONStringList(t *testing.T) { + t.Parallel() + + if got := (batchImportResumeJob{}).Name(); got != "batch import runtime scheduler" { + t.Fatalf("batchImportResumeJob.Name() = %q, want batch import runtime scheduler", got) + } + values := parseJSONStringList(`[" user-1 ","user-2"]`) + if len(values) != 2 || values[0] != " user-1 " || values[1] != "user-2" { + t.Fatalf("parseJSONStringList() = %v, want raw decoded values", values) + } + if got := parseJSONStringList("{"); len(got) != 0 { + t.Fatalf("parseJSONStringList(invalid) = %v, want empty", got) + } +} + +func TestBatchImportResumeJobRunAndReconcileSweepJobName(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + mustCreateAppImportRun(t, store, sqlite.ImportRun{ + RunID: "run-1", + HostID: "host-1", + Mode: "partial", + AccessMode: "self_service", + State: "completed", + }) + job := batchImportResumeJob{sqliteDSN: appTestDSN(t, store)} + if err := job.Run(context.Background()); err != nil { + t.Fatalf("batchImportResumeJob.Run() error = %v", err) + } + if got := (reconcileSweepJob{}).Name(); got != "reconcile background scheduler" { + t.Fatalf("reconcileSweepJob.Name() = %q, want reconcile background scheduler", got) + } +} + +func TestDefaultBackgroundSchedulersAndNewActionSet(t *testing.T) { + t.Parallel() + + schedulers := defaultBackgroundSchedulers() + if schedulers.runBatchImport == nil || schedulers.runReconcile == nil { + t.Fatalf("defaultBackgroundSchedulers() = %+v, want non-nil functions", schedulers) + } + actions := NewActionSet("file:/tmp/nonexistent.db") + if actions.CreateBatchImportRun == nil || actions.ListBatchImportRuns == nil || actions.GetBatchImportRun == nil || actions.ListBatchImportRunItems == nil || actions.GetBatchImportRunItem == nil { + t.Fatalf("NewActionSet() returned nil batch actions: %+v", actions) + } + if actions.CreateHost == nil || actions.ListPacks == nil || actions.GetPack == nil || actions.ListPackProviders == nil { + t.Fatalf("NewActionSet() returned nil app actions: %+v", actions) + } +} + +func TestCreateHostAuthFromLegacyFields(t *testing.T) { + t.Parallel() + + if got := createHostAuthFromLegacyFields(" api-key ", " bearer-token "); got.Type != "bearer" || got.Token != "bearer-token" { + t.Fatalf("createHostAuthFromLegacyFields() = %+v, want bearer token", got) + } + if got := createHostAuthFromLegacyFields(" api-key ", ""); got.Type != "apikey" || got.Token != "api-key" { + t.Fatalf("createHostAuthFromLegacyFields() = %+v, want apikey", got) + } +} + +func TestHostRecordToInfoAndPackRecordToInfo(t *testing.T) { + t.Parallel() + + hostInfo := hostRecordToInfo(sqlite.Host{ + HostID: "host-1", + BaseURL: "https://sub2api.example.com", + HostVersion: "0.1.126", + AuthType: "apikey", + CapabilityProbeJSON: `{"groups":true,"channels":true,"plans":true,"accounts":true,"account_test":true,"account_models":true,"subscriptions":true}`, + }) + if hostInfo.Status != "supported" || hostInfo.Capabilities == nil || !hostInfo.Capabilities.Subscriptions { + t.Fatalf("hostRecordToInfo() = %+v, want supported capabilities", hostInfo) + } + + hostInfo = hostRecordToInfo(sqlite.Host{ + HostID: "host-2", + BaseURL: "https://bad.example.com", + CapabilityProbeJSON: "{", + }) + if hostInfo.Capabilities != nil || hostInfo.Status != "" { + t.Fatalf("hostRecordToInfo(invalid json) = %+v, want nil capabilities and empty status", hostInfo) + } + + packInfo := packRecordToInfo(sqlite.Pack{ + PackID: "openai-cn-pack", + Version: "1.0.0", + Vendor: "OpenAI CN", + TargetHost: "sub2api", + MinHostVersion: "0.1.126", + MaxHostVersion: "0.2.x", + }) + if packInfo.PackID != "openai-cn-pack" || packInfo.TargetHost != "sub2api" { + t.Fatalf("packRecordToInfo() = %+v, want projected pack info", packInfo) + } +} + +func TestDeriveAccessStatusAndDefaultPositiveInt(t *testing.T) { + t.Parallel() + + if got := deriveAccessStatus(sub2api.GatewayAccessResult{OK: true, HasExpectedModel: true, CompletionOK: true}); got != provision.AccessStatusSubscriptionReady { + t.Fatalf("deriveAccessStatus(ready) = %q, want %q", got, provision.AccessStatusSubscriptionReady) + } + if got := deriveAccessStatus(sub2api.GatewayAccessResult{OK: true, HasExpectedModel: true, CompletionOK: false}); got != provision.AccessStatusBroken { + t.Fatalf("deriveAccessStatus(broken) = %q, want %q", got, provision.AccessStatusBroken) + } + if got := defaultPositiveInt(3, 9); got != 3 { + t.Fatalf("defaultPositiveInt(3, 9) = %d, want 3", got) + } + if got := defaultPositiveInt(0, 9); got != 9 { + t.Fatalf("defaultPositiveInt(0, 9) = %d, want 9", got) + } +} + +func TestMatchesItemFilters(t *testing.T) { + t.Parallel() + + view := batch.ItemSummaryProjection{ + ItemID: "item-1", + BaseURL: "https://kimi.example.com/v1", + ProviderID: "kimi-a7m", + CurrentStage: "done", + ConfirmationStatus: "advisory", + AccessStatus: "active", + MatchedAccountState: "active", + AccountResolution: "reused", + AdvisoryMessages: []string{"warning"}, + } + hasWarning := true + if !matchesItemFilters(view, ListBatchImportRunItemsRequest{ + CurrentStage: "done", + ConfirmationStatus: "advisory", + AccessStatus: "active", + ProviderID: "kimi-a7m", + MatchedAccountState: "active", + AccountResolution: "reused", + Query: "kimi", + HasWarning: &hasWarning, + }) { + t.Fatal("matchesItemFilters() = false, want match") + } + noWarning := false + if matchesItemFilters(view, ListBatchImportRunItemsRequest{HasWarning: &noWarning}) { + t.Fatal("matchesItemFilters(has_warning=false) = true, want false") + } + if matchesItemFilters(view, ListBatchImportRunItemsRequest{Query: "other"}) { + t.Fatal("matchesItemFilters(query=other) = true, want false") + } +} + +func TestBuildGetBatchImportRunActionAndGetItemAction(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + mustCreateAppImportRun(t, store, sqlite.ImportRun{ + RunID: "run-1", + HostID: "host-1", + Mode: "partial", + AccessMode: "self_service", + State: "running", + }) + mustExecSQL(t, store, `UPDATE import_runs SET started_at = '2026-05-23 10:00:00' WHERE run_id = 'run-1'`) + mustCreateAppImportRunItem(t, store, sqlite.ImportRunItem{ + ItemID: "item-1", + RunID: "run-1", + BaseURL: "https://kimi.example.com/v1", + ProviderID: "kimi-a7m", + APIKeyFingerprint: "sha256:1", + CurrentStage: "done", + ConfirmationStatus: "confirmed", + AccessStatus: "active", + MatchedAccountState: "active", + AccountResolution: "reused", + AdvisoryMessagesJSON: `["gateway_warmup_retry_succeeded"]`, + }) + + action := buildGetBatchImportRunAction(appTestDSN(t, store)) + run, err := action(context.Background(), "run-1") + if err != nil { + t.Fatalf("buildGetBatchImportRunAction() error = %v", err) + } + if run.RunID != "run-1" { + t.Fatalf("run.RunID = %q, want run-1", run.RunID) + } + if _, err := action(context.Background(), "missing"); err == nil || err.Error() != "run not found: missing" { + t.Fatalf("missing run error = %v, want run not found", err) + } + + itemAction := buildGetBatchImportRunItemAction(appTestDSN(t, store)) + item, err := itemAction(context.Background(), GetBatchImportRunItemRequest{RunID: "run-1", ItemID: "item-1"}) + if err != nil { + t.Fatalf("buildGetBatchImportRunItemAction() error = %v", err) + } + if item.ItemID != "item-1" || item.AccountResolution != "reused" { + t.Fatalf("item = %+v, want projected item", item) + } + if _, err := itemAction(context.Background(), GetBatchImportRunItemRequest{RunID: "run-2", ItemID: "item-1"}); err == nil || err.Error() != "item not found in run run-2" { + t.Fatalf("wrong run error = %v, want item not found in run", err) + } +} + +func TestResolveProvidersForQueryAndLatestAccessStatus(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + host1, err := store.Hosts().Create(context.Background(), sqlite.Host{ + HostID: "host-1", + BaseURL: "https://one.example.com", + HostVersion: "0.1.126", + AuthToken: "token-1", + }) + if err != nil { + t.Fatalf("Hosts().Create(host1) error = %v", err) + } + host2, err := store.Hosts().Create(context.Background(), sqlite.Host{ + HostID: "host-2", + BaseURL: "https://two.example.com", + HostVersion: "0.1.126", + AuthToken: "token-2", + }) + if err != nil { + t.Fatalf("Hosts().Create(host2) error = %v", err) + } + packID, err := store.Packs().Create(context.Background(), sqlite.Pack{ + PackID: "openai-cn-pack", + Version: "1.0.0", + Checksum: "checksum-1", + }) + if err != nil { + t.Fatalf("Packs().Create() error = %v", err) + } + providerID, err := store.Providers().Create(context.Background(), sqlite.Provider{ + PackID: packID, + ProviderID: "deepseek", + DisplayName: "DeepSeek", + BaseURL: "https://api.example.com", + Platform: "openai", + }) + if err != nil { + t.Fatalf("Providers().Create() error = %v", err) + } + batch1, err := store.ImportBatches().Create(context.Background(), sqlite.ImportBatch{ + HostID: host1, + PackID: packID, + ProviderID: providerID, + Mode: provision.ImportModePartial, + BatchStatus: provision.BatchStatusSucceeded, + AccessStatus: provision.AccessStatusSelfServiceReady, + }) + if err != nil { + t.Fatalf("ImportBatches().Create(batch1) error = %v", err) + } + batch2, err := store.ImportBatches().Create(context.Background(), sqlite.ImportBatch{ + HostID: host2, + PackID: packID, + ProviderID: providerID, + Mode: provision.ImportModeStrict, + BatchStatus: provision.BatchStatusSucceeded, + AccessStatus: provision.AccessStatusSubscriptionReady, + }) + if err != nil { + t.Fatalf("ImportBatches().Create(batch2) error = %v", err) + } + if _, err := store.AccessClosures().Create(context.Background(), sqlite.AccessClosureRecord{ + BatchID: batch1, + ClosureType: provision.AccessModeSelfService, + Status: provision.AccessStatusSelfServiceReady, + }); err != nil { + t.Fatalf("AccessClosures().Create(batch1) error = %v", err) + } + if _, err := store.AccessClosures().Create(context.Background(), sqlite.AccessClosureRecord{ + BatchID: batch2, + ClosureType: provision.AccessModeSubscription, + Status: provision.AccessStatusSubscriptionReady, + }); err != nil { + t.Fatalf("AccessClosures().Create(batch2) error = %v", err) + } + + if _, err := resolveProvidersForQuery(context.Background(), nil, ProviderQueryRequest{}); err == nil || err.Error() != "store is required" { + t.Fatalf("resolveProvidersForQuery(nil store) error = %v, want store is required", err) + } + if _, err := resolveProvidersForQuery(context.Background(), store, ProviderQueryRequest{}); err == nil || err.Error() != "provider_id is required" { + t.Fatalf("resolveProvidersForQuery(missing provider) error = %v, want provider_id is required", err) + } + providers, err := resolveProvidersForQuery(context.Background(), store, ProviderQueryRequest{ProviderID: "deepseek", PackID: "openai-cn-pack"}) + if err != nil { + t.Fatalf("resolveProvidersForQuery() error = %v", err) + } + if len(providers) != 1 || providers[0].ProviderID != "deepseek" { + t.Fatalf("resolveProvidersForQuery() = %+v, want deepseek provider", providers) + } + + if _, err := resolveLatestAccessStatus(context.Background(), nil, sqlite.Provider{}, ""); err == nil || err.Error() != "store is required" { + t.Fatalf("resolveLatestAccessStatus(nil store) error = %v, want store is required", err) + } + status, err := resolveLatestAccessStatus(context.Background(), store, providers[0], "host-1") + if err != nil { + t.Fatalf("resolveLatestAccessStatus(host-1) error = %v", err) + } + if status != provision.AccessStatusSelfServiceReady { + t.Fatalf("resolveLatestAccessStatus(host-1) = %q, want %q", status, provision.AccessStatusSelfServiceReady) + } + if _, err := resolveLatestAccessStatus(context.Background(), store, providers[0], ""); err == nil || err.Error() != "provider exists on multiple hosts; host_id is required" { + t.Fatalf("resolveLatestAccessStatus(multi-host) error = %v, want multi-host error", err) + } +} + +func TestBuildListBatchImportRunsActionAndItemsAction(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + mustCreateAppImportRun(t, store, sqlite.ImportRun{ + RunID: "run-1", + HostID: "host-1", + Mode: "partial", + AccessMode: "self_service", + State: "running", + TotalItems: 1, + WarningItems: 1, + ActiveItems: 1, + BrokenItems: 0, + DegradedItems: 0, + }) + mustCreateAppImportRun(t, store, sqlite.ImportRun{ + RunID: "run-2", + HostID: "host-1", + Mode: "strict", + AccessMode: "subscription", + State: "completed", + }) + mustCreateAppImportRun(t, store, sqlite.ImportRun{ + RunID: "run-3", + HostID: "host-1", + Mode: "strict", + AccessMode: "subscription", + State: "completed", + }) + mustExecSQL(t, store, `UPDATE import_runs SET started_at = '2026-05-23 10:00:03' WHERE run_id = 'run-1'`) + mustExecSQL(t, store, `UPDATE import_runs SET started_at = '2026-05-23 10:00:02' WHERE run_id = 'run-2'`) + mustExecSQL(t, store, `UPDATE import_runs SET started_at = '2026-05-23 10:00:01' WHERE run_id = 'run-3'`) + + mustCreateAppImportRunItem(t, store, sqlite.ImportRunItem{ + ItemID: "item-1", + RunID: "run-1", + BaseURL: "https://kimi.example.com/v1", + ProviderID: "kimi-a7m", + APIKeyFingerprint: "sha256:1", + CurrentStage: "done", + ConfirmationStatus: "advisory", + AccessStatus: "active", + MatchedAccountState: "active", + AccountResolution: "reused", + AdvisoryMessagesJSON: `["warning"]`, + }) + mustCreateAppImportRunItem(t, store, sqlite.ImportRunItem{ + ItemID: "item-2", + RunID: "run-2", + BaseURL: "https://other.example.com/v1", + ProviderID: "deepseek", + APIKeyFingerprint: "sha256:2", + CurrentStage: "confirm", + ConfirmationStatus: "pending", + AccessStatus: "broken", + MatchedAccountState: "broken", + AccountResolution: "created", + AdvisoryMessagesJSON: `[]`, + }) + mustCreateAppImportRunItem(t, store, sqlite.ImportRunItem{ + ItemID: "item-3", + RunID: "run-3", + BaseURL: "https://other.example.com/v1", + ProviderID: "deepseek-backup", + APIKeyFingerprint: "sha256:3", + CurrentStage: "confirm", + ConfirmationStatus: "pending", + AccessStatus: "active", + MatchedAccountState: "active", + AccountResolution: "reused", + AdvisoryMessagesJSON: `[]`, + }) + + listRuns := buildListBatchImportRunsAction(appTestDSN(t, store)) + runs, err := listRuns(context.Background(), ListBatchImportRunsRequest{ + State: "completed", + Query: "other.example.com", + Limit: 1, + }) + if err != nil { + t.Fatalf("buildListBatchImportRunsAction() error = %v", err) + } + if len(runs.Runs) != 1 || runs.Runs[0].RunID != "run-2" { + t.Fatalf("runs = %+v, want [run-2]", runs.Runs) + } + if runs.NextCursor == nil || *runs.NextCursor != "run-3" { + t.Fatalf("runs.NextCursor = %v, want run-3", runs.NextCursor) + } + + listItems := buildListBatchImportRunItemsAction(appTestDSN(t, store)) + hasWarning := true + items, err := listItems(context.Background(), ListBatchImportRunItemsRequest{ + RunID: "run-1", + HasWarning: &hasWarning, + Query: "kimi", + Limit: 10, + }) + if err != nil { + t.Fatalf("buildListBatchImportRunItemsAction() error = %v", err) + } + if len(items.Items) != 1 || items.Items[0].ItemID != "item-1" { + t.Fatalf("items = %+v, want [item-1]", items.Items) + } + if items.NextCursor != nil { + t.Fatalf("items.NextCursor = %v, want nil", items.NextCursor) + } + if _, err := listItems(context.Background(), ListBatchImportRunItemsRequest{RunID: "missing"}); err == nil || err.Error() != "run not found: missing" { + t.Fatalf("missing items run error = %v, want run not found", err) + } +} + +func appTestDSN(t *testing.T, store *sqlite.DB) string { + t.Helper() + + row := store.SQLDB().QueryRow(`PRAGMA database_list`) + var seq int + var name string + var file string + if err := row.Scan(&seq, &name, &file); err != nil { + t.Fatalf("PRAGMA database_list scan error = %v", err) + } + return "file:" + file + "?_busy_timeout=5000&_pragma=foreign_keys(0)" +} + +func mustCreateAppImportRun(t *testing.T, store *sqlite.DB, run sqlite.ImportRun) { + t.Helper() + if err := store.ImportRuns().Create(context.Background(), run); err != nil { + t.Fatalf("ImportRuns().Create() error = %v", err) + } +} + +func mustCreateAppImportRunItem(t *testing.T, store *sqlite.DB, item sqlite.ImportRunItem) { + t.Helper() + if err := store.ImportRunItems().Create(context.Background(), item); err != nil { + t.Fatalf("ImportRunItems().Create() error = %v", err) + } +} + +func mustExecSQL(t *testing.T, store *sqlite.DB, query string, args ...any) { + t.Helper() + if _, err := store.SQLDB().Exec(query, args...); err != nil { + t.Fatalf("Exec(%q) error = %v", query, err) + } +} + +func TestResolveManagedHostAndNewSub2APIClient(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + if _, err := store.Hosts().Create(context.Background(), sqlite.Host{ + HostID: "host-1", + BaseURL: "https://sub2api.example.com", + HostVersion: "0.1.126", + AuthType: "", + AuthToken: "host-token", + }); err != nil { + t.Fatalf("Hosts().Create() error = %v", err) + } + + hostRow, client, err := resolveManagedHost(context.Background(), store, "host-1", "", CreateHostAuth{}) + if err != nil { + t.Fatalf("resolveManagedHost() error = %v", err) + } + if hostRow.HostID != "host-1" || client == nil { + t.Fatalf("resolveManagedHost() = (%+v, %v), want host-1 and client", hostRow, client) + } + if _, _, err := resolveManagedHost(context.Background(), store, "host-1", "https://other.example.com", CreateHostAuth{}); err == nil || !strings.Contains(err.Error(), `host "host-1" base_url mismatch`) { + t.Fatalf("resolveManagedHost(mismatch) error = %v, want mismatch", err) + } + if _, _, err := resolveManagedHost(context.Background(), store, "", "", CreateHostAuth{}); err == nil || err.Error() != "host_id is required" { + t.Fatalf("resolveManagedHost(empty) error = %v, want host_id is required", err) + } + + if auth := authFromStoredHost(sqlite.Host{AuthType: "", AuthToken: " token "}); auth.Type != "apikey" || auth.Token != "token" { + t.Fatalf("authFromStoredHost(default) = %+v, want apikey/token", auth) + } + if _, err := newSub2APIClient("https://sub2api.example.com", CreateHostAuth{Type: "other", Token: "t"}); err == nil || !strings.Contains(err.Error(), `unsupported auth type "other"`) { + t.Fatalf("newSub2APIClient(unsupported) error = %v, want unsupported auth type", err) + } + if _, err := newSub2APIClient("https://sub2api.example.com", CreateHostAuth{Type: "apikey"}); err == nil || !strings.Contains(err.Error(), "auth.token is required") { + t.Fatalf("newSub2APIClient(missing token) error = %v, want auth.token is required", err) + } +} + +func TestHandlerWrappersForPackAndHostRoutes(t *testing.T) { + t.Parallel() + + t.Run("handleListPacks returns empty array", func(t *testing.T) { + t.Parallel() + req := httptestRequest(t, "GET", "/packs", map[string]any{}, "") + rec := &responseRecorder{header: map[string][]string{}} + handleListPacks(rec, req, func(context.Context) ([]PackInfo, error) { return nil, nil }) + assertStatusCode(t, rec, 200) + packs, ok := decodeTopLevelArray(t, rec.Body().Bytes(), "packs") + if !ok || len(packs) != 0 { + t.Fatalf("packs = %#v, want empty array", packs) + } + }) + + t.Run("handleGetPack requires pack id", func(t *testing.T) { + t.Parallel() + req := httptestRequest(t, "GET", "/packs/", map[string]any{}, "") + rec := &responseRecorder{header: map[string][]string{}} + handleGetPack(rec, req, func(context.Context, string) (PackInfo, error) { return PackInfo{}, nil }) + assertStatusCode(t, rec, 400) + assertJSONContains(t, rec.Body().Bytes(), "error.message", "pack_id is required") + }) + + t.Run("handleGetPack returns payload", func(t *testing.T) { + t.Parallel() + req := httptestRequest(t, "GET", "/packs/openai-cn-pack", map[string]any{}, "") + req.SetPathValue("packID", "openai-cn-pack") + rec := &responseRecorder{header: map[string][]string{}} + handleGetPack(rec, req, func(_ context.Context, packID string) (PackInfo, error) { + if packID != "openai-cn-pack" { + t.Fatalf("packID = %q, want openai-cn-pack", packID) + } + return PackInfo{PackID: "openai-cn-pack", Version: "1.0.0"}, nil + }) + assertStatusCode(t, rec, 200) + assertJSONContains(t, rec.Body().Bytes(), "pack_id", "openai-cn-pack") + }) + + t.Run("handleListPackProviders returns array", func(t *testing.T) { + t.Parallel() + req := httptestRequest(t, "GET", "/packs/openai-cn-pack/providers", map[string]any{}, "") + req.SetPathValue("packID", "openai-cn-pack") + rec := &responseRecorder{header: map[string][]string{}} + handleListPackProviders(rec, req, func(_ context.Context, packID string) ([]PackProviderInfo, error) { + if packID != "openai-cn-pack" { + t.Fatalf("packID = %q, want openai-cn-pack", packID) + } + return nil, nil + }) + assertStatusCode(t, rec, 200) + providers, ok := decodeTopLevelArray(t, rec.Body().Bytes(), "providers") + if !ok || len(providers) != 0 { + t.Fatalf("providers = %#v, want empty array", providers) + } + }) + + t.Run("handleCreateHost decodes request", func(t *testing.T) { + t.Parallel() + req := httptestRequest(t, "POST", "/hosts", map[string]any{ + "name": "host-1", + "base_url": "https://sub2api.example.com", + "auth": map[string]any{"type": "apikey", "token": "host-token"}, + }, "") + rec := &responseRecorder{header: map[string][]string{}} + handleCreateHost(rec, req, func(_ context.Context, req CreateHostRequest) (HostInfo, error) { + if req.BaseURL != "https://sub2api.example.com" || req.Auth.Token != "host-token" { + t.Fatalf("request = %+v, want decoded create host request", req) + } + return HostInfo{HostID: "host-1", BaseURL: req.BaseURL}, nil + }) + assertStatusCode(t, rec, 200) + assertJSONContains(t, rec.Body().Bytes(), "host_id", "host-1") + }) + + t.Run("handleProbeHost requires host id", func(t *testing.T) { + t.Parallel() + req := httptestRequest(t, "POST", "/hosts/probe", map[string]any{ + "auth": map[string]any{"type": "apikey", "token": "host-token"}, + }, "") + rec := &responseRecorder{header: map[string][]string{}} + handleProbeHost(rec, req, func(context.Context, ProbeHostRequest) (HostInfo, error) { return HostInfo{}, nil }) + assertStatusCode(t, rec, 400) + assertJSONContains(t, rec.Body().Bytes(), "error.message", "host_id is required") + }) +} + +func TestBuildListBatchImportRunItemsActionCursor(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + mustCreateAppImportRun(t, store, sqlite.ImportRun{ + RunID: "run-1", + HostID: "host-1", + Mode: "partial", + AccessMode: "self_service", + State: "running", + }) + mustExecSQL(t, store, `UPDATE import_runs SET started_at = '2026-05-23 10:00:00' WHERE run_id = 'run-1'`) + mustCreateAppImportRunItem(t, store, sqlite.ImportRunItem{ + ItemID: "item-1", + RunID: "run-1", + BaseURL: "https://a.example.com/v1", + ProviderID: "a", + APIKeyFingerprint: "sha256:a", + CurrentStage: "done", + ConfirmationStatus: "confirmed", + AccessStatus: "active", + MatchedAccountState: "active", + AccountResolution: "created", + }) + mustCreateAppImportRunItem(t, store, sqlite.ImportRunItem{ + ItemID: "item-2", + RunID: "run-1", + BaseURL: "https://b.example.com/v1", + ProviderID: "b", + APIKeyFingerprint: "sha256:b", + CurrentStage: "done", + ConfirmationStatus: "confirmed", + AccessStatus: "active", + MatchedAccountState: "active", + AccountResolution: "created", + }) + + action := buildListBatchImportRunItemsAction(appTestDSN(t, store)) + result, err := action(context.Background(), ListBatchImportRunItemsRequest{ + RunID: "run-1", + Cursor: "item-1", + Limit: 1, + }) + if err != nil { + t.Fatalf("buildListBatchImportRunItemsAction(cursor) error = %v", err) + } + if len(result.Items) != 1 || result.Items[0].ItemID != "item-2" { + t.Fatalf("result.Items = %+v, want [item-2]", result.Items) + } +} + +func TestBuildListBatchImportRunsActionCursorAndDefaults(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + mustCreateAppImportRun(t, store, sqlite.ImportRun{RunID: "run-1", HostID: "host-1", Mode: "partial", AccessMode: "self_service", State: "running"}) + mustCreateAppImportRun(t, store, sqlite.ImportRun{RunID: "run-2", HostID: "host-1", Mode: "partial", AccessMode: "self_service", State: "running"}) + mustExecSQL(t, store, `UPDATE import_runs SET started_at = '2026-05-23 10:00:02' WHERE run_id = 'run-1'`) + mustExecSQL(t, store, `UPDATE import_runs SET started_at = '2026-05-23 10:00:01' WHERE run_id = 'run-2'`) + + action := buildListBatchImportRunsAction(appTestDSN(t, store)) + result, err := action(context.Background(), ListBatchImportRunsRequest{Cursor: "run-1", Limit: -1}) + if err != nil { + t.Fatalf("buildListBatchImportRunsAction(cursor) error = %v", err) + } + if len(result.Runs) != 1 || result.Runs[0].RunID != "run-2" { + t.Fatalf("result.Runs = %+v, want [run-2]", result.Runs) + } +} + +func TestBuildGetBatchImportRunActionPropagatesDBError(t *testing.T) { + t.Parallel() + + action := buildGetBatchImportRunAction("file:/definitely-missing-path/does-not-exist.db?mode=ro") + if _, err := action(context.Background(), "run-1"); err == nil { + t.Fatal("buildGetBatchImportRunAction() error = nil, want open db error") + } +} + +func TestBuildListBatchImportRunItemsActionPropagatesDBError(t *testing.T) { + t.Parallel() + + action := buildListBatchImportRunItemsAction("file:/definitely-missing-path/does-not-exist.db?mode=ro") + if _, err := action(context.Background(), ListBatchImportRunItemsRequest{RunID: "run-1"}); err == nil { + t.Fatal("buildListBatchImportRunItemsAction() error = nil, want open db error") + } +} + +func TestBuildListBatchImportRunsActionPropagatesDBError(t *testing.T) { + t.Parallel() + + action := buildListBatchImportRunsAction("file:/definitely-missing-path/does-not-exist.db?mode=ro") + if _, err := action(context.Background(), ListBatchImportRunsRequest{}); err == nil { + t.Fatal("buildListBatchImportRunsAction() error = nil, want open db error") + } +} + +func TestBuildGetBatchImportRunItemActionPropagatesMissingItem(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + action := buildGetBatchImportRunItemAction(appTestDSN(t, store)) + if _, err := action(context.Background(), GetBatchImportRunItemRequest{RunID: "run-1", ItemID: "missing"}); err == nil || err.Error() != "item not found: missing" { + t.Fatalf("missing item error = %v, want item not found", err) + } +} + +func TestResumePendingBatchImportRunsNoRunningRuns(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + mustCreateAppImportRun(t, store, sqlite.ImportRun{ + RunID: "run-1", + HostID: "host-1", + Mode: "partial", + AccessMode: "self_service", + State: "completed", + }) + if err := resumePendingBatchImportRuns(context.Background(), appTestDSN(t, store)); err != nil { + t.Fatalf("resumePendingBatchImportRuns() error = %v", err) + } +} + +func TestNewBatchImportRuntimeRunnerFromStoredRun(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + if _, err := store.Hosts().Create(context.Background(), sqlite.Host{ + HostID: "host-1", + BaseURL: "https://sub2api.example.com", + HostVersion: "0.1.126", + AuthToken: "host-token", + }); err != nil { + t.Fatalf("Hosts().Create() error = %v", err) + } + runner, err := newBatchImportRuntimeRunnerFromStoredRun(context.Background(), store, sqlite.ImportRun{ + RunID: "run-1", + HostID: "host-1", + Mode: "partial", + AccessMode: "subscription", + SubscriptionUsersJSON: `["user-1","user-2"]`, + SubscriptionDays: 30, + ProbeAPIKey: "probe-key", + }) + if err != nil { + t.Fatalf("newBatchImportRuntimeRunnerFromStoredRun() error = %v", err) + } + if runner.request.HostID != "host-1" || len(runner.request.SubscriptionUsers) != 2 || runner.request.ProbeAPIKey != "probe-key" { + t.Fatalf("runner.request = %+v, want parsed stored run request", runner.request) + } +} + +func TestBuildGetBatchImportRunActionClassifiesNotFoundOnlyOnSQLNoRows(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + action := buildGetBatchImportRunAction(appTestDSN(t, store)) + _, err := action(context.Background(), "missing") + if err == nil || err.Error() != "run not found: missing" { + t.Fatalf("missing run error = %v, want run not found", err) + } +} + +func TestSQLNoRowsReference(t *testing.T) { + t.Parallel() + if sql.ErrNoRows == nil { + t.Fatal("sql.ErrNoRows = nil") + } +}