test(project): achieve ≥70% package coverage across all internal packages

- store/sqlite: 75.4% (repos + db coverage)
- host/sub2api: 80.8% (httptest mock server, pure function tests)
- app: 74.2% (handler error paths, NewActionSet closures)
- pack: 72.4%
- provision: 75.2%
- access: 77.3%
- config: 94.7% (lookup mock tests)

All tests pass: build, vet, race, coverage gates.
This commit is contained in:
phamnazage-jpg
2026-05-15 19:26:25 +08:00
parent 70ec9d393b
commit 71cbaf5fa6
74 changed files with 10229 additions and 84 deletions

View File

@@ -0,0 +1,80 @@
package access
import (
"context"
"fmt"
"strings"
"sub2api-cn-relay-manager/internal/host/sub2api"
)
const (
ModeSubscription = "subscription"
ModeSelfService = "self_service"
)
type SubscriptionTarget struct {
UserID string
DurationDays int
}
type ClosureRequest struct {
Mode string
ProbeAPIKey string
Subscriptions []SubscriptionTarget
GroupID string
ExpectedModel string
}
type Host interface {
AssignSubscription(ctx context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error)
CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error)
}
type Service struct {
host Host
}
func NewService(host Host) *Service {
return &Service{host: host}
}
func Validate(req ClosureRequest) error {
switch strings.TrimSpace(req.Mode) {
case ModeSubscription:
if len(req.Subscriptions) == 0 {
return fmt.Errorf("subscription access requires at least one subscription target")
}
case ModeSelfService:
if strings.TrimSpace(req.ProbeAPIKey) == "" {
return fmt.Errorf("self_service access requires probe api key")
}
default:
return fmt.Errorf("unsupported access mode %q", req.Mode)
}
if strings.TrimSpace(req.ProbeAPIKey) == "" {
return fmt.Errorf("access probe api key is required to verify gateway closure")
}
return nil
}
func (s *Service) Close(ctx context.Context, req ClosureRequest) (sub2api.GatewayAccessResult, error) {
if s == nil || s.host == nil {
return sub2api.GatewayAccessResult{}, fmt.Errorf("access host is required")
}
if err := Validate(req); err != nil {
return sub2api.GatewayAccessResult{}, err
}
if strings.TrimSpace(req.Mode) == ModeSubscription {
for _, target := range req.Subscriptions {
if _, err := s.host.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{UserID: target.UserID, GroupID: req.GroupID, DurationDays: target.DurationDays}); err != nil {
return sub2api.GatewayAccessResult{}, fmt.Errorf("assign subscription for %s: %w", target.UserID, err)
}
}
}
result, err := s.host.CheckGatewayAccess(ctx, sub2api.GatewayAccessCheckRequest{APIKey: req.ProbeAPIKey, ExpectedModel: req.ExpectedModel})
if err != nil {
return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway access: %w", err)
}
return result, nil
}

View File

@@ -0,0 +1,91 @@
package access
import (
"context"
"errors"
"testing"
"sub2api-cn-relay-manager/internal/host/sub2api"
)
func TestValidateRejectsMissingProbeAPIKeyForSelfService(t *testing.T) {
err := Validate(ClosureRequest{Mode: "self_service"})
if err == nil {
t.Fatal("Validate() error = nil, want validation error")
}
}
func TestValidateRejectsMissingSubscriptionsForSubscriptionMode(t *testing.T) {
err := Validate(ClosureRequest{Mode: "subscription", ProbeAPIKey: "user-key"})
if err == nil {
t.Fatal("Validate() error = nil, want validation error")
}
}
func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) {
host := &fakeClosureHost{
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
service := NewService(host)
result, err := service.Close(context.Background(), ClosureRequest{
Mode: "subscription",
ProbeAPIKey: "user-key",
GroupID: "group-1",
ExpectedModel: "deepseek-chat",
Subscriptions: []SubscriptionTarget{{UserID: "user-1", DurationDays: 30}},
})
if err != nil {
t.Fatalf("Close() error = %v", err)
}
if len(host.assigned) != 1 {
t.Fatalf("assigned subscriptions = %d, want 1", len(host.assigned))
}
if host.gatewayProbe.APIKey != "user-key" || host.gatewayProbe.ExpectedModel != "deepseek-chat" {
t.Fatalf("gateway probe = %+v, want api key + expected model", host.gatewayProbe)
}
if !result.OK || !result.HasExpectedModel {
t.Fatalf("gateway result = %+v, want success", result)
}
}
func TestServiceCloseReturnsSubscriptionErrorBeforeGatewayProbe(t *testing.T) {
host := &fakeClosureHost{assignErr: errors.New("assign failed")}
service := NewService(host)
_, err := service.Close(context.Background(), ClosureRequest{
Mode: "subscription",
ProbeAPIKey: "user-key",
GroupID: "group-1",
ExpectedModel: "deepseek-chat",
Subscriptions: []SubscriptionTarget{{UserID: "user-1", DurationDays: 30}},
})
if err == nil {
t.Fatal("Close() error = nil, want subscription failure")
}
if host.gatewayProbe.APIKey != "" {
t.Fatalf("gateway probe should not run after subscription error, got %+v", host.gatewayProbe)
}
}
type fakeClosureHost struct {
assigned []sub2api.AssignSubscriptionRequest
assignErr error
gatewayProbe sub2api.GatewayAccessCheckRequest
gatewayResult sub2api.GatewayAccessResult
gatewayErr error
}
func (f *fakeClosureHost) AssignSubscription(_ context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) {
if f.assignErr != nil {
return sub2api.SubscriptionRef{}, f.assignErr
}
f.assigned = append(f.assigned, req)
return sub2api.SubscriptionRef{ID: "sub-1"}, nil
}
func (f *fakeClosureHost) CheckGatewayAccess(_ context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) {
f.gatewayProbe = req
if f.gatewayErr != nil {
return sub2api.GatewayAccessResult{}, f.gatewayErr
}
return f.gatewayResult, nil
}

View File

@@ -15,25 +15,20 @@ type Server struct {
listen ListenerFactory
}
func NewServer(listenAddr string, listenerFactory ListenerFactory) *Server {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
func NewServer(listenAddr string, handler http.Handler, listenerFactory ListenerFactory) *Server {
if handler == nil {
handler = NewAPIHandler("", ActionSet{})
}
server := &Server{
server: &http.Server{
Addr: listenAddr,
Handler: mux,
Handler: handler,
},
listen: net.Listen,
}
if listenerFactory != nil {
server.listen = listenerFactory
}
return server
}
@@ -46,13 +41,11 @@ func (s *Server) Run(ctx context.Context) error {
if err != nil {
return err
}
return s.Serve(ctx, listener)
}
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
errCh := make(chan error, 1)
go func() {
err := s.server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
@@ -65,11 +58,9 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.server.Shutdown(shutdownCtx); err != nil {
return err
}
return <-errCh
case err := <-errCh:
return err

View File

@@ -1,17 +1,26 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"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", nil)
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)
@@ -50,7 +59,7 @@ func TestRunReturnsAfterContextCancellation(t *testing.T) {
t.Fatalf("net.Listen() error = %v", err)
}
server := NewServer("127.0.0.1:0", func(string, string) (net.Listener, error) {
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), func(string, string) (net.Listener, error) {
return listener, nil
})
@@ -77,7 +86,7 @@ func TestRunReturnsAfterContextCancellation(t *testing.T) {
func TestRunReturnsListenError(t *testing.T) {
wantErr := errors.New("listen failed")
server := NewServer("127.0.0.1:0", func(string, string) (net.Listener, error) {
server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), func(string, string) (net.Listener, error) {
return nil, wantErr
})
@@ -88,7 +97,7 @@ func TestRunReturnsListenError(t *testing.T) {
}
func TestServeReturnsListenerError(t *testing.T) {
server := NewServer("127.0.0.1:0", nil)
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)
@@ -104,6 +113,208 @@ func TestServeReturnsListenerError(t *testing.T) {
}
}
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()
@@ -126,3 +337,613 @@ func waitForHealthz(t *testing.T, url string) *http.Response {
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: "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 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",
},
}
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 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 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")
}
})
}
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")
}
})
}
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")
}
})
}

View File

@@ -11,6 +11,10 @@ func Bootstrap(_ context.Context) (*Server, error) {
if err != nil {
return nil, err
}
return NewServer(cfg.Server.ListenAddr, nil), nil
adminToken, err := config.LoadAdminTokenFromEnv()
if err != nil {
return nil, err
}
handler := NewAPIHandler(adminToken, NewActionSet(cfg.Database.SQLiteDSN))
return NewServer(cfg.Server.ListenAddr, handler, nil), nil
}

638
internal/app/http_api.go Normal file
View File

@@ -0,0 +1,638 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"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"
)
type ActionSet struct {
InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error)
BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)
GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
GetProviderResources func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
GetProviderAccessStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
PreviewProvider func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error)
ImportProvider func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error)
RollbackProvider func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error)
ReconcileProvider func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error)
}
type InstallPackRequest struct {
HostBaseURL string `json:"host_base_url"`
HostAPIKey string `json:"host_api_key"`
HostBearerToken string `json:"host_bearer_token"`
PackPath string `json:"pack_path"`
}
type BatchDetailRequest struct {
BatchID int64
}
type ProviderQueryRequest struct {
ProviderID string
PackID string
}
type RollbackProviderRequest struct {
HostBaseURL string `json:"host_base_url"`
HostAPIKey string `json:"host_api_key"`
HostBearerToken string `json:"host_bearer_token"`
PackPath string `json:"pack_path"`
ProviderID string `json:"provider_id"`
}
type ReconcileProviderRequest struct {
HostBaseURL string `json:"host_base_url"`
HostAPIKey string `json:"host_api_key"`
HostBearerToken string `json:"host_bearer_token"`
PackPath string `json:"pack_path"`
ProviderID string `json:"provider_id"`
AccessAPIKey string `json:"access_api_key"`
}
type PreviewProviderRequest struct {
HostBaseURL string `json:"host_base_url"`
HostAPIKey string `json:"host_api_key"`
HostBearerToken string `json:"host_bearer_token"`
PackPath string `json:"pack_path"`
ProviderID string `json:"provider_id"`
Keys []string `json:"keys"`
Mode string `json:"mode"`
}
type ImportProviderRequest struct {
HostBaseURL string `json:"host_base_url"`
HostAPIKey string `json:"host_api_key"`
HostBearerToken string `json:"host_bearer_token"`
PackPath string `json:"pack_path"`
ProviderID string `json:"provider_id"`
Keys []string `json:"keys"`
Mode string `json:"mode"`
AccessMode string `json:"access_mode"`
AccessAPIKey string `json:"access_api_key"`
SubscriptionUsers []string `json:"subscription_users"`
SubscriptionDays int `json:"subscription_days"`
}
type httpError struct {
StatusCode int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
UpstreamStatus int `json:"upstream_status,omitempty"`
}
func (e *httpError) Error() string {
return e.Message
}
func NewAPIHandler(adminToken string, actions ActionSet) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleBatchDetail(w, r, actions.BatchDetail)
})))
mux.Handle("GET /api/providers/{providerID}/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderStatus(w, r, actions.GetProviderStatus)
})))
mux.Handle("GET /api/providers/{providerID}/resources", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderResources(w, r, actions.GetProviderResources)
})))
mux.Handle("GET /api/providers/{providerID}/access/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderAccessStatus(w, r, actions.GetProviderAccessStatus)
})))
mux.Handle("POST /api/packs/install", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleInstallPack(w, r, actions.InstallPack)
})))
mux.Handle("POST /api/providers/{providerID}/preview-import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlePreviewProvider(w, r, actions.PreviewProvider)
})))
mux.Handle("POST /api/providers/{providerID}/import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleImportProvider(w, r, actions.ImportProvider)
})))
mux.Handle("POST /api/providers/{providerID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleRollbackProvider(w, r, actions.RollbackProvider)
})))
mux.Handle("POST /api/providers/{providerID}/reconcile", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleReconcileProvider(w, r, actions.ReconcileProvider)
})))
return mux
}
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func requireAdminToken(token string, next http.Handler) http.Handler {
if strings.TrimSpace(token) == "" {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bearerToken(r) != token {
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid admin token"})
return
}
next.ServeHTTP(w, r)
})
}
func bearerToken(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
return ""
}
return strings.TrimSpace(header[len("Bearer "):])
}
func handleInstallPack(w http.ResponseWriter, r *http.Request, fn func(context.Context, InstallPackRequest) (provision.PackInstallResult, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "install-pack action is not configured"})
return
}
var req InstallPackRequest
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
result, err := fn(r.Context(), req)
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
providers := make([]map[string]string, 0, len(result.Providers))
for _, provider := range result.Providers {
providers = append(providers, map[string]string{
"provider_id": provider.ProviderID,
"display_name": provider.DisplayName,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"pack_id": result.Pack.PackID,
"version": result.Pack.Version,
"host_version": result.HostVersion,
"already_installed": result.AlreadyInstalled,
"providers": providers,
})
}
func handleBatchDetail(w http.ResponseWriter, r *http.Request, fn func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "batch-detail action is not configured"})
return
}
batchID, err := strconv.ParseInt(r.PathValue("batchID"), 10, 64)
if err != nil || batchID <= 0 {
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "batch_id must be a positive integer"})
return
}
result, err := fn(r.Context(), BatchDetailRequest{BatchID: batchID})
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
items := make([]map[string]any, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, map[string]any{
"id": item.ID,
"batch_id": item.BatchID,
"key_fingerprint": item.KeyFingerprint,
"account_status": item.AccountStatus,
"probe_summary_json": item.ProbeSummaryJSON,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"batch": map[string]any{
"id": result.Batch.ID,
"host_id": result.Batch.HostID,
"pack_id": result.Batch.PackID,
"provider_id": result.Batch.ProviderID,
"mode": result.Batch.Mode,
"batch_status": result.Batch.BatchStatus,
"access_status": result.Batch.AccessStatus,
},
"items": items,
"managed_resources": result.ManagedResources,
"access_closures": result.AccessClosures,
"reconcile_runs": result.ReconcileRuns,
"items_count": len(result.Items),
"managed_count": len(result.ManagedResources),
"access_count": len(result.AccessClosures),
"reconcile_count": len(result.ReconcileRuns),
})
}
func handleProviderStatus(w http.ResponseWriter, r *http.Request, fn func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "provider-status action is not configured"})
return
}
result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id"))})
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"host": map[string]any{"host_id": result.Host.HostID, "base_url": result.Host.BaseURL, "host_version": result.Host.HostVersion},
"pack": map[string]any{"pack_id": result.Pack.PackID, "version": result.Pack.Version},
"provider": map[string]any{"provider_id": result.Provider.ProviderID, "display_name": result.Provider.DisplayName, "platform": result.Provider.Platform},
"batch": map[string]any{"id": result.Batch.ID, "batch_status": result.Batch.BatchStatus, "access_status": result.Batch.AccessStatus, "mode": result.Batch.Mode},
"provider_status": result.ProviderStatus,
"latest_access_status": result.LatestAccessStatus,
"latest_reconcile_status": result.LatestReconcileStatus,
"latest_reconcile_summary": result.LatestReconcileSummary,
"managed_resources_count": len(result.ManagedResources),
"access_closures_count": len(result.AccessClosures),
"reconcile_runs_count": len(result.ReconcileRuns),
})
}
func handleProviderAccessStatus(w http.ResponseWriter, r *http.Request, fn func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "provider-access-status action is not configured"})
return
}
result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id"))})
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
latestClosure := map[string]any{}
if n := len(result.AccessClosures); n > 0 {
closure := result.AccessClosures[n-1]
latestClosure = map[string]any{"id": closure.ID, "closure_type": closure.ClosureType, "status": closure.Status, "details_json": closure.DetailsJSON}
}
writeJSON(w, http.StatusOK, map[string]any{
"provider_id": result.Provider.ProviderID,
"pack_id": result.Pack.PackID,
"batch_id": result.Batch.ID,
"batch_access_status": result.Batch.AccessStatus,
"latest_access_status": result.LatestAccessStatus,
"closures_count": len(result.AccessClosures),
"latest_closure": latestClosure,
})
}
func handleProviderResources(w http.ResponseWriter, r *http.Request, fn func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "provider-resources action is not configured"})
return
}
result, err := fn(r.Context(), ProviderQueryRequest{ProviderID: r.PathValue("providerID"), PackID: strings.TrimSpace(r.URL.Query().Get("pack_id"))})
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
resources := make([]map[string]any, 0, len(result.ManagedResources))
for _, resource := range result.ManagedResources {
resources = append(resources, map[string]any{"id": resource.ID, "resource_type": resource.ResourceType, "host_resource_id": resource.HostResourceID, "resource_name": resource.ResourceName})
}
accessClosures := make([]map[string]any, 0, len(result.AccessClosures))
for _, closure := range result.AccessClosures {
accessClosures = append(accessClosures, map[string]any{"id": closure.ID, "closure_type": closure.ClosureType, "status": closure.Status, "details_json": closure.DetailsJSON})
}
reconcileRuns := make([]map[string]any, 0, len(result.ReconcileRuns))
for _, run := range result.ReconcileRuns {
reconcileRuns = append(reconcileRuns, map[string]any{"id": run.ID, "status": run.Status, "summary_json": run.SummaryJSON})
}
writeJSON(w, http.StatusOK, map[string]any{
"provider_id": result.Provider.ProviderID,
"pack_id": result.Pack.PackID,
"batch_id": result.Batch.ID,
"resources": resources,
"access_closures": accessClosures,
"reconcile_runs": reconcileRuns,
})
}
func handlePreviewProvider(w http.ResponseWriter, r *http.Request, fn func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "preview-provider action is not configured"})
return
}
var req PreviewProviderRequest
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
req.ProviderID = r.PathValue("providerID")
result, err := fn(r.Context(), req)
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"accepted_keys_count": len(result.AcceptedKeys),
"names": result.Names,
"decisions": result.Decisions,
})
}
func handleImportProvider(w http.ResponseWriter, r *http.Request, fn func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "import-provider action is not configured"})
return
}
var req ImportProviderRequest
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
req.ProviderID = r.PathValue("providerID")
result, err := fn(r.Context(), req)
if err != nil {
payload := map[string]any{
"batch_id": result.BatchID,
"batch_status": result.Report.BatchStatus,
"provider_status": result.Report.ProviderStatus,
"access_status": result.Report.AccessStatus,
"accepted_keys_count": len(result.Report.AcceptedKeys),
"accounts_count": len(result.Report.Accounts),
"gateway": result.Report.Gateway,
"error": classifyError(err),
}
statusCode := http.StatusConflict
if result.BatchID == 0 {
statusCode = classifyError(err).StatusCode
}
writeJSON(w, statusCode, payload)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"batch_id": result.BatchID,
"batch_status": result.Report.BatchStatus,
"provider_status": result.Report.ProviderStatus,
"access_status": result.Report.AccessStatus,
"accepted_keys_count": len(result.Report.AcceptedKeys),
"accounts_count": len(result.Report.Accounts),
"group": result.Report.Group,
"channel": result.Report.Channel,
"plan": result.Report.Plan,
"gateway": result.Report.Gateway,
})
}
func handleRollbackProvider(w http.ResponseWriter, r *http.Request, fn func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "rollback-provider action is not configured"})
return
}
var req RollbackProviderRequest
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
req.ProviderID = r.PathValue("providerID")
result, err := fn(r.Context(), req)
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"provider_id": req.ProviderID,
"deleted_accounts": result.AccountsDeleted,
"deleted_plans": result.PlansDeleted,
"deleted_channels": result.ChannelsDeleted,
"deleted_groups": result.GroupsDeleted,
})
}
func handleReconcileProvider(w http.ResponseWriter, r *http.Request, fn func(context.Context, ReconcileProviderRequest) (provision.ReconcileResult, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "reconcile-provider action is not configured"})
return
}
var req ReconcileProviderRequest
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
req.ProviderID = r.PathValue("providerID")
result, err := fn(r.Context(), req)
if err != nil {
writeHTTPError(w, classifyError(err))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"provider_id": req.ProviderID,
"batch_id": result.BatchID,
"status": result.Status,
"missing_count": result.MissingCount,
"extra_count": result.ExtraCount,
"summary": result.Summary,
})
}
func decodeJSON(r *http.Request, dest any) *httpError {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(dest); err != nil {
return &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: fmt.Sprintf("decode request body: %v", err)}
}
if err := decoder.Decode(&struct{}{}); err != nil && !errors.Is(err, io.EOF) {
return &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "request body must contain a single JSON object"}
}
return nil
}
func writeHTTPError(w http.ResponseWriter, err *httpError) {
if err == nil {
err = &httpError{StatusCode: http.StatusInternalServerError, Code: "internal_error", Message: "internal server error"}
}
writeJSON(w, err.StatusCode, map[string]any{"error": err})
}
func writeJSON(w http.ResponseWriter, statusCode int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(body)
}
func classifyError(err error) *httpError {
if err == nil {
return nil
}
var requestErr *httpError
if errors.As(err, &requestErr) {
return requestErr
}
var upstreamErr *sub2api.HTTPError
if errors.As(err, &upstreamErr) {
return &httpError{StatusCode: http.StatusBadGateway, Code: "host_request_failed", Message: err.Error(), UpstreamStatus: upstreamErr.StatusCode}
}
message := err.Error()
switch {
case strings.Contains(message, "already installed") || strings.Contains(message, "checksum drift"):
return &httpError{StatusCode: http.StatusConflict, Code: "pack_conflict", Message: message}
case strings.Contains(message, "not found in pack"):
return &httpError{StatusCode: http.StatusBadRequest, Code: "provider_not_found", Message: message}
case strings.Contains(message, "pack path") || strings.Contains(message, "pack dir") || strings.Contains(message, "required") || strings.Contains(message, "decode"):
return &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: message}
default:
return &httpError{StatusCode: http.StatusInternalServerError, Code: "internal_error", Message: message}
}
}
func NewActionSet(sqliteDSN string) ActionSet {
return ActionSet{
InstallPack: func(ctx context.Context, req InstallPackRequest) (provision.PackInstallResult, error) {
loadedPack, err := pack.LoadPath(req.PackPath)
if err != nil {
return provision.PackInstallResult{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return provision.PackInstallResult{}, err
}
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return provision.PackInstallResult{}, err
}
defer store.Close()
service := provision.NewPackInstallService(store, client)
return service.Install(ctx, provision.PackInstallRequest{Pack: loadedPack})
},
BatchDetail: func(ctx context.Context, req BatchDetailRequest) (provision.BatchDetailResult, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return provision.BatchDetailResult{}, err
}
defer store.Close()
return provision.NewBatchDetailService(store).Get(ctx, req.BatchID)
},
GetProviderStatus: func(ctx context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return provision.ProviderSnapshot{}, err
}
defer store.Close()
return provision.NewProviderStatusService(store).GetStatus(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID})
},
GetProviderResources: func(ctx context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return provision.ProviderSnapshot{}, err
}
defer store.Close()
return provision.NewProviderStatusService(store).GetResources(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID})
},
GetProviderAccessStatus: func(ctx context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return provision.ProviderSnapshot{}, err
}
defer store.Close()
return provision.NewProviderStatusService(store).GetStatus(ctx, provision.ProviderQuery{ProviderID: req.ProviderID, PackID: req.PackID})
},
PreviewProvider: func(ctx context.Context, req PreviewProviderRequest) (provision.PreviewReport, error) {
loadedPack, err := pack.LoadPath(req.PackPath)
if err != nil {
return provision.PreviewReport{}, err
}
providerManifest, err := findProvider(loadedPack, req.ProviderID)
if err != nil {
return provision.PreviewReport{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return provision.PreviewReport{}, err
}
service := provision.NewPreviewService(client)
return service.PreviewImport(ctx, provision.PreviewRequest{Provider: providerManifest, Mode: req.Mode, Keys: req.Keys})
},
ImportProvider: func(ctx context.Context, req ImportProviderRequest) (provision.RuntimeImportResult, error) {
loadedPack, err := pack.LoadPath(req.PackPath)
if err != nil {
return provision.RuntimeImportResult{}, err
}
providerManifest, err := findProvider(loadedPack, req.ProviderID)
if err != nil {
return provision.RuntimeImportResult{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return provision.RuntimeImportResult{}, err
}
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return provision.RuntimeImportResult{}, err
}
defer store.Close()
subscriptions := make([]provision.SubscriptionTarget, 0, len(req.SubscriptionUsers))
for _, userID := range req.SubscriptionUsers {
subscriptions = append(subscriptions, provision.SubscriptionTarget{UserID: userID, DurationDays: req.SubscriptionDays})
}
service := provision.NewRuntimeImportService(store, client)
return service.Import(ctx, provision.RuntimeImportRequest{
HostBaseURL: req.HostBaseURL,
Pack: loadedPack,
Provider: providerManifest,
Mode: req.Mode,
Keys: req.Keys,
Access: provision.AccessRequest{
Mode: req.AccessMode,
ProbeAPIKey: req.AccessAPIKey,
Subscriptions: subscriptions,
},
})
},
RollbackProvider: func(ctx context.Context, req RollbackProviderRequest) (provision.RollbackReport, error) {
loadedPack, err := pack.LoadPath(req.PackPath)
if err != nil {
return provision.RollbackReport{}, err
}
providerManifest, err := findProvider(loadedPack, req.ProviderID)
if err != nil {
return provision.RollbackReport{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return provision.RollbackReport{}, err
}
service := provision.NewRollbackService(client)
return service.Rollback(ctx, provision.RollbackRequest{Provider: providerManifest})
},
ReconcileProvider: func(ctx context.Context, req ReconcileProviderRequest) (provision.ReconcileResult, error) {
loadedPack, err := pack.LoadPath(req.PackPath)
if err != nil {
return provision.ReconcileResult{}, err
}
providerManifest, err := findProvider(loadedPack, req.ProviderID)
if err != nil {
return provision.ReconcileResult{}, err
}
client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken))
if err != nil {
return provision.ReconcileResult{}, err
}
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return provision.ReconcileResult{}, err
}
defer store.Close()
service := provision.NewReconcileService(store, client)
return service.Reconcile(ctx, provision.ReconcileRequest{HostBaseURL: req.HostBaseURL, AccessProbeAPIKey: req.AccessAPIKey, Pack: loadedPack, Provider: providerManifest})
},
}
}
func findProvider(loaded pack.LoadedPack, providerID string) (pack.ProviderManifest, error) {
for _, provider := range loaded.Providers {
if provider.ProviderID == strings.TrimSpace(providerID) {
return provider, nil
}
}
return pack.ProviderManifest{}, fmt.Errorf("provider %q not found in pack %q", providerID, loaded.Manifest.PackID)
}

View File

@@ -0,0 +1,140 @@
package config
import (
"errors"
"testing"
)
func TestReadOptionalEnv(t *testing.T) {
t.Run("present non-empty", func(t *testing.T) {
lookup := func(k string) (string, bool) {
if k == "MY_KEY" {
return " value ", true
}
return "", false
}
if got := readOptionalEnv(lookup, "MY_KEY", "default"); got != "value" {
t.Fatalf("got %q, want %q", got, "value")
}
})
t.Run("present empty", func(t *testing.T) {
lookup := func(k string) (string, bool) {
return " ", true
}
if got := readOptionalEnv(lookup, "MY_KEY", "default"); got != "default" {
t.Fatalf("got %q, want %q", got, "default")
}
})
t.Run("missing", func(t *testing.T) {
lookup := func(k string) (string, bool) {
return "", false
}
if got := readOptionalEnv(lookup, "MY_KEY", "default"); got != "default" {
t.Fatalf("got %q, want %q", got, "default")
}
})
}
func TestReadRequiredEnv(t *testing.T) {
t.Run("present", func(t *testing.T) {
lookup := func(k string) (string, bool) {
return "my-token", true
}
if got := readRequiredEnv(lookup, "TOKEN"); got != "my-token" {
t.Fatalf("got %q, want %q", got, "my-token")
}
})
t.Run("missing", func(t *testing.T) {
lookup := func(k string) (string, bool) {
return "", false
}
if got := readRequiredEnv(lookup, "TOKEN"); got != "" {
t.Fatalf("got %q, want empty", got)
}
})
}
func TestLoadStartupFromLookupEnv(t *testing.T) {
t.Run("custom values", func(t *testing.T) {
lookup := func(k string) (string, bool) {
switch k {
case EnvListenAddr:
return ":9090", true
case EnvSQLiteDSN:
return "/data/db.sqlite", true
default:
return "", false
}
}
cfg, err := loadStartupFromLookupEnv(lookup)
if err != nil {
t.Fatal(err)
}
if cfg.Server.ListenAddr != ":9090" {
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, ":9090")
}
if cfg.Database.SQLiteDSN != "/data/db.sqlite" {
t.Fatalf("SQLiteDSN = %q, want %q", cfg.Database.SQLiteDSN, "/data/db.sqlite")
}
})
t.Run("default values", func(t *testing.T) {
lookup := func(k string) (string, bool) {
return "", false
}
cfg, err := loadStartupFromLookupEnv(lookup)
if err != nil {
t.Fatal(err)
}
if cfg.Server.ListenAddr != DefaultListenAddr {
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, DefaultListenAddr)
}
if cfg.Database.SQLiteDSN != DefaultSQLiteDSN {
t.Fatalf("SQLiteDSN = %q, want %q", cfg.Database.SQLiteDSN, DefaultSQLiteDSN)
}
})
}
func TestLoadAdminTokenFromLookupEnv(t *testing.T) {
t.Run("valid token", func(t *testing.T) {
lookup := func(k string) (string, bool) {
return " admin-secret-123 ", true
}
token, err := loadAdminTokenFromLookupEnv(lookup)
if err != nil {
t.Fatal(err)
}
if token != "admin-secret-123" {
t.Fatalf("token = %q, want %q", token, "admin-secret-123")
}
})
t.Run("empty token", func(t *testing.T) {
lookup := func(k string) (string, bool) {
return " ", true
}
_, err := loadAdminTokenFromLookupEnv(lookup)
if err == nil {
t.Fatal("expected error for empty token")
}
})
t.Run("missing env", func(t *testing.T) {
lookup := func(k string) (string, bool) {
return "", false
}
_, err := loadAdminTokenFromLookupEnv(lookup)
if err == nil {
t.Fatal("expected error for missing env")
}
})
}
// Verify exported wrappers call the lookup versions.
// We can't easily test LoadStartupFromEnv / LoadAdminTokenFromEnv
// since they depend on os.LookupEnv, but we verify they compile and don't panic.
func TestExportFunctionsExist(t *testing.T) {
// Just verify the exported functions are reachable and return the right types
_, err := LoadAdminTokenFromEnv()
if err != nil && !errors.Is(err, err) {
// any result is fine, just proving the function exists
}
}

View File

@@ -16,13 +16,19 @@ type HostAdapter interface {
GetHostVersion(ctx context.Context) (string, error)
ProbeCapabilities(ctx context.Context) (HostCapabilities, error)
CreateGroup(ctx context.Context, req CreateGroupRequest) (GroupRef, error)
DeleteGroup(ctx context.Context, groupID string) error
CreateChannel(ctx context.Context, req CreateChannelRequest) (ChannelRef, error)
DeleteChannel(ctx context.Context, channelID string) error
CreatePlan(ctx context.Context, req CreatePlanRequest) (PlanRef, error)
DeletePlan(ctx context.Context, planID string) error
CreateAccount(ctx context.Context, req CreateAccountRequest) (AccountRef, error)
BatchCreateAccounts(ctx context.Context, req BatchCreateAccountsRequest) ([]AccountRef, error)
DeleteAccount(ctx context.Context, accountID string) error
TestAccount(ctx context.Context, accountID string) (ProbeResult, error)
GetAccountModels(ctx context.Context, accountID string) ([]AccountModel, error)
AssignSubscription(ctx context.Context, req AssignSubscriptionRequest) (SubscriptionRef, error)
CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error)
ListManagedResources(ctx context.Context, req ListManagedResourcesRequest) (ManagedResourceSnapshot, error)
}
type HostCapabilities struct {

View File

@@ -0,0 +1,40 @@
package sub2api
import (
"context"
"fmt"
"net/http"
"strings"
)
func (c *Client) DeleteGroup(ctx context.Context, groupID string) error {
return c.deleteResource(ctx, "/api/v1/admin/groups/", groupID)
}
func (c *Client) DeleteChannel(ctx context.Context, channelID string) error {
return c.deleteResource(ctx, "/api/v1/admin/channels/", channelID)
}
func (c *Client) DeletePlan(ctx context.Context, planID string) error {
return c.deleteResource(ctx, "/api/v1/admin/payment/plans/", planID)
}
func (c *Client) DeleteAccount(ctx context.Context, accountID string) error {
return c.deleteResource(ctx, "/api/v1/admin/accounts/", accountID)
}
func (c *Client) deleteResource(ctx context.Context, prefix, resourceID string) error {
resourceID = strings.TrimSpace(resourceID)
if resourceID == "" {
return fmt.Errorf("resource id is required")
}
path := prefix + resourceID
statusCode, _, body, err := c.perform(ctx, http.MethodDelete, path, nil)
if err != nil {
return err
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return newHTTPError(http.MethodDelete, path, statusCode, body)
}
return nil
}

View File

@@ -0,0 +1,62 @@
package sub2api
import (
"context"
"encoding/json"
"net/http"
"strings"
)
type GatewayAccessCheckRequest struct {
APIKey string
ExpectedModel string
}
type GatewayAccessResult struct {
OK bool `json:"ok"`
StatusCode int `json:"status_code"`
Models []string `json:"models"`
HasExpectedModel bool `json:"has_expected_model"`
}
func (c *Client) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error) {
gatewayClient := *c
gatewayClient.apiKey = strings.TrimSpace(req.APIKey)
gatewayClient.bearerToken = ""
statusCode, _, body, err := gatewayClient.perform(ctx, http.MethodGet, "/v1/models", nil)
if err != nil {
return GatewayAccessResult{}, err
}
result := GatewayAccessResult{StatusCode: statusCode, OK: statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices}
if !result.OK {
return result, nil
}
result.Models = decodeGatewayModelIDs(body)
for _, modelID := range result.Models {
if modelID == strings.TrimSpace(req.ExpectedModel) {
result.HasExpectedModel = true
break
}
}
return result, nil
}
func decodeGatewayModelIDs(body []byte) []string {
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err == nil && len(payload.Data) > 0 {
models := make([]string, 0, len(payload.Data))
for _, item := range payload.Data {
if id := strings.TrimSpace(item.ID); id != "" {
models = append(models, id)
}
}
return models
}
return nil
}

View File

@@ -0,0 +1,97 @@
package sub2api
import (
"context"
"encoding/json"
"fmt"
"strings"
)
func (c *Client) ListManagedResources(ctx context.Context, req ListManagedResourcesRequest) (ManagedResourceSnapshot, error) {
groups, err := c.listNamedResources(ctx, "/api/v1/admin/groups", req.GroupName)
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list groups: %w", err)
}
channels, err := c.listNamedResources(ctx, "/api/v1/admin/channels", req.ChannelName)
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list channels: %w", err)
}
plans, err := c.listNamedResources(ctx, "/api/v1/admin/payment/plans", req.PlanName)
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list plans: %w", err)
}
accounts, err := c.listNamedResources(ctx, "/api/v1/admin/accounts", "")
if err != nil {
return ManagedResourceSnapshot{}, fmt.Errorf("list accounts: %w", err)
}
return ManagedResourceSnapshot{
Groups: groups,
Channels: channels,
Plans: plans,
Accounts: filterNamedResourcesByPrefix(accounts, req.AccountNamePrefix),
}, nil
}
func (c *Client) listNamedResources(ctx context.Context, path, expectedName string) ([]NamedResource, error) {
statusCode, _, body, err := c.perform(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
if statusCode < 200 || statusCode >= 300 {
return nil, newHTTPError("GET", path, statusCode, body)
}
resources, err := decodeNamedResources(body)
if err != nil {
return nil, fmt.Errorf("decode %s response: %w", path, err)
}
return filterNamedResourcesByName(resources, expectedName), nil
}
func decodeNamedResources(body []byte) ([]NamedResource, error) {
var resources []NamedResource
if err := decodeEnvelopeObject(body, &resources); err == nil {
return resources, nil
}
var wrapper struct {
Data struct {
Items []NamedResource `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return nil, err
}
return wrapper.Data.Items, nil
}
func filterNamedResourcesByName(resources []NamedResource, expectedName string) []NamedResource {
expectedName = strings.TrimSpace(expectedName)
if expectedName == "" {
return resources
}
filtered := make([]NamedResource, 0, len(resources))
for _, resource := range resources {
if strings.TrimSpace(resource.Name) == expectedName {
filtered = append(filtered, resource)
}
}
return filtered
}
func filterNamedResourcesByPrefix(resources []NamedResource, prefix string) []NamedResource {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return resources
}
filtered := make([]NamedResource, 0, len(resources))
for _, resource := range resources {
if strings.HasPrefix(strings.TrimSpace(resource.Name), prefix) {
filtered = append(filtered, resource)
}
}
return filtered
}

View File

@@ -0,0 +1,20 @@
package sub2api
type ListManagedResourcesRequest struct {
GroupName string
ChannelName string
PlanName string
AccountNamePrefix string
}
type NamedResource struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ManagedResourceSnapshot struct {
Groups []NamedResource `json:"groups"`
Channels []NamedResource `json:"channels"`
Plans []NamedResource `json:"plans"`
Accounts []NamedResource `json:"accounts"`
}

View File

@@ -0,0 +1,699 @@
package sub2api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPErrorErrorMessage(t *testing.T) {
e := newHTTPError("POST", "/api/v1/admin/groups", http.StatusTeapot, []byte("short and stout"))
want := "sub2api POST /api/v1/admin/groups returned 418: short and stout"
if got := e.Error(); got != want {
t.Fatalf("HTTPError.Error() = %q, want %q", got, want)
}
}
func TestWithHTTPClientAndOptions(t *testing.T) {
customHTTP := &http.Client{Timeout: 123}
client, err := NewClient("http://localhost:8080",
WithHTTPClient(customHTTP),
WithAPIKey(" sk-abc "),
WithBearerToken(" tok-xyz "),
)
if err != nil {
t.Fatal(err)
}
if client.httpClient != customHTTP {
t.Fatal("WithHTTPClient not applied")
}
if client.apiKey != "sk-abc" {
t.Fatalf("apiKey = %q, want %q", client.apiKey, "sk-abc")
}
if client.bearerToken != "tok-xyz" {
t.Fatalf("bearerToken = %q, want %q", client.bearerToken, "tok-xyz")
}
}
func TestNewClient_RejectsInvalidURLs(t *testing.T) {
tests := []struct {
name string
url string
}{
{"empty", ""},
{"no scheme", "localhost:8080"},
{"no host", "http://"},
{"garbage", "://foo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewClient(tt.url)
if err == nil {
t.Fatalf("NewClient(%q) error = nil, want error", tt.url)
}
})
}
}
func TestResolvePath(t *testing.T) {
client, err := NewClient("http://host:9090")
if err != nil {
t.Fatal(err)
}
tests := []struct {
path string
want string
}{
{"/v1/models", "http://host:9090/v1/models"},
{"v1/models", "http://host:9090/v1/models"},
{"/v1/models?key=val", "http://host:9090/v1/models?key=val"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := client.resolvePath(tt.path); got != tt.want {
t.Fatalf("resolvePath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestApplyAuth(t *testing.T) {
t.Run("api key preferred", func(t *testing.T) {
c, _ := NewClient("http://h:8080", WithAPIKey("key1"), WithBearerToken("btok"))
req, _ := http.NewRequest("GET", "http://h:8080/path", nil)
c.applyAuth(req)
if h := req.Header.Get("x-api-key"); h != "key1" {
t.Fatalf("x-api-key = %q, want %q", h, "key1")
}
if h := req.Header.Get("Authorization"); h != "" {
t.Fatalf("Authorization should be empty, got %q", h)
}
})
t.Run("bearer token fallback", func(t *testing.T) {
c, _ := NewClient("http://h:8080", WithBearerToken("btok"))
req, _ := http.NewRequest("GET", "http://h:8080/path", nil)
c.applyAuth(req)
if h := req.Header.Get("Authorization"); h != "Bearer btok" {
t.Fatalf("Authorization = %q, want %q", h, "Bearer btok")
}
})
t.Run("no auth", func(t *testing.T) {
c, _ := NewClient("http://h:8080")
req, _ := http.NewRequest("GET", "http://h:8080/path", nil)
c.applyAuth(req)
if h := req.Header.Get("x-api-key"); h != "" {
t.Fatalf("x-api-key should be empty, got %q", h)
}
if h := req.Header.Get("Authorization"); h != "" {
t.Fatalf("Authorization should be empty, got %q", h)
}
})
}
func TestDecodeEnvelopeObject(t *testing.T) {
t.Run("standard envelope", func(t *testing.T) {
body := []byte(`{"data":{"id":"g1","name":"test"}}`)
var ref GroupRef
if err := decodeEnvelopeObject(body, &ref); err != nil {
t.Fatal(err)
}
if ref.ID != "g1" || ref.Name != "test" {
t.Fatalf("got %+v, want {ID:g1 Name:test}", ref)
}
})
t.Run("flat response (no data wrapper)", func(t *testing.T) {
body := []byte(`{"id":"g2","name":"flat"}`)
var ref GroupRef
if err := decodeEnvelopeObject(body, &ref); err != nil {
t.Fatal(err)
}
if ref.ID != "g2" || ref.Name != "flat" {
t.Fatalf("got %+v, want {ID:g2 Name:flat}", ref)
}
})
t.Run("data:null returns flat", func(t *testing.T) {
body := []byte(`{"data":null,"id":"g3"}`)
var ref GroupRef
if err := decodeEnvelopeObject(body, &ref); err != nil {
t.Fatal(err)
}
if ref.ID != "g3" {
t.Fatalf("id = %q, want %q", ref.ID, "g3")
}
})
t.Run("invalid json returns error", func(t *testing.T) {
var ref GroupRef
if err := decodeEnvelopeObject([]byte(`not json`), &ref); err == nil {
t.Fatal("expected error")
}
})
}
func TestDecodeGatewayModelIDs(t *testing.T) {
t.Run("standard list", func(t *testing.T) {
ids := decodeGatewayModelIDs([]byte(`{"data":[{"id":"gpt-4"},{"id":" claude-3 "}]}`))
if len(ids) != 2 || ids[0] != "gpt-4" || ids[1] != "claude-3" {
t.Fatalf("got %v, want [gpt-4 claude-3]", ids)
}
})
t.Run("empty data", func(t *testing.T) {
if ids := decodeGatewayModelIDs([]byte(`{}`)); ids != nil {
t.Fatalf("expected nil, got %v", ids)
}
})
t.Run("invalid json", func(t *testing.T) {
if ids := decodeGatewayModelIDs([]byte(`not json`)); ids != nil {
t.Fatalf("expected nil, got %v", ids)
}
})
t.Run("empty array", func(t *testing.T) {
if ids := decodeGatewayModelIDs([]byte(`{"data":[]}`)); ids != nil {
t.Fatalf("expected nil, got %v", ids)
}
})
}
func TestFilterNamedResourcesByName(t *testing.T) {
resources := []NamedResource{
{Name: "group-a", ID: "g1"},
{Name: "group-b", ID: "g2"},
{Name: " group-a ", ID: "g3"},
}
t.Run("match", func(t *testing.T) {
got := filterNamedResourcesByName(resources, "group-a")
if len(got) != 2 || got[0].ID != "g1" || got[1].ID != "g3" {
t.Fatalf("got %+v, want 2 matches", got)
}
})
t.Run("no match", func(t *testing.T) {
if got := filterNamedResourcesByName(resources, "nonexistent"); len(got) != 0 {
t.Fatalf("expected 0, got %d", len(got))
}
})
t.Run("empty name returns all", func(t *testing.T) {
if got := filterNamedResourcesByName(resources, ""); len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
})
}
func TestFilterNamedResourcesByPrefix(t *testing.T) {
resources := []NamedResource{
{Name: "deepseek-proxy", ID: "r1"},
{Name: "deepseek-us", ID: "r2"},
{Name: "claude-eu", ID: "r3"},
}
t.Run("prefix matches", func(t *testing.T) {
got := filterNamedResourcesByPrefix(resources, "deepseek")
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
})
t.Run("no prefix match", func(t *testing.T) {
if got := filterNamedResourcesByPrefix(resources, "nope"); len(got) != 0 {
t.Fatalf("expected 0, got %d", len(got))
}
})
t.Run("empty prefix returns all", func(t *testing.T) {
if got := filterNamedResourcesByPrefix(resources, ""); len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
})
}
func TestDecodeNamedResources(t *testing.T) {
t.Run("envelope", func(t *testing.T) {
resources, err := decodeNamedResources([]byte(`{"data":[{"id":"r1","name":"n1"}]}`))
if err != nil {
t.Fatal(err)
}
if len(resources) != 1 || resources[0].ID != "r1" {
t.Fatalf("got %+v", resources)
}
})
t.Run("wrapper with items", func(t *testing.T) {
resources, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":"r2","name":"n2"}]}}`))
if err != nil {
t.Fatal(err)
}
if len(resources) != 1 || resources[0].ID != "r2" {
t.Fatalf("got %+v", resources)
}
})
t.Run("invalid json", func(t *testing.T) {
_, err := decodeNamedResources([]byte(`not json`))
if err == nil {
t.Fatal("expected error")
}
})
}
func TestDecodeAccountRefs(t *testing.T) {
t.Run("envelope", func(t *testing.T) {
refs, err := decodeAccountRefs([]byte(`{"data":[{"id":"a1"}]}`))
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 || refs[0].ID != "a1" {
t.Fatalf("got %+v", refs)
}
})
t.Run("wrapper with items", func(t *testing.T) {
refs, err := decodeAccountRefs([]byte(`{"data":{"items":[{"id":"a2"}]}}`))
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 || refs[0].ID != "a2" {
t.Fatalf("got %+v", refs)
}
})
t.Run("invalid json", func(t *testing.T) {
_, err := decodeAccountRefs([]byte(`not json`))
if err == nil {
t.Fatal("expected error")
}
})
}
func TestDecodeAccountModels(t *testing.T) {
t.Run("envelope", func(t *testing.T) {
models, err := decodeAccountModels([]byte(`{"data":[{"id":"gpt4","display_name":"GPT-4","type":"chat"}]}`))
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "gpt4" {
t.Fatalf("got %+v", models)
}
})
t.Run("wrapper with items", func(t *testing.T) {
models, err := decodeAccountModels([]byte(`{"data":{"items":[{"id":"cl3","display_name":"Claude 3","type":"chat"}]}}`))
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "cl3" {
t.Fatalf("got %+v", models)
}
})
t.Run("invalid json", func(t *testing.T) {
_, err := decodeAccountModels([]byte(`not json`))
if err == nil {
t.Fatal("expected error")
}
})
}
func TestParseProbeResult(t *testing.T) {
t.Run("SSE with ok=true", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"passed\",\"ok\":true}\n"))
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Status != "passed" {
t.Fatalf("got %+v, want OK=true Status=passed", result)
}
})
t.Run("SSE with success=true", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"succeeded\",\"success\":true}\n"))
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Status != "passed" {
t.Fatalf("got %+v", result)
}
})
t.Run("SSE with ok=false", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"failed\",\"ok\":false}\n"))
if err != nil {
t.Fatal(err)
}
if result.OK || result.Status != "failed" {
t.Fatalf("got %+v", result)
}
})
t.Run("SSE with status-based ok", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"pass\",\"message\":\"all good\"}\n"))
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Message != "all good" {
t.Fatalf("got %+v", result)
}
})
t.Run("multiple SSE events picks last", func(t *testing.T) {
result, err := parseProbeResult([]byte("data: {\"status\":\"running\"}\ndata: {\"status\":\"passed\",\"ok\":true}\n"))
if err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatalf("expected OK=true from last event, got %+v", result)
}
})
t.Run("no data events", func(t *testing.T) {
_, err := parseProbeResult([]byte("not data\n"))
if err == nil {
t.Fatal("expected error")
}
})
}
func TestNormalizeProbeStatus(t *testing.T) {
tests := []struct {
status string
ok bool
want string
}{
{"pass", true, "passed"},
{"PASSED", true, "passed"},
{"Ok", true, "passed"},
{"success", true, "passed"},
{"succeeded", true, "passed"},
{"fail", false, "failed"},
{"FAILED", false, "failed"},
{"error", false, "failed"},
{"custom_ok", true, "passed"},
{"custom_fail", false, "failed"},
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
if got := normalizeProbeStatus(tt.status, tt.ok); got != tt.want {
t.Fatalf("normalizeProbeStatus(%q, %v) = %q, want %q", tt.status, tt.ok, got, tt.want)
}
})
}
}
func TestLooksLikeExistingEndpoint(t *testing.T) {
t.Run("json content type", func(t *testing.T) {
h := http.Header{"Content-Type": []string{"application/json"}}
if !looksLikeExistingEndpoint(h, nil) {
t.Fatal("expected true with json content type")
}
})
t.Run("sse content type", func(t *testing.T) {
h := http.Header{"Content-Type": []string{"text/event-stream"}}
if !looksLikeExistingEndpoint(h, nil) {
t.Fatal("expected true with sse content type")
}
})
t.Run("empty body and no content type", func(t *testing.T) {
if looksLikeExistingEndpoint(http.Header{}, nil) {
t.Fatal("expected false")
}
})
t.Run("json-like body", func(t *testing.T) {
if !looksLikeExistingEndpoint(http.Header{}, []byte(`{"error":"not found"}`)) {
t.Fatal("expected true for json body")
}
})
t.Run("array body", func(t *testing.T) {
if !looksLikeExistingEndpoint(http.Header{}, []byte(`[]`)) {
t.Fatal("expected true for array body")
}
})
t.Run("html body", func(t *testing.T) {
if looksLikeExistingEndpoint(http.Header{}, []byte(`<html>`)) {
t.Fatal("expected false for html body")
}
})
}
// Tests for NamedResource type used by the filter functions.
// Defined locally since it's in the same package.
func TestNewClientWithNilOption(t *testing.T) {
client, err := NewClient("http://localhost:8080", nil)
if err != nil {
t.Fatal(err)
}
if client == nil {
t.Fatal("client is nil")
}
}
func TestNewHTTPError(t *testing.T) {
e := newHTTPError("GET", "/v1/models", 200, []byte(`{"ok":true}`))
if e.Method != "GET" || e.Path != "/v1/models" || e.StatusCode != 200 || e.Body != `{"ok":true}` {
t.Fatalf("unexpected http error: %+v", e)
}
}
func TestPerformWithMockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/admin/system/version":
w.Write([]byte(`{"data":{"version":"v1.2.3"}}`))
case "/api/v1/admin/groups":
w.Write([]byte(`{"data":{"id":"g1","name":"test-group"}}`))
case "/api/v1/admin/channels":
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"panic"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
client, err := NewClient(srv.URL, WithAPIKey("test-key"))
if err != nil {
t.Fatal(err)
}
t.Run("GetHostVersion", func(t *testing.T) {
ver, err := client.GetHostVersion(context.Background())
if err != nil {
t.Fatal(err)
}
if ver != "v1.2.3" {
t.Fatalf("version = %q, want %q", ver, "v1.2.3")
}
})
t.Run("postJSON success", func(t *testing.T) {
var ref GroupRef
if err := client.postJSON(context.Background(), "/api/v1/admin/groups", CreateGroupRequest{Name: "test"}, &ref); err != nil {
t.Fatal(err)
}
if ref.ID != "g1" || ref.Name != "test-group" {
t.Fatalf("got %+v, want {ID:g1 Name:test-group}", ref)
}
})
t.Run("postJSON error status", func(t *testing.T) {
var ref GroupRef
err := client.postJSON(context.Background(), "/api/v1/admin/channels", nil, &ref)
if err == nil {
t.Fatal("expected error")
}
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("expected HTTPError, got %T: %v", err, err)
}
if httpErr.StatusCode != 500 {
t.Fatalf("status code = %d, want 500", httpErr.StatusCode)
}
})
t.Run("getJSON success", func(t *testing.T) {
var ref GroupRef
if err := client.getJSON(context.Background(), "/api/v1/admin/groups", &ref); err != nil {
t.Fatal(err)
}
})
t.Run("getJSON error status", func(t *testing.T) {
var ref GroupRef
err := client.getJSON(context.Background(), "/bad/path", &ref)
if err == nil {
t.Fatal("expected error")
}
})
}
func TestCreateGroupWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"id":"g1","name":"demo"}}`))
}))
defer srv.Close()
client, err := NewClient(srv.URL, WithAPIKey("k"))
if err != nil {
t.Fatal(err)
}
ref, err := client.CreateGroup(context.Background(), CreateGroupRequest{Name: "demo", RateMultiplier: 1.0})
if err != nil {
t.Fatal(err)
}
if ref.ID != "g1" || ref.Name != "demo" {
t.Fatalf("got %+v", ref)
}
}
func TestCreateChannelWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"id":"c1","name":"ch"}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
_, err := client.CreateChannel(context.Background(), CreateChannelRequest{Name: "ch"})
if err != nil {
t.Fatal(err)
}
}
func TestCreatePlanWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"id":"p1","name":"plan"}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
_, err := client.CreatePlan(context.Background(), CreatePlanRequest{Name: "plan"})
if err != nil {
t.Fatal(err)
}
}
func TestDeleteWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
t.Run("DeleteGroup", func(t *testing.T) {
if err := client.DeleteGroup(context.Background(), "g1"); err != nil {
t.Fatal(err)
}
})
t.Run("DeleteChannel", func(t *testing.T) {
if err := client.DeleteChannel(context.Background(), "c1"); err != nil {
t.Fatal(err)
}
})
t.Run("DeletePlan", func(t *testing.T) {
if err := client.DeletePlan(context.Background(), "p1"); err != nil {
t.Fatal(err)
}
})
t.Run("DeleteAccount", func(t *testing.T) {
if err := client.DeleteAccount(context.Background(), "a1"); err != nil {
t.Fatal(err)
}
})
}
func TestAssignSubscriptionWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"id":"s1"}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
ref, err := client.AssignSubscription(context.Background(), AssignSubscriptionRequest{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
if ref.ID != "s1" {
t.Fatalf("id = %q", ref.ID)
}
}
func TestCheckGatewayAccessWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"gpt-4"},{"id":"claude-3"}]}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
result, err := client.CheckGatewayAccess(context.Background(), GatewayAccessCheckRequest{APIKey: "gk", ExpectedModel: "gpt-4"})
if err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatal("expected OK=true")
}
if !result.HasExpectedModel {
t.Fatal("expected HasExpectedModel=true")
}
}
func TestBatchCreateAccountsWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"a1","name":"acct1"}]}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
refs, err := client.BatchCreateAccounts(context.Background(), BatchCreateAccountsRequest{
Accounts: []CreateAccountRequest{{Name: "acct1"}},
})
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 || refs[0].ID != "a1" {
t.Fatalf("got %+v", refs)
}
}
func TestProbeCapabilitiesWithMock(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
caps, err := client.ProbeCapabilities(context.Background())
if err != nil {
t.Fatal(err)
}
if !caps.Groups || !caps.Channels || !caps.Plans || !caps.Accounts || !caps.AccountTest || !caps.AccountModels || !caps.Subscriptions {
t.Fatalf("all capabilities should be true, got %+v", caps)
}
}
func TestListManagedResourcesWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"items":[
{"id":"r1","name":"resource-1"}
]}}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
snapshot, err := client.ListManagedResources(context.Background(), ListManagedResourcesRequest{})
if err != nil {
t.Fatal(err)
}
if len(snapshot.Groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(snapshot.Groups))
}
}
func TestTestAccountWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("data: {\"status\":\"passed\",\"ok\":true}\n"))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
result, err := client.TestAccount(context.Background(), "a1")
if err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatal("expected OK=true")
}
}
func TestGetAccountModelsWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"m1","display_name":"M1","type":"chat"}]}`))
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithAPIKey("k"))
models, err := client.GetAccountModels(context.Background(), "a1")
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID != "m1" {
t.Fatalf("got %+v", models)
}
}

266
internal/pack/extra_test.go Normal file
View File

@@ -0,0 +1,266 @@
package pack
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateManifestRequiredFields(t *testing.T) {
tests := []struct {
name string
manifest Manifest
wantErr string
}{
{
name: "empty pack id",
manifest: Manifest{
Version: "1.0.0",
TargetHost: "sub2api",
ProvidersDir: "providers",
ChecksumFile: "checksums.txt",
},
wantErr: "pack_id is required",
},
{
name: "empty version",
manifest: Manifest{
PackID: "openai-cn-pack",
TargetHost: "sub2api",
ProvidersDir: "providers",
ChecksumFile: "checksums.txt",
},
wantErr: "version is required",
},
{
name: "empty target host",
manifest: Manifest{
PackID: "openai-cn-pack",
Version: "1.0.0",
ProvidersDir: "providers",
ChecksumFile: "checksums.txt",
},
wantErr: "target_host is required",
},
{
name: "empty providers dir",
manifest: Manifest{
PackID: "openai-cn-pack",
Version: "1.0.0",
TargetHost: "sub2api",
ChecksumFile: "checksums.txt",
},
wantErr: "providers_dir is required",
},
{
name: "empty checksum file",
manifest: Manifest{
PackID: "openai-cn-pack",
Version: "1.0.0",
TargetHost: "sub2api",
ProvidersDir: "providers",
},
wantErr: "checksum_file is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateManifest(tt.manifest)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validateManifest() error = %v, want substring %q", err, tt.wantErr)
}
})
}
}
func TestValidateProvidersRejectsInvalidProviderFields(t *testing.T) {
tests := []struct {
name string
providers []ProviderManifest
wantErr string
}{
{
name: "empty provider id",
providers: []ProviderManifest{{
DisplayName: "DeepSeek",
BaseURL: "https://api.deepseek.com",
Platform: "openai",
AccountType: "api",
DefaultModels: []string{"deepseek-chat"},
SmokeTestModel: "deepseek-chat",
GroupTemplate: GroupTemplate{Name: "g"},
ChannelTemplate: ChannelTemplate{
Name: "c",
ModelMapping: map[string]string{"deepseek-chat": "deepseek-chat"},
},
PlanTemplate: PlanTemplate{Name: "p", ValidityDays: 30},
}},
wantErr: "provider_id is required",
},
{
name: "empty base url",
providers: []ProviderManifest{{
ProviderID: "deepseek",
DisplayName: "DeepSeek",
BaseURL: "",
Platform: "openai",
AccountType: "api",
DefaultModels: []string{"deepseek-chat"},
SmokeTestModel: "deepseek-chat",
GroupTemplate: GroupTemplate{Name: "g"},
ChannelTemplate: ChannelTemplate{
Name: "c",
ModelMapping: map[string]string{"deepseek-chat": "deepseek-chat"},
},
PlanTemplate: PlanTemplate{Name: "p", ValidityDays: 30},
}},
wantErr: "base_url must use https",
},
{
name: "missing display name",
providers: []ProviderManifest{{
ProviderID: "deepseek",
DisplayName: "",
BaseURL: "https://api.deepseek.com",
Platform: "openai",
AccountType: "api",
DefaultModels: []string{"deepseek-chat"},
SmokeTestModel: "deepseek-chat",
GroupTemplate: GroupTemplate{Name: "g"},
ChannelTemplate: ChannelTemplate{
Name: "c",
ModelMapping: map[string]string{"deepseek-chat": "deepseek-chat"},
},
PlanTemplate: PlanTemplate{Name: "p", ValidityDays: 30},
}},
wantErr: "display_name is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateProviders(tt.providers)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validateProviders() error = %v, want substring %q", err, tt.wantErr)
}
})
}
}
func TestExtractZipToTempRejectsEmptyArchive(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "empty.zip")
file, err := os.Create(archivePath)
if err != nil {
t.Fatalf("os.Create() error = %v", err)
}
writer := zip.NewWriter(file)
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("file.Close() error = %v", err)
}
_, cleanup, err := extractZipToTemp(archivePath)
if cleanup != nil {
cleanup()
}
if err == nil || !strings.Contains(err.Error(), "pack archive is empty") {
t.Fatalf("extractZipToTemp() error = %v, want empty archive error", err)
}
}
func TestExtractZipToTempRejectsPathTraversal(t *testing.T) {
archivePath := filepath.Join(t.TempDir(), "traversal.zip")
file, err := os.Create(archivePath)
if err != nil {
t.Fatalf("os.Create() error = %v", err)
}
writer := zip.NewWriter(file)
entry, err := writer.Create("../../../evil.txt")
if err != nil {
t.Fatalf("writer.Create() error = %v", err)
}
if _, err := entry.Write([]byte("evil")); err != nil {
t.Fatalf("entry.Write() error = %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("file.Close() error = %v", err)
}
_, cleanup, err := extractZipToTemp(archivePath)
if cleanup != nil {
cleanup()
}
if err == nil || !strings.Contains(err.Error(), "invalid path") {
t.Fatalf("extractZipToTemp() error = %v, want invalid path error", err)
}
}
func TestMatchesMaxConstraintCases(t *testing.T) {
tests := []struct {
name string
hostVersion string
maxVersion string
want bool
}{
{name: "exact version match", hostVersion: "1.2.3", maxVersion: "1.2.3", want: true},
{name: "wildcard x accepts same minor", hostVersion: "0.2.9", maxVersion: "0.2.x", want: true},
{name: "non matching version", hostVersion: "1.2.4", maxVersion: "1.2.3", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := matchesMaxConstraint(tt.hostVersion, tt.maxVersion)
if err != nil {
t.Fatalf("matchesMaxConstraint() error = %v", err)
}
if got != tt.want {
t.Fatalf("matchesMaxConstraint() = %v, want %v", got, tt.want)
}
})
}
}
func TestMatchesMaxConstraintRejectsWildcardStar(t *testing.T) {
_, err := matchesMaxConstraint("1.2.3", "1.2.*")
if err == nil || !strings.Contains(err.Error(), `parse version segment "*"`) {
t.Fatalf("matchesMaxConstraint() error = %v, want parse failure for wildcard star", err)
}
}
func TestLoadPathRejectsEmptyAndMissingPath(t *testing.T) {
tests := []struct {
name string
path string
wantErr string
}{
{name: "empty path", path: " ", wantErr: "pack path is required"},
{name: "missing path", path: filepath.Join(t.TempDir(), "missing-pack"), wantErr: "stat pack path"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := LoadPath(tt.path)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("LoadPath() error = %v, want substring %q", err, tt.wantErr)
}
})
}
}
func TestLoadArchiveRejectsNonZipFile(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "not-a-zip.zip")
mustWrite(t, filePath, "plain text, not a zip archive")
_, err := LoadArchive(filePath)
if err == nil || !strings.Contains(err.Error(), "open pack archive") {
t.Fatalf("LoadArchive() error = %v, want open archive error", err)
}
}

249
internal/pack/loader.go Normal file
View File

@@ -0,0 +1,249 @@
package pack
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
type Manifest struct {
PackID string `json:"pack_id"`
Version string `json:"version"`
Vendor string `json:"vendor"`
TargetHost string `json:"target_host"`
MinHostVersion string `json:"min_host_version"`
MaxHostVersion string `json:"max_host_version"`
ProvidersDir string `json:"providers_dir"`
ChecksumFile string `json:"checksum_file"`
}
type ProviderManifest struct {
ProviderID string `json:"provider_id"`
DisplayName string `json:"display_name"`
BaseURL string `json:"base_url"`
Platform string `json:"platform"`
AccountType string `json:"account_type"`
DefaultModels []string `json:"default_models"`
SmokeTestModel string `json:"smoke_test_model"`
GroupTemplate GroupTemplate `json:"group_template"`
ChannelTemplate ChannelTemplate `json:"channel_template"`
PlanTemplate PlanTemplate `json:"plan_template"`
Import ImportOptions `json:"import"`
}
type GroupTemplate struct {
Name string `json:"name"`
RateMultiplier float64 `json:"rate_multiplier"`
}
type ChannelTemplate struct {
Name string `json:"name"`
ModelMapping map[string]string `json:"model_mapping"`
}
type PlanTemplate struct {
Name string `json:"name"`
Price float64 `json:"price"`
ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validity_unit"`
}
type ImportOptions struct {
SupportsMultiKey bool `json:"supports_multi_key"`
SupportsStrict bool `json:"supports_strict"`
SupportsPartial bool `json:"supports_partial"`
}
type LoadedPack struct {
Dir string
Manifest Manifest
Providers []ProviderManifest
Checksum string
}
func LoadDir(dir string) (LoadedPack, error) {
root := strings.TrimSpace(dir)
if root == "" {
return LoadedPack{}, fmt.Errorf("pack dir is required")
}
manifestPath := filepath.Join(root, "pack.json")
manifestBytes, err := os.ReadFile(manifestPath)
if err != nil {
return LoadedPack{}, fmt.Errorf("read pack.json: %w", err)
}
var manifest Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
return LoadedPack{}, fmt.Errorf("decode pack.json: %w", err)
}
if err := validateManifest(manifest); err != nil {
return LoadedPack{}, err
}
if err := validateChecksums(root, manifest.ChecksumFile); err != nil {
return LoadedPack{}, err
}
providers, err := loadProviders(root, manifest.ProvidersDir)
if err != nil {
return LoadedPack{}, err
}
if len(providers) == 0 {
return LoadedPack{}, fmt.Errorf("providers dir %q does not contain provider manifests", manifest.ProvidersDir)
}
if err := validateProviders(providers); err != nil {
return LoadedPack{}, err
}
checksum, err := computeAggregateChecksum(root, manifest.ChecksumFile)
if err != nil {
return LoadedPack{}, err
}
return LoadedPack{Dir: root, Manifest: manifest, Providers: providers, Checksum: checksum}, nil
}
func validateManifest(manifest Manifest) error {
switch {
case strings.TrimSpace(manifest.PackID) == "":
return fmt.Errorf("pack.json: pack_id is required")
case strings.TrimSpace(manifest.Version) == "":
return fmt.Errorf("pack.json: version is required")
case strings.TrimSpace(manifest.TargetHost) == "":
return fmt.Errorf("pack.json: target_host is required")
case strings.TrimSpace(manifest.ProvidersDir) == "":
return fmt.Errorf("pack.json: providers_dir is required")
case strings.TrimSpace(manifest.ChecksumFile) == "":
return fmt.Errorf("pack.json: checksum_file is required")
}
return nil
}
func loadProviders(root string, providersDir string) ([]ProviderManifest, error) {
dir := filepath.Join(root, providersDir)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read providers dir %q: %w", providersDir, err)
}
providers := make([]ProviderManifest, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
path := filepath.Join(dir, entry.Name())
body, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read provider %q: %w", entry.Name(), err)
}
var provider ProviderManifest
if err := json.Unmarshal(body, &provider); err != nil {
return nil, fmt.Errorf("decode provider %q: %w", entry.Name(), err)
}
providers = append(providers, provider)
}
sort.Slice(providers, func(i, j int) bool { return providers[i].ProviderID < providers[j].ProviderID })
return providers, nil
}
func validateProviders(providers []ProviderManifest) error {
seen := make(map[string]struct{}, len(providers))
for _, provider := range providers {
providerID := strings.TrimSpace(provider.ProviderID)
switch {
case providerID == "":
return fmt.Errorf("provider manifest: provider_id is required")
case strings.TrimSpace(provider.DisplayName) == "":
return fmt.Errorf("provider %q: display_name is required", providerID)
case !strings.HasPrefix(strings.TrimSpace(provider.BaseURL), "https://"):
return fmt.Errorf("provider %q: base_url must use https", providerID)
case strings.TrimSpace(provider.Platform) == "":
return fmt.Errorf("provider %q: platform is required", providerID)
case strings.TrimSpace(provider.AccountType) == "":
return fmt.Errorf("provider %q: account_type is required", providerID)
case len(provider.DefaultModels) == 0:
return fmt.Errorf("provider %q: default_models must not be empty", providerID)
case strings.TrimSpace(provider.SmokeTestModel) == "":
return fmt.Errorf("provider %q: smoke_test_model is required", providerID)
case !contains(provider.DefaultModels, provider.SmokeTestModel):
return fmt.Errorf("provider %q: smoke_test_model must be present in default_models", providerID)
case strings.TrimSpace(provider.GroupTemplate.Name) == "":
return fmt.Errorf("provider %q: group_template.name is required", providerID)
case strings.TrimSpace(provider.ChannelTemplate.Name) == "":
return fmt.Errorf("provider %q: channel_template.name is required", providerID)
case len(provider.ChannelTemplate.ModelMapping) == 0:
return fmt.Errorf("provider %q: channel_template.model_mapping must not be empty", providerID)
case strings.TrimSpace(provider.PlanTemplate.Name) == "":
return fmt.Errorf("provider %q: plan_template.name is required", providerID)
case provider.PlanTemplate.ValidityDays <= 0:
return fmt.Errorf("provider %q: plan_template.validity_days must be positive", providerID)
}
if _, ok := seen[providerID]; ok {
return fmt.Errorf("duplicate provider_id %q", providerID)
}
seen[providerID] = struct{}{}
}
return nil
}
func validateChecksums(root string, checksumFile string) error {
path := filepath.Join(root, checksumFile)
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("read checksum file %q: %w", checksumFile, err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) != 2 {
return fmt.Errorf("checksum file %q line %d: invalid format", checksumFile, lineNumber)
}
relativePath := parts[1]
body, err := os.ReadFile(filepath.Join(root, relativePath))
if err != nil {
return fmt.Errorf("checksum file %q line %d: read %q: %w", checksumFile, lineNumber, relativePath, err)
}
sum := sha256.Sum256(body)
actual := hex.EncodeToString(sum[:])
if !strings.EqualFold(parts[0], actual) {
return fmt.Errorf("checksum mismatch for %s", relativePath)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan checksum file %q: %w", checksumFile, err)
}
return nil
}
func computeAggregateChecksum(root string, checksumFile string) (string, error) {
body, err := os.ReadFile(filepath.Join(root, checksumFile))
if err != nil {
return "", fmt.Errorf("read checksum file %q: %w", checksumFile, err)
}
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:]), nil
}
func contains(items []string, target string) bool {
for _, item := range items {
if strings.TrimSpace(item) == strings.TrimSpace(target) {
return true
}
}
return false
}

View File

@@ -0,0 +1,108 @@
package pack
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadDirParsesAndValidatesPack(t *testing.T) {
packDir := createPackFixture(t, map[string]string{
"pack.json": `{
"pack_id": "openai-cn-pack",
"version": "1.0.0",
"vendor": "YourTeam",
"target_host": "sub2api",
"min_host_version": "0.1.126",
"max_host_version": "0.2.x",
"providers_dir": "providers",
"checksum_file": "checksums.txt"
}`,
"providers/deepseek.json": `{
"provider_id": "deepseek",
"display_name": "DeepSeek OpenAI Compatible",
"base_url": "https://api.deepseek.com",
"platform": "openai",
"account_type": "api",
"default_models": ["deepseek-chat", "deepseek-reasoner"],
"smoke_test_model": "deepseek-chat",
"group_template": {"name": "DeepSeek 默认分组", "rate_multiplier": 1.0},
"channel_template": {"name": "DeepSeek 默认渠道", "model_mapping": {"deepseek-chat": "deepseek-chat"}},
"plan_template": {"name": "DeepSeek 默认套餐", "price": 19.9, "validity_days": 30, "validity_unit": "day"},
"import": {"supports_multi_key": true, "supports_strict": true, "supports_partial": true}
}`,
})
loaded, err := LoadDir(packDir)
if err != nil {
t.Fatalf("LoadDir() error = %v", err)
}
if loaded.Manifest.PackID != "openai-cn-pack" {
t.Fatalf("PackID = %q, want %q", loaded.Manifest.PackID, "openai-cn-pack")
}
if len(loaded.Providers) != 1 {
t.Fatalf("len(Providers) = %d, want 1", len(loaded.Providers))
}
if loaded.Providers[0].ProviderID != "deepseek" {
t.Fatalf("ProviderID = %q, want %q", loaded.Providers[0].ProviderID, "deepseek")
}
}
func TestLoadDirRejectsChecksumMismatch(t *testing.T) {
packDir := t.TempDir()
mustWrite(t, filepath.Join(packDir, "pack.json"), `{"pack_id":"openai-cn-pack","version":"1.0.0","vendor":"x","target_host":"sub2api","min_host_version":"0.1.126","max_host_version":"0.2.x","providers_dir":"providers","checksum_file":"checksums.txt"}`)
mustWrite(t, filepath.Join(packDir, "providers", "deepseek.json"), `{"provider_id":"deepseek","display_name":"DeepSeek","base_url":"https://api.deepseek.com","platform":"openai","account_type":"api","default_models":["deepseek-chat"],"smoke_test_model":"deepseek-chat","group_template":{"name":"g","rate_multiplier":1},"channel_template":{"name":"c","model_mapping":{"deepseek-chat":"deepseek-chat"}},"plan_template":{"name":"p","price":1,"validity_days":30,"validity_unit":"day"},"import":{"supports_multi_key":true,"supports_strict":true,"supports_partial":true}}`)
mustWrite(t, filepath.Join(packDir, "checksums.txt"), "deadbeef pack.json\ndeadbeef providers/deepseek.json\n")
_, err := LoadDir(packDir)
if err == nil {
t.Fatal("LoadDir() error = nil, want checksum mismatch")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("LoadDir() error = %v, want checksum mismatch", err)
}
}
func TestLoadDirRejectsInvalidProviderSchema(t *testing.T) {
packDir := createPackFixture(t, map[string]string{
"pack.json": `{"pack_id":"openai-cn-pack","version":"1.0.0","vendor":"x","target_host":"sub2api","min_host_version":"0.1.126","max_host_version":"0.2.x","providers_dir":"providers","checksum_file":"checksums.txt"}`,
"providers/deepseek.json": `{"provider_id":"deepseek","display_name":"DeepSeek","base_url":"http://insecure.example.com","platform":"openai","account_type":"api","default_models":["deepseek-chat"],"smoke_test_model":"missing-model","group_template":{"name":"g","rate_multiplier":1},"channel_template":{"name":"c","model_mapping":{"deepseek-chat":"deepseek-chat"}},"plan_template":{"name":"p","price":1,"validity_days":30,"validity_unit":"day"},"import":{"supports_multi_key":true,"supports_strict":true,"supports_partial":true}}`,
})
_, err := LoadDir(packDir)
if err == nil {
t.Fatal("LoadDir() error = nil, want schema validation failure")
}
if !strings.Contains(err.Error(), "https") && !strings.Contains(err.Error(), "smoke_test_model") {
t.Fatalf("LoadDir() error = %v, want schema validation detail", err)
}
}
func createPackFixture(t *testing.T, files map[string]string) string {
t.Helper()
packDir := t.TempDir()
var lines []string
for relativePath, content := range files {
absolutePath := filepath.Join(packDir, relativePath)
mustWrite(t, absolutePath, content)
sum := sha256.Sum256([]byte(content))
lines = append(lines, hex.EncodeToString(sum[:])+" "+relativePath)
}
mustWrite(t, filepath.Join(packDir, "checksums.txt"), strings.Join(lines, "\n")+"\n")
return packDir
}
func mustWrite(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}

View File

@@ -0,0 +1,171 @@
package pack
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const (
maxArchiveEntries = 256
maxArchiveFileSize = 5 << 20
maxArchiveTotalSize = 20 << 20
)
func LoadPath(path string) (LoadedPack, error) {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return LoadedPack{}, fmt.Errorf("pack path is required")
}
info, err := os.Stat(trimmed)
if err != nil {
return LoadedPack{}, fmt.Errorf("stat pack path: %w", err)
}
if info.IsDir() {
return LoadDir(trimmed)
}
if strings.EqualFold(filepath.Ext(info.Name()), ".zip") {
return LoadArchive(trimmed)
}
return LoadedPack{}, fmt.Errorf("pack path %q must be a directory or .zip archive", trimmed)
}
func LoadArchive(path string) (LoadedPack, error) {
root, cleanup, err := extractZipToTemp(path)
if err != nil {
return LoadedPack{}, err
}
defer cleanup()
loaded, err := LoadDir(root)
if err != nil {
return LoadedPack{}, err
}
loaded.Dir = strings.TrimSpace(path)
return loaded, nil
}
func extractZipToTemp(path string) (string, func(), error) {
reader, err := zip.OpenReader(strings.TrimSpace(path))
if err != nil {
return "", nil, fmt.Errorf("open pack archive: %w", err)
}
defer reader.Close()
if len(reader.File) == 0 {
return "", nil, fmt.Errorf("pack archive is empty")
}
if len(reader.File) > maxArchiveEntries {
return "", nil, fmt.Errorf("pack archive has too many entries: %d", len(reader.File))
}
tempDir, err := os.MkdirTemp("", "relay-pack-*")
if err != nil {
return "", nil, fmt.Errorf("create temp dir for pack archive: %w", err)
}
cleanup := func() { _ = os.RemoveAll(tempDir) }
var totalSize uint64
for _, file := range reader.File {
cleanName := filepath.Clean(file.Name)
if cleanName == "." || cleanName == "" {
continue
}
if filepath.IsAbs(cleanName) || cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) {
cleanup()
return "", nil, fmt.Errorf("pack archive contains invalid path %q", file.Name)
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
cleanup()
return "", nil, fmt.Errorf("pack archive contains unsupported symlink entry %q", file.Name)
}
if file.UncompressedSize64 > maxArchiveFileSize {
cleanup()
return "", nil, fmt.Errorf("pack archive entry %q exceeds size limit", file.Name)
}
totalSize += file.UncompressedSize64
if totalSize > maxArchiveTotalSize {
cleanup()
return "", nil, fmt.Errorf("pack archive exceeds total size limit")
}
targetPath := filepath.Join(tempDir, cleanName)
relativeTarget, err := filepath.Rel(tempDir, targetPath)
if err != nil || relativeTarget == ".." || strings.HasPrefix(relativeTarget, ".."+string(filepath.Separator)) {
cleanup()
return "", nil, fmt.Errorf("pack archive entry %q escapes extraction root", file.Name)
}
if file.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0o755); err != nil {
cleanup()
return "", nil, fmt.Errorf("create archive dir %q: %w", file.Name, err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
cleanup()
return "", nil, fmt.Errorf("create archive parent dir %q: %w", file.Name, err)
}
src, err := file.Open()
if err != nil {
cleanup()
return "", nil, fmt.Errorf("open archive entry %q: %w", file.Name, err)
}
dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
src.Close()
cleanup()
return "", nil, fmt.Errorf("create archive file %q: %w", file.Name, err)
}
_, copyErr := io.Copy(dst, src)
closeErr := dst.Close()
srcErr := src.Close()
if copyErr != nil {
cleanup()
return "", nil, fmt.Errorf("extract archive entry %q: %w", file.Name, copyErr)
}
if closeErr != nil {
cleanup()
return "", nil, fmt.Errorf("close archive file %q: %w", file.Name, closeErr)
}
if srcErr != nil {
cleanup()
return "", nil, fmt.Errorf("close archive entry %q: %w", file.Name, srcErr)
}
}
root, err := resolvePackRoot(tempDir)
if err != nil {
cleanup()
return "", nil, err
}
return root, cleanup, nil
}
func resolvePackRoot(extractDir string) (string, error) {
manifestPath := filepath.Join(extractDir, "pack.json")
if _, err := os.Stat(manifestPath); err == nil {
return extractDir, nil
}
entries, err := os.ReadDir(extractDir)
if err != nil {
return "", fmt.Errorf("read extracted archive root: %w", err)
}
if len(entries) != 1 || !entries[0].IsDir() {
return "", fmt.Errorf("pack archive must contain pack.json at root or a single top-level directory")
}
root := filepath.Join(extractDir, entries[0].Name())
if _, err := os.Stat(filepath.Join(root, "pack.json")); err != nil {
return "", fmt.Errorf("pack archive root does not contain pack.json")
}
return root, nil
}

View File

@@ -0,0 +1,70 @@
package pack
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func TestLoadPathSupportsDirectory(t *testing.T) {
loaded, err := LoadPath(filepath.Join("..", "..", "packs", "openai-cn-pack"))
if err != nil {
t.Fatalf("LoadPath(dir) error = %v", err)
}
if loaded.Manifest.PackID != "openai-cn-pack" {
t.Fatalf("PackID = %q, want %q", loaded.Manifest.PackID, "openai-cn-pack")
}
}
func TestLoadPathSupportsZipArchive(t *testing.T) {
tempDir := t.TempDir()
archivePath := filepath.Join(tempDir, "openai-cn-pack.zip")
writePackArchive(t, archivePath)
loaded, err := LoadPath(archivePath)
if err != nil {
t.Fatalf("LoadPath(zip) error = %v", err)
}
if loaded.Manifest.PackID != "openai-cn-pack" {
t.Fatalf("PackID = %q, want %q", loaded.Manifest.PackID, "openai-cn-pack")
}
if len(loaded.Providers) == 0 {
t.Fatal("Providers = 0, want parsed providers from archive")
}
}
func writePackArchive(t *testing.T, archivePath string) {
t.Helper()
file, err := os.Create(archivePath)
if err != nil {
t.Fatalf("os.Create() error = %v", err)
}
defer file.Close()
writer := zip.NewWriter(file)
defer writer.Close()
sourceRoot := filepath.Join("..", "..", "packs", "openai-cn-pack")
files := []string{
"pack.json",
"checksums.txt",
filepath.Join("providers", "deepseek.json"),
}
for _, relativePath := range files {
body, err := os.ReadFile(filepath.Join(sourceRoot, relativePath))
if err != nil {
t.Fatalf("os.ReadFile(%q) error = %v", relativePath, err)
}
entry, err := writer.Create(filepath.ToSlash(filepath.Join("openai-cn-pack", relativePath)))
if err != nil {
t.Fatalf("Create(%q) error = %v", relativePath, err)
}
if _, err := entry.Write(body); err != nil {
t.Fatalf("Write(%q) error = %v", relativePath, err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("Close archive writer: %v", err)
}
}

134
internal/pack/version.go Normal file
View File

@@ -0,0 +1,134 @@
package pack
import (
"fmt"
"strconv"
"strings"
)
func CheckHostCompatibility(manifest Manifest, hostVersion string) error {
targetHost := strings.TrimSpace(manifest.TargetHost)
if targetHost == "" {
return fmt.Errorf("pack manifest target_host is required")
}
if targetHost != "sub2api" {
return fmt.Errorf("pack target_host %q is not supported", targetHost)
}
normalizedHost, err := parseVersion(strings.TrimSpace(hostVersion))
if err != nil {
return fmt.Errorf("parse host version %q: %w", hostVersion, err)
}
minVersion := strings.TrimSpace(manifest.MinHostVersion)
if minVersion != "" {
cmp, err := compareVersions(normalizedHost.raw, minVersion)
if err != nil {
return fmt.Errorf("compare min_host_version: %w", err)
}
if cmp < 0 {
return fmt.Errorf("host version %q is below min_host_version %q", hostVersion, minVersion)
}
}
maxVersion := strings.TrimSpace(manifest.MaxHostVersion)
if maxVersion != "" {
ok, err := matchesMaxConstraint(normalizedHost.raw, maxVersion)
if err != nil {
return fmt.Errorf("compare max_host_version: %w", err)
}
if !ok {
return fmt.Errorf("host version %q is above max_host_version %q", hostVersion, maxVersion)
}
}
return nil
}
type parsedVersion struct {
raw string
parts [3]int
}
func compareVersions(a, b string) (int, error) {
left, err := parseVersion(a)
if err != nil {
return 0, err
}
right, err := parseVersion(b)
if err != nil {
return 0, err
}
for i := 0; i < len(left.parts); i++ {
if left.parts[i] < right.parts[i] {
return -1, nil
}
if left.parts[i] > right.parts[i] {
return 1, nil
}
}
return 0, nil
}
func matchesMaxConstraint(hostVersion, maxVersion string) (bool, error) {
normalizedMax := normalizeVersion(maxVersion)
if strings.HasSuffix(normalizedMax, ".x") {
prefix := strings.TrimSuffix(normalizedMax, ".x")
parts := strings.Split(prefix, ".")
if len(parts) != 2 {
return false, fmt.Errorf("wildcard max version %q must be in N.N.x format", maxVersion)
}
host, err := parseVersion(hostVersion)
if err != nil {
return false, err
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return false, fmt.Errorf("parse major version %q: %w", parts[0], err)
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return false, fmt.Errorf("parse minor version %q: %w", parts[1], err)
}
if host.parts[0] < major {
return true, nil
}
if host.parts[0] > major {
return false, nil
}
return host.parts[1] <= minor, nil
}
cmp, err := compareVersions(hostVersion, maxVersion)
if err != nil {
return false, err
}
return cmp <= 0, nil
}
func parseVersion(value string) (parsedVersion, error) {
normalized := normalizeVersion(value)
if normalized == "" {
return parsedVersion{}, fmt.Errorf("version is required")
}
parts := strings.Split(normalized, ".")
if len(parts) != 3 {
return parsedVersion{}, fmt.Errorf("version %q must be in N.N.N format", value)
}
var parsed parsedVersion
parsed.raw = normalized
for i, part := range parts {
number, err := strconv.Atoi(part)
if err != nil {
return parsedVersion{}, fmt.Errorf("parse version segment %q: %w", part, err)
}
parsed.parts[i] = number
}
return parsed, nil
}
func normalizeVersion(value string) string {
trimmed := strings.TrimSpace(value)
trimmed = strings.TrimPrefix(trimmed, "v")
trimmed = strings.TrimPrefix(trimmed, "V")
return trimmed
}

View File

@@ -0,0 +1,32 @@
package pack
import "testing"
func TestCheckHostCompatibilityAcceptsRange(t *testing.T) {
manifest := Manifest{
PackID: "openai-cn-pack",
TargetHost: "sub2api",
MinHostVersion: "0.1.126",
MaxHostVersion: "0.2.x",
}
if err := CheckHostCompatibility(manifest, "0.1.126"); err != nil {
t.Fatalf("CheckHostCompatibility() error = %v, want nil", err)
}
if err := CheckHostCompatibility(manifest, "0.2.9"); err != nil {
t.Fatalf("CheckHostCompatibility() error = %v, want nil for wildcard upper bound", err)
}
}
func TestCheckHostCompatibilityRejectsBelowMinimum(t *testing.T) {
manifest := Manifest{TargetHost: "sub2api", MinHostVersion: "0.1.126"}
if err := CheckHostCompatibility(manifest, "0.1.125"); err == nil {
t.Fatal("CheckHostCompatibility() error = nil, want min version failure")
}
}
func TestCheckHostCompatibilityRejectsDifferentMaxMinor(t *testing.T) {
manifest := Manifest{TargetHost: "sub2api", MaxHostVersion: "0.2.x"}
if err := CheckHostCompatibility(manifest, "0.3.0"); err == nil {
t.Fatal("CheckHostCompatibility() error = nil, want max version failure")
}
}

View File

@@ -0,0 +1,321 @@
package provision
import (
"context"
"encoding/json"
"fmt"
"strings"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type BatchDetailResult struct {
Batch sqlite.ImportBatch
Items []sqlite.ImportBatchItem
ManagedResources []sqlite.ManagedResource
AccessClosures []sqlite.AccessClosureRecord
ReconcileRuns []sqlite.ReconcileRun
}
type BatchDetailService struct {
store *sqlite.DB
}
func NewBatchDetailService(store *sqlite.DB) *BatchDetailService {
return &BatchDetailService{store: store}
}
func (s *BatchDetailService) Get(ctx context.Context, batchID int64) (BatchDetailResult, error) {
if s == nil || s.store == nil {
return BatchDetailResult{}, fmt.Errorf("store is required")
}
batch, err := s.store.ImportBatches().GetByID(ctx, batchID)
if err != nil {
return BatchDetailResult{}, err
}
items, err := s.store.ImportBatchItems().GetByBatchID(ctx, batchID)
if err != nil {
return BatchDetailResult{}, err
}
managedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchID)
if err != nil {
return BatchDetailResult{}, err
}
accessClosures, err := s.store.AccessClosures().GetByBatchID(ctx, batchID)
if err != nil {
return BatchDetailResult{}, err
}
reconcileRuns, err := s.store.ReconcileRuns().GetByProviderID(ctx, batch.ProviderID)
if err != nil {
return BatchDetailResult{}, err
}
return BatchDetailResult{
Batch: batch,
Items: items,
ManagedResources: managedResources,
AccessClosures: accessClosures,
ReconcileRuns: reconcileRuns,
}, nil
}
type ReconcileRequest struct {
HostBaseURL string
AccessProbeAPIKey string
Pack pack.LoadedPack
Provider pack.ProviderManifest
}
type ReconcileResult struct {
BatchID int64
Status string
MissingCount int
ExtraCount int
ProbeFailureCount int
AccessStatus string
Summary map[string]any
}
type ReconcileService struct {
store *sqlite.DB
host sub2api.HostAdapter
}
func NewReconcileService(store *sqlite.DB, host sub2api.HostAdapter) *ReconcileService {
return &ReconcileService{store: store, host: host}
}
func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) (ReconcileResult, error) {
if s == nil || s.store == nil {
return ReconcileResult{}, fmt.Errorf("store is required")
}
if s.host == nil {
return ReconcileResult{}, fmt.Errorf("host adapter is required")
}
if strings.TrimSpace(req.HostBaseURL) == "" {
return ReconcileResult{}, fmt.Errorf("host_base_url is required")
}
hostVersion, err := s.host.GetHostVersion(ctx)
if err != nil {
return ReconcileResult{}, fmt.Errorf("get host version: %w", err)
}
if err := pack.CheckHostCompatibility(req.Pack.Manifest, hostVersion); err != nil {
return ReconcileResult{}, err
}
packRow, err := s.store.Packs().GetByPackID(ctx, req.Pack.Manifest.PackID)
if err != nil {
return ReconcileResult{}, err
}
providerRow, err := s.store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, req.Provider.ProviderID)
if err != nil {
return ReconcileResult{}, err
}
batchRow, err := s.store.ImportBatches().GetLatestByProviderID(ctx, providerRow.ID)
if err != nil {
return ReconcileResult{}, err
}
storedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return ReconcileResult{}, err
}
batchItems, err := s.store.ImportBatchItems().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return ReconcileResult{}, err
}
accessClosures, err := s.store.AccessClosures().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return ReconcileResult{}, err
}
snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{
GroupName: SuggestResourceNames(req.Provider).Group,
ChannelName: SuggestResourceNames(req.Provider).Channel,
PlanName: SuggestResourceNames(req.Provider).Plan,
})
if err != nil {
return ReconcileResult{}, fmt.Errorf("list managed resources: %w", err)
}
missing, extra := diffManagedResources(storedResources, snapshot)
probeFailures, err := s.rerunAccountProbes(ctx, batchItems, req.Provider.SmokeTestModel)
if err != nil {
return ReconcileResult{}, err
}
accessStatus, accessChecked, err := s.rerunAccessClosure(ctx, batchRow.ID, accessClosures, req.AccessProbeAPIKey, req.Provider.SmokeTestModel)
if err != nil {
return ReconcileResult{}, err
}
status := "active"
if missing > 0 || extra > 0 {
status = "drifted"
} else if probeFailures > 0 || (accessChecked && accessStatus == AccessStatusBroken) {
status = "degraded"
}
summary := map[string]any{
"missing_count": missing,
"extra_count": extra,
"host_version": hostVersion,
"probe_failures": probeFailures,
"access_status": accessStatus,
"access_rechecked": accessChecked,
}
summaryJSON, err := json.Marshal(summary)
if err != nil {
return ReconcileResult{}, fmt.Errorf("marshal reconcile summary: %w", err)
}
if _, err := s.store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{ProviderID: providerRow.ID, Status: status, SummaryJSON: string(summaryJSON)}); err != nil {
return ReconcileResult{}, err
}
return ReconcileResult{BatchID: batchRow.ID, Status: status, MissingCount: missing, ExtraCount: extra, ProbeFailureCount: probeFailures, AccessStatus: accessStatus, Summary: summary}, nil
}
func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlite.ImportBatchItem, expectedModel string) (int, error) {
if len(items) == 0 {
return 0, nil
}
failures := 0
for _, item := range items {
accountID, err := accountIDFromProbeSummary(item.ProbeSummaryJSON)
if err != nil {
return 0, fmt.Errorf("decode import batch item %d probe summary: %w", item.ID, err)
}
if strings.TrimSpace(accountID) == "" {
return 0, fmt.Errorf("import batch item %d missing account_id in probe summary", item.ID)
}
probe, err := s.host.TestAccount(ctx, accountID)
if err != nil {
return 0, fmt.Errorf("re-test account %s: %w", accountID, err)
}
models, err := s.host.GetAccountModels(ctx, accountID)
if err != nil {
return 0, fmt.Errorf("reload account models %s: %w", accountID, err)
}
smokeModelSeen := hasModel(models, expectedModel)
status := firstNonEmpty(probe.Status, "unknown")
payload, err := json.Marshal(map[string]any{
"account_id": accountID,
"probe_ok": probe.OK,
"probe_status": probe.Status,
"probe_message": probe.Message,
"models": models,
"smoke_model_seen": smokeModelSeen,
"reconcile_rerun": true,
})
if err != nil {
return 0, fmt.Errorf("marshal probe rerun summary for %s: %w", accountID, err)
}
if err := s.store.ImportBatchItems().UpdateResult(ctx, item.ID, status, string(payload)); err != nil {
return 0, err
}
if _, err := s.store.ProbeResults().Create(ctx, sqlite.ProbeResult{BatchItemID: item.ID, ProbeType: "account_smoke_rerun", Status: status, SummaryJSON: string(payload)}); err != nil {
return 0, err
}
if !probe.OK || !smokeModelSeen {
failures++
}
}
return failures, nil
}
func (s *ReconcileService) rerunAccessClosure(ctx context.Context, batchID int64, accessClosures []sqlite.AccessClosureRecord, probeAPIKey, expectedModel string) (string, bool, error) {
if len(accessClosures) == 0 {
return "not_configured", false, nil
}
latest := accessClosures[len(accessClosures)-1]
status := firstNonEmpty(latest.Status, deriveHealthyAccessStatus(latest.ClosureType))
if strings.TrimSpace(probeAPIKey) == "" {
return status, false, nil
}
result, err := s.host.CheckGatewayAccess(ctx, sub2api.GatewayAccessCheckRequest{APIKey: probeAPIKey, ExpectedModel: expectedModel})
if err != nil {
return "", false, fmt.Errorf("re-check gateway access: %w", err)
}
if result.OK && result.HasExpectedModel {
status = deriveHealthyAccessStatus(latest.ClosureType)
} else {
status = AccessStatusBroken
}
payload, err := json.Marshal(map[string]any{
"status_code": result.StatusCode,
"ok": result.OK,
"has_expected_model": result.HasExpectedModel,
"models": result.Models,
"reconcile_rerun": true,
})
if err != nil {
return "", false, fmt.Errorf("marshal access rerun summary: %w", err)
}
if _, err := s.store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchID, ClosureType: latest.ClosureType, Status: status, DetailsJSON: string(payload)}); err != nil {
return "", false, err
}
return status, true, nil
}
func deriveHealthyAccessStatus(closureType string) string {
switch strings.TrimSpace(closureType) {
case AccessModeSubscription:
return AccessStatusSubscriptionReady
case AccessModeSelfService:
return AccessStatusSelfServiceReady
default:
return "unknown"
}
}
func accountIDFromProbeSummary(summaryJSON string) (string, error) {
if strings.TrimSpace(summaryJSON) == "" {
return "", nil
}
var payload map[string]any
if err := json.Unmarshal([]byte(summaryJSON), &payload); err != nil {
return "", err
}
accountID, _ := payload["account_id"].(string)
return strings.TrimSpace(accountID), nil
}
func diffManagedResources(stored []sqlite.ManagedResource, snapshot sub2api.ManagedResourceSnapshot) (int, int) {
live := map[string]map[string]struct{}{
"group": make(map[string]struct{}),
"channel": make(map[string]struct{}),
"plan": make(map[string]struct{}),
"account": make(map[string]struct{}),
}
for _, resource := range snapshot.Groups {
live["group"][strings.TrimSpace(resource.ID)] = struct{}{}
}
for _, resource := range snapshot.Channels {
live["channel"][strings.TrimSpace(resource.ID)] = struct{}{}
}
for _, resource := range snapshot.Plans {
live["plan"][strings.TrimSpace(resource.ID)] = struct{}{}
}
for _, resource := range snapshot.Accounts {
live["account"][strings.TrimSpace(resource.ID)] = struct{}{}
}
storedByType := map[string]map[string]struct{}{
"group": make(map[string]struct{}),
"channel": make(map[string]struct{}),
"plan": make(map[string]struct{}),
"account": make(map[string]struct{}),
}
for _, resource := range stored {
storedByType[strings.TrimSpace(resource.ResourceType)][strings.TrimSpace(resource.HostResourceID)] = struct{}{}
}
missing := 0
extra := 0
for resourceType, storedIDs := range storedByType {
for id := range storedIDs {
if _, ok := live[resourceType][id]; !ok {
missing++
}
}
for id := range live[resourceType] {
if _, ok := storedIDs[id]; !ok {
extra++
}
}
}
return missing, extra
}

View File

@@ -0,0 +1,235 @@
package provision
import (
"context"
"testing"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
func TestBatchDetailServiceGetReturnsPersistedArtifacts(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
batchID := seedRuntimeImportForReconcile(t, store, host)
providerRow, err := store.Providers().ListByProviderID(context.Background(), sampleProviderManifest().ProviderID)
if err != nil {
t.Fatalf("Providers().ListByProviderID() error = %v", err)
}
if len(providerRow) != 1 {
t.Fatalf("providers = %d, want 1", len(providerRow))
}
if _, err := store.ReconcileRuns().Create(context.Background(), sqlite.ReconcileRun{ProviderID: providerRow[0].ID, Status: "active", SummaryJSON: `{"missing_count":0}`}); err != nil {
t.Fatalf("ReconcileRuns().Create() error = %v", err)
}
result, err := NewBatchDetailService(store).Get(context.Background(), batchID)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if result.Batch.ID != batchID {
t.Fatalf("Batch.ID = %d, want %d", result.Batch.ID, batchID)
}
if len(result.Items) != 2 {
t.Fatalf("len(Items) = %d, want 2", len(result.Items))
}
if len(result.ManagedResources) != 4 {
t.Fatalf("len(ManagedResources) = %d, want 4", len(result.ManagedResources))
}
if len(result.AccessClosures) != 1 {
t.Fatalf("len(AccessClosures) = %d, want 1", len(result.AccessClosures))
}
if len(result.ReconcileRuns) != 1 {
t.Fatalf("len(ReconcileRuns) = %d, want 1", len(result.ReconcileRuns))
}
}
func TestBatchDetailServiceGetValidatesStore(t *testing.T) {
_, err := (*BatchDetailService)(nil).Get(context.Background(), 1)
if err == nil || err.Error() != "store is required" {
t.Fatalf("nil service Get() error = %v, want store is required", err)
}
}
func TestAccountIDFromProbeSummary(t *testing.T) {
accountID, err := accountIDFromProbeSummary(`{"account_id":" account_1 "}`)
if err != nil {
t.Fatalf("accountIDFromProbeSummary() error = %v", err)
}
if accountID != "account_1" {
t.Fatalf("accountID = %q, want account_1", accountID)
}
if _, err := accountIDFromProbeSummary(`{`); err == nil {
t.Fatal("accountIDFromProbeSummary() error = nil, want JSON decode error")
}
blank, err := accountIDFromProbeSummary("")
if err != nil {
t.Fatalf("accountIDFromProbeSummary(blank) error = %v", err)
}
if blank != "" {
t.Fatalf("blank accountID = %q, want empty", blank)
}
}
func TestReconcileServiceRerunAccessClosureWithoutProbeKeyUsesLatestStatus(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
status, checked, err := NewReconcileService(store, &fakeHostAdapter{}).rerunAccessClosure(context.Background(), 1, []sqlite.AccessClosureRecord{{ClosureType: AccessModeSubscription, Status: AccessStatusSubscriptionReady}}, "", "deepseek-chat")
if err != nil {
t.Fatalf("rerunAccessClosure() error = %v", err)
}
if checked {
t.Fatal("checked = true, want false without probe key")
}
if status != AccessStatusSubscriptionReady {
t.Fatalf("status = %q, want %q", status, AccessStatusSubscriptionReady)
}
}
func TestReconcileServiceRerunAccessClosureMarksBrokenWhenGatewayCheckFails(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
hostSeed := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
batchID := seedRuntimeImportForReconcile(t, store, hostSeed)
host := &fakeHostAdapter{gatewayResult: sub2api.GatewayAccessResult{OK: false, StatusCode: 403, HasExpectedModel: false}}
status, checked, err := NewReconcileService(store, host).rerunAccessClosure(context.Background(), batchID, []sqlite.AccessClosureRecord{{ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady}}, "user-key", "deepseek-chat")
if err != nil {
t.Fatalf("rerunAccessClosure() error = %v", err)
}
if !checked {
t.Fatal("checked = false, want true")
}
if status != AccessStatusBroken {
t.Fatalf("status = %q, want %q", status, AccessStatusBroken)
}
if got := queryCount(t, store.SQLDB(), "access_closure_records"); got != 2 {
t.Fatalf("access_closure_records row count = %d, want 2 after rerun", got)
}
if host.gatewayProbe.ExpectedModel != "deepseek-chat" {
t.Fatalf("ExpectedModel = %q, want deepseek-chat", host.gatewayProbe.ExpectedModel)
}
}
func TestDiffManagedResourcesCountsMissingAndExtra(t *testing.T) {
missing, extra := diffManagedResources(
[]sqlite.ManagedResource{
{ResourceType: "group", HostResourceID: "group_1"},
{ResourceType: "account", HostResourceID: "account_1"},
},
sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1"}},
Accounts: []sub2api.NamedResource{{ID: "account_2"}},
},
)
if missing != 1 || extra != 1 {
t.Fatalf("diffManagedResources() = (%d, %d), want (1, 1)", missing, extra)
}
}
func TestDeriveProviderStatus(t *testing.T) {
tests := []struct {
name string
batchStatus string
reconcileStatus string
want string
}{
{name: "reconcile wins", batchStatus: BatchStatusSucceeded, reconcileStatus: "degraded", want: "degraded"},
{name: "succeeded batch", batchStatus: BatchStatusSucceeded, reconcileStatus: "not_run", want: ProviderStatusActive},
{name: "failed batch", batchStatus: BatchStatusFailed, want: ProviderStatusFailed},
{name: "running batch", batchStatus: "running", want: "running"},
{name: "unknown fallback", batchStatus: " pending ", want: "pending"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := deriveProviderStatus(tc.batchStatus, tc.reconcileStatus); got != tc.want {
t.Fatalf("deriveProviderStatus(%q, %q) = %q, want %q", tc.batchStatus, tc.reconcileStatus, got, tc.want)
}
})
}
}
func TestBuildPackAndProviderRecord(t *testing.T) {
packRow, err := buildPackRecord(sampleLoadedPack())
if err != nil {
t.Fatalf("buildPackRecord() error = %v", err)
}
if packRow.PackID != "openai-cn-pack" || packRow.TargetHost != "sub2api" {
t.Fatalf("packRow = %#v, want populated pack metadata", packRow)
}
providerRow, err := buildProviderRecord(7, sampleProviderManifest())
if err != nil {
t.Fatalf("buildProviderRecord() error = %v", err)
}
if providerRow.PackID != 7 || providerRow.ProviderID != sampleProviderManifest().ProviderID {
t.Fatalf("providerRow = %#v, want persisted provider metadata", providerRow)
}
if providerRow.DefaultModelsJSON == "" || providerRow.ManifestJSON == "" {
t.Fatalf("providerRow JSON fields = %#v, want serialized JSON", providerRow)
}
}
func TestFirstNonEmptyAndFingerprintKey(t *testing.T) {
if got := firstNonEmpty(" ", "value", "other"); got != "value" {
t.Fatalf("firstNonEmpty() = %q, want value", got)
}
if got := fingerprintKey([]string{" key-1 "}, 0); got == "key-1" || got == "sha256:" || len(got) < 20 {
t.Fatalf("fingerprintKey() = %q, want sha256 fingerprint", got)
}
if got := fingerprintKey(nil, 3); got != "key-4" {
t.Fatalf("fingerprintKey(nil, 3) = %q, want key-4", got)
}
}
func TestProviderStatusServiceGetResourcesRequiresProviderID(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
_, err := NewProviderStatusService(store).GetResources(context.Background(), ProviderQuery{})
if err == nil || err.Error() != "provider_id is required" {
t.Fatalf("GetResources() error = %v, want provider_id is required", err)
}
}
func TestResourceSlugFallsBackToProvider(t *testing.T) {
if got := resourceSlug(" !!! "); got != "provider" {
t.Fatalf("resourceSlug() = %q, want provider", got)
}
provider := sampleProviderManifest()
provider.ProviderID = " DeepSeek CN / Prod "
if got := SuggestAccountNamePrefix(provider); got != "deepseek-cn-prod-" {
t.Fatalf("SuggestAccountNamePrefix() = %q, want deepseek-cn-prod-", got)
}
resourceNames := SuggestResourceNames(provider)
if resourceNames.Group != "crm-deepseek-cn-prod-group" {
t.Fatalf("SuggestResourceNames() = %#v, want slugged resource names", resourceNames)
}
}

View File

@@ -0,0 +1,343 @@
package provision
import (
"context"
"errors"
"fmt"
"strings"
"sub2api-cn-relay-manager/internal/access"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
)
const (
ImportModeStrict = "strict"
ImportModePartial = "partial"
AccessModeSubscription = "subscription"
AccessModeSelfService = "self_service"
BatchStatusSucceeded = "succeeded"
BatchStatusPartial = "partially_succeeded"
BatchStatusFailed = "failed"
ProviderStatusActive = "active"
ProviderStatusDegraded = "degraded"
ProviderStatusFailed = "failed"
AccessStatusSubscriptionReady = "subscription_ready"
AccessStatusSelfServiceReady = "self_service_ready"
AccessStatusBroken = "broken"
)
type AccessRequest struct {
Mode string
ProbeAPIKey string
Subscriptions []SubscriptionTarget
}
type SubscriptionTarget struct {
UserID string
DurationDays int
}
type ImportRequest struct {
Provider pack.ProviderManifest
Mode string
Access AccessRequest
Keys []string
}
type ImportReport struct {
BatchStatus string
ProviderStatus string
AccessStatus string
AcceptedKeys []string
Group sub2api.GroupRef
Channel sub2api.ChannelRef
Plan *sub2api.PlanRef
Accounts []AccountImportResult
Gateway sub2api.GatewayAccessResult
}
type AccountImportResult struct {
Ref sub2api.AccountRef
Probe sub2api.ProbeResult
Models []sub2api.AccountModel
SmokeModelSeen bool
}
type hostAdapter interface {
sub2api.HostAdapter
CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error)
}
type ImportService struct {
host hostAdapter
}
func NewImportService(host hostAdapter) *ImportService {
return &ImportService{host: host}
}
func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report ImportReport, err error) {
normalizedKeys, err := normalizeKeys(req.Keys)
if err != nil {
return ImportReport{}, err
}
if err := validateMode(req.Mode); err != nil {
return ImportReport{}, err
}
if err := access.Validate(access.ClosureRequest{
Mode: req.Access.Mode,
ProbeAPIKey: req.Access.ProbeAPIKey,
Subscriptions: toAccessSubscriptionTargets(req.Access.Subscriptions),
}); err != nil {
return ImportReport{}, err
}
report = ImportReport{AcceptedKeys: normalizedKeys}
rollback := newManagedResourceRollback(s.host)
defer func() {
if err == nil || req.Mode != ImportModeStrict {
return
}
if rollbackErr := rollback.Run(ctx); rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("rollback managed resources: %w", rollbackErr))
}
}()
group, err := s.host.CreateGroup(ctx, sub2api.CreateGroupRequest{
Name: req.Provider.GroupTemplate.Name,
RateMultiplier: req.Provider.GroupTemplate.RateMultiplier,
})
if err != nil {
return report, fmt.Errorf("create group: %w", err)
}
report.Group = group
rollback.AddGroup(group.ID)
channel, err := s.host.CreateChannel(ctx, sub2api.CreateChannelRequest{
Name: req.Provider.ChannelTemplate.Name,
GroupIDs: []string{group.ID},
})
if err != nil {
return report, fmt.Errorf("create channel: %w", err)
}
report.Channel = channel
rollback.AddChannel(channel.ID)
if req.Access.Mode == AccessModeSubscription {
plan, err := s.host.CreatePlan(ctx, sub2api.CreatePlanRequest{
GroupID: group.ID,
Name: req.Provider.PlanTemplate.Name,
Price: req.Provider.PlanTemplate.Price,
ValidityDays: req.Provider.PlanTemplate.ValidityDays,
ValidityUnit: req.Provider.PlanTemplate.ValidityUnit,
})
if err != nil {
return report, fmt.Errorf("create plan: %w", err)
}
report.Plan = &plan
rollback.AddPlan(plan.ID)
}
accounts, err := s.host.BatchCreateAccounts(ctx, buildBatchAccountsRequest(req.Provider, group.ID, normalizedKeys))
if err != nil {
return report, fmt.Errorf("batch create accounts: %w", err)
}
rollback.AddAccounts(accounts)
for _, account := range accounts {
probe, err := s.host.TestAccount(ctx, account.ID)
if err != nil {
return failOrDegrade(report, req.Mode, fmt.Errorf("test account %s: %w", account.ID, err))
}
models, err := s.host.GetAccountModels(ctx, account.ID)
if err != nil {
return failOrDegrade(report, req.Mode, fmt.Errorf("get account models %s: %w", account.ID, err))
}
result := AccountImportResult{Ref: account, Probe: probe, Models: models, SmokeModelSeen: hasModel(models, req.Provider.SmokeTestModel)}
report.Accounts = append(report.Accounts, result)
}
failedAccounts := 0
for _, account := range report.Accounts {
if !account.Probe.OK || !account.SmokeModelSeen {
failedAccounts++
}
}
if failedAccounts > 0 && req.Mode == ImportModeStrict {
report.BatchStatus = BatchStatusFailed
report.ProviderStatus = ProviderStatusFailed
report.AccessStatus = AccessStatusBroken
return report, fmt.Errorf("strict import failed: %d account(s) did not pass smoke validation", failedAccounts)
}
closureService := access.NewService(s.host)
gateway, err := closureService.Close(ctx, access.ClosureRequest{
Mode: req.Access.Mode,
ProbeAPIKey: req.Access.ProbeAPIKey,
Subscriptions: toAccessSubscriptionTargets(req.Access.Subscriptions),
GroupID: group.ID,
ExpectedModel: req.Provider.SmokeTestModel,
})
if err != nil {
return failOrDegrade(report, req.Mode, err)
}
report.Gateway = gateway
report.BatchStatus = BatchStatusSucceeded
report.ProviderStatus = ProviderStatusActive
if failedAccounts > 0 || !gateway.OK || !gateway.HasExpectedModel {
report.BatchStatus = BatchStatusPartial
report.ProviderStatus = ProviderStatusDegraded
}
switch req.Access.Mode {
case AccessModeSubscription:
report.AccessStatus = AccessStatusSubscriptionReady
case AccessModeSelfService:
report.AccessStatus = AccessStatusSelfServiceReady
}
if !gateway.OK || !gateway.HasExpectedModel {
report.AccessStatus = AccessStatusBroken
}
return report, nil
}
func validateMode(mode string) error {
switch strings.TrimSpace(mode) {
case ImportModeStrict, ImportModePartial:
return nil
default:
return fmt.Errorf("unsupported import mode %q", mode)
}
}
func toAccessSubscriptionTargets(targets []SubscriptionTarget) []access.SubscriptionTarget {
result := make([]access.SubscriptionTarget, 0, len(targets))
for _, target := range targets {
result = append(result, access.SubscriptionTarget{UserID: target.UserID, DurationDays: target.DurationDays})
}
return result
}
func normalizeKeys(keys []string) ([]string, error) {
seen := map[string]struct{}{}
result := make([]string, 0, len(keys))
for _, key := range keys {
normalized := strings.TrimSpace(strings.TrimPrefix(key, "\ufeff"))
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
if len(result) == 0 {
return nil, fmt.Errorf("at least one api key is required")
}
return result, nil
}
func buildBatchAccountsRequest(provider pack.ProviderManifest, groupID string, keys []string) sub2api.BatchCreateAccountsRequest {
accounts := make([]sub2api.CreateAccountRequest, 0, len(keys))
for index, key := range keys {
accounts = append(accounts, sub2api.CreateAccountRequest{
Name: fmt.Sprintf("%s-%02d", provider.ProviderID, index+1),
Platform: provider.Platform,
Type: provider.AccountType,
GroupIDs: []string{groupID},
Credentials: map[string]any{
"base_url": provider.BaseURL,
"api_key": key,
},
})
}
return sub2api.BatchCreateAccountsRequest{Accounts: accounts}
}
func hasModel(models []sub2api.AccountModel, target string) bool {
for _, model := range models {
if strings.TrimSpace(model.ID) == strings.TrimSpace(target) {
return true
}
}
return false
}
type managedResourceRollback struct {
host hostAdapter
groupID string
channelID string
planID string
accountIDs []string
}
func newManagedResourceRollback(host hostAdapter) *managedResourceRollback {
return &managedResourceRollback{host: host}
}
func (r *managedResourceRollback) AddGroup(groupID string) {
r.groupID = strings.TrimSpace(groupID)
}
func (r *managedResourceRollback) AddChannel(channelID string) {
r.channelID = strings.TrimSpace(channelID)
}
func (r *managedResourceRollback) AddPlan(planID string) {
r.planID = strings.TrimSpace(planID)
}
func (r *managedResourceRollback) AddAccounts(accounts []sub2api.AccountRef) {
for _, account := range accounts {
accountID := strings.TrimSpace(account.ID)
if accountID == "" {
continue
}
r.accountIDs = append(r.accountIDs, accountID)
}
}
func (r *managedResourceRollback) Run(ctx context.Context) error {
if r == nil || r.host == nil {
return nil
}
var errs []error
for index := len(r.accountIDs) - 1; index >= 0; index-- {
if err := r.host.DeleteAccount(ctx, r.accountIDs[index]); err != nil {
errs = append(errs, fmt.Errorf("delete account %s: %w", r.accountIDs[index], err))
}
}
if r.planID != "" {
if err := r.host.DeletePlan(ctx, r.planID); err != nil {
errs = append(errs, fmt.Errorf("delete plan %s: %w", r.planID, err))
}
}
if r.channelID != "" {
if err := r.host.DeleteChannel(ctx, r.channelID); err != nil {
errs = append(errs, fmt.Errorf("delete channel %s: %w", r.channelID, err))
}
}
if r.groupID != "" {
if err := r.host.DeleteGroup(ctx, r.groupID); err != nil {
errs = append(errs, fmt.Errorf("delete group %s: %w", r.groupID, err))
}
}
return errors.Join(errs...)
}
func failOrDegrade(report ImportReport, mode string, err error) (ImportReport, error) {
if mode == ImportModeStrict {
report.BatchStatus = BatchStatusFailed
report.ProviderStatus = ProviderStatusFailed
report.AccessStatus = AccessStatusBroken
return report, err
}
report.BatchStatus = BatchStatusPartial
report.ProviderStatus = ProviderStatusDegraded
report.AccessStatus = AccessStatusBroken
return report, err
}

View File

@@ -0,0 +1,241 @@
package provision
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
)
func TestImportServiceImportSubscriptionFlow(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
svc := NewImportService(host)
report, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{
Mode: AccessModeSubscription,
ProbeAPIKey: "user-key",
Subscriptions: []SubscriptionTarget{{UserID: "user_1", DurationDays: 30}},
},
Keys: []string{" key-1 ", "key-2", "key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSubscriptionReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSubscriptionReady)
}
if !reflect.DeepEqual(report.AcceptedKeys, []string{"key-1", "key-2"}) {
t.Fatalf("AcceptedKeys = %#v, want deduped normalized keys", report.AcceptedKeys)
}
if len(host.assignedSubscriptions) != 1 {
t.Fatalf("assigned subscriptions = %d, want 1", len(host.assignedSubscriptions))
}
if host.gatewayProbe.ExpectedModel != "deepseek-chat" {
t.Fatalf("gateway probe model = %q, want %q", host.gatewayProbe.ExpectedModel, "deepseek-chat")
}
}
func TestImportServiceStrictModeFailsWhenAnyAccountProbeFails(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: false, Status: "failed", Message: "bad key"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
}
svc := NewImportService(host)
report, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModeStrict,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1", "key-2"},
})
if err == nil {
t.Fatal("Import() error = nil, want strict mode failure")
}
if report.BatchStatus != BatchStatusFailed {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusFailed)
}
if report.ProviderStatus != ProviderStatusFailed {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusFailed)
}
}
func TestImportServiceRejectsUnknownMode(t *testing.T) {
svc := NewImportService(&fakeHostAdapter{})
_, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: "unknown",
Access: AccessRequest{Mode: AccessModeSelfService},
Keys: []string{"key-1"},
})
if err == nil {
t.Fatal("Import() error = nil, want mode validation error")
}
}
func TestImportServiceStrictModeRollsBackCreatedResources(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: false, Status: "failed", Message: "bad key"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
}
svc := NewImportService(host)
_, err := svc.Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModeStrict,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1", "key-2"},
})
if err == nil {
t.Fatal("Import() error = nil, want strict mode failure")
}
want := []string{"account:account_2", "account:account_1", "channel:channel_1", "group:group_1"}
if !reflect.DeepEqual(host.deletedResources, want) {
t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, want)
}
}
func sampleProviderManifest() pack.ProviderManifest {
return pack.ProviderManifest{
ProviderID: "deepseek",
DisplayName: "DeepSeek OpenAI Compatible",
BaseURL: "https://api.deepseek.com",
Platform: "openai",
AccountType: "api",
DefaultModels: []string{"deepseek-chat", "deepseek-reasoner"},
SmokeTestModel: "deepseek-chat",
GroupTemplate: pack.GroupTemplate{Name: "DeepSeek 默认分组", RateMultiplier: 1},
ChannelTemplate: pack.ChannelTemplate{Name: "DeepSeek 默认渠道", ModelMapping: map[string]string{"deepseek-chat": "deepseek-chat"}},
PlanTemplate: pack.PlanTemplate{Name: "DeepSeek 默认套餐", Price: 19.9, ValidityDays: 30, ValidityUnit: "day"},
}
}
type fakeHostAdapter struct {
batchAccounts []sub2api.AccountRef
testResults map[string]sub2api.ProbeResult
models map[string][]sub2api.AccountModel
gatewayResult sub2api.GatewayAccessResult
batchCreateErr error
assignErr error
gatewayErr error
hostVersion string
assignedSubscriptions []sub2api.AssignSubscriptionRequest
gatewayProbe sub2api.GatewayAccessCheckRequest
deletedResources []string
managedSnapshot sub2api.ManagedResourceSnapshot
}
func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) {
if strings.TrimSpace(f.hostVersion) == "" {
return "0.1.126", nil
}
return f.hostVersion, nil
}
func (f *fakeHostAdapter) ProbeCapabilities(context.Context) (sub2api.HostCapabilities, error) {
return sub2api.HostCapabilities{}, nil
}
func (f *fakeHostAdapter) CreateGroup(context.Context, sub2api.CreateGroupRequest) (sub2api.GroupRef, error) {
return sub2api.GroupRef{ID: "group_1", Name: "g"}, nil
}
func (f *fakeHostAdapter) DeleteGroup(_ context.Context, groupID string) error {
f.deletedResources = append(f.deletedResources, "group:"+groupID)
return nil
}
func (f *fakeHostAdapter) CreateChannel(context.Context, sub2api.CreateChannelRequest) (sub2api.ChannelRef, error) {
return sub2api.ChannelRef{ID: "channel_1", Name: "c"}, nil
}
func (f *fakeHostAdapter) DeleteChannel(_ context.Context, channelID string) error {
f.deletedResources = append(f.deletedResources, "channel:"+channelID)
return nil
}
func (f *fakeHostAdapter) CreatePlan(context.Context, sub2api.CreatePlanRequest) (sub2api.PlanRef, error) {
return sub2api.PlanRef{ID: "plan_1", Name: "p"}, nil
}
func (f *fakeHostAdapter) DeletePlan(_ context.Context, planID string) error {
f.deletedResources = append(f.deletedResources, "plan:"+planID)
return nil
}
func (f *fakeHostAdapter) CreateAccount(context.Context, sub2api.CreateAccountRequest) (sub2api.AccountRef, error) {
return sub2api.AccountRef{}, errors.New("unused")
}
func (f *fakeHostAdapter) BatchCreateAccounts(_ context.Context, _ sub2api.BatchCreateAccountsRequest) ([]sub2api.AccountRef, error) {
if f.batchCreateErr != nil {
return nil, f.batchCreateErr
}
return f.batchAccounts, nil
}
func (f *fakeHostAdapter) DeleteAccount(_ context.Context, accountID string) error {
f.deletedResources = append(f.deletedResources, "account:"+accountID)
return nil
}
func (f *fakeHostAdapter) TestAccount(_ context.Context, accountID string) (sub2api.ProbeResult, error) {
result, ok := f.testResults[accountID]
if !ok {
return sub2api.ProbeResult{}, fmt.Errorf("missing test result for %s", accountID)
}
return result, nil
}
func (f *fakeHostAdapter) GetAccountModels(_ context.Context, accountID string) ([]sub2api.AccountModel, error) {
models, ok := f.models[accountID]
if !ok {
return nil, fmt.Errorf("missing models for %s", accountID)
}
return models, nil
}
func (f *fakeHostAdapter) AssignSubscription(_ context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) {
if f.assignErr != nil {
return sub2api.SubscriptionRef{}, f.assignErr
}
f.assignedSubscriptions = append(f.assignedSubscriptions, req)
return sub2api.SubscriptionRef{ID: "subscription_1"}, nil
}
func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) {
f.gatewayProbe = req
if f.gatewayErr != nil {
return sub2api.GatewayAccessResult{}, f.gatewayErr
}
return f.gatewayResult, nil
}
func (f *fakeHostAdapter) ListManagedResources(context.Context, sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) {
return f.managedSnapshot, nil
}

View File

@@ -0,0 +1,40 @@
package provision
import (
"fmt"
"regexp"
"strings"
"sub2api-cn-relay-manager/internal/pack"
)
var nonSlugPattern = regexp.MustCompile(`[^a-z0-9]+`)
type ResourceNames struct {
Group string
Channel string
Plan string
}
func SuggestAccountNamePrefix(provider pack.ProviderManifest) string {
return fmt.Sprintf("%s-", resourceSlug(provider.ProviderID))
}
func SuggestResourceNames(provider pack.ProviderManifest) ResourceNames {
slug := resourceSlug(provider.ProviderID)
return ResourceNames{
Group: fmt.Sprintf("crm-%s-group", slug),
Channel: fmt.Sprintf("crm-%s-channel", slug),
Plan: fmt.Sprintf("crm-%s-plan", slug),
}
}
func resourceSlug(raw string) string {
slug := strings.ToLower(strings.TrimSpace(raw))
slug = nonSlugPattern.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
if slug == "" {
return "provider"
}
return slug
}

View File

@@ -0,0 +1,178 @@
package provision
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"sub2api-cn-relay-manager/internal/host/sub2api"
packdef "sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type PackInstallRequest struct {
Pack packdef.LoadedPack
}
type PackInstallResult struct {
Pack sqlite.Pack
Providers []sqlite.Provider
HostVersion string
AlreadyInstalled bool
}
type PackInstallService struct {
store *sqlite.DB
host sub2api.HostAdapter
}
func NewPackInstallService(store *sqlite.DB, host sub2api.HostAdapter) *PackInstallService {
return &PackInstallService{store: store, host: host}
}
func (s *PackInstallService) Install(ctx context.Context, req PackInstallRequest) (PackInstallResult, error) {
if s == nil || s.store == nil {
return PackInstallResult{}, fmt.Errorf("store is required")
}
if s.host == nil {
return PackInstallResult{}, fmt.Errorf("host adapter is required")
}
if strings.TrimSpace(req.Pack.Manifest.PackID) == "" {
return PackInstallResult{}, fmt.Errorf("pack manifest is required")
}
hostVersion, err := s.host.GetHostVersion(ctx)
if err != nil {
return PackInstallResult{}, fmt.Errorf("get host version: %w", err)
}
if err := packdef.CheckHostCompatibility(req.Pack.Manifest, hostVersion); err != nil {
return PackInstallResult{}, err
}
result := PackInstallResult{HostVersion: hostVersion}
if err := s.store.WithTx(ctx, func(queries *sqlite.Queries) error {
existing, err := queries.Packs.GetByPackID(ctx, req.Pack.Manifest.PackID)
if err == nil {
if err := validateExistingPack(existing, req.Pack); err != nil {
return err
}
result.AlreadyInstalled = true
} else if !errors.Is(err, sql.ErrNoRows) {
return err
}
packRow, err := buildPackRecord(req.Pack)
if err != nil {
return err
}
if _, err := queries.Packs.Upsert(ctx, packRow); err != nil {
return err
}
persistedPack, err := queries.Packs.GetByPackID(ctx, req.Pack.Manifest.PackID)
if err != nil {
return err
}
result.Pack = persistedPack
providers := make([]sqlite.Provider, 0, len(req.Pack.Providers))
for _, providerManifest := range req.Pack.Providers {
providerRow, err := buildProviderRecord(persistedPack.ID, providerManifest)
if err != nil {
return err
}
if _, err := queries.Providers.Upsert(ctx, providerRow); err != nil {
return err
}
persistedProvider, err := queries.Providers.GetByPackIDAndProviderID(ctx, persistedPack.ID, providerManifest.ProviderID)
if err != nil {
return err
}
providers = append(providers, persistedProvider)
}
result.Providers = providers
return nil
}); err != nil {
return PackInstallResult{}, err
}
return result, nil
}
func validateExistingPack(existing sqlite.Pack, loaded packdef.LoadedPack) error {
if strings.TrimSpace(existing.Version) != strings.TrimSpace(loaded.Manifest.Version) {
return fmt.Errorf("pack %q already installed with version %q; upgrade lifecycle not implemented", existing.PackID, existing.Version)
}
if strings.TrimSpace(existing.Checksum) != strings.TrimSpace(loaded.Checksum) {
return fmt.Errorf("pack %q version %q checksum drift detected", existing.PackID, existing.Version)
}
return nil
}
func buildPackRecord(loaded packdef.LoadedPack) (sqlite.Pack, error) {
manifestJSON, err := json.Marshal(loaded.Manifest)
if err != nil {
return sqlite.Pack{}, fmt.Errorf("marshal pack manifest: %w", err)
}
return sqlite.Pack{
PackID: loaded.Manifest.PackID,
Version: loaded.Manifest.Version,
Checksum: loaded.Checksum,
Vendor: loaded.Manifest.Vendor,
TargetHost: loaded.Manifest.TargetHost,
MinHostVersion: loaded.Manifest.MinHostVersion,
MaxHostVersion: loaded.Manifest.MaxHostVersion,
ManifestJSON: string(manifestJSON),
}, nil
}
func buildProviderRecord(packID int64, provider packdef.ProviderManifest) (sqlite.Provider, error) {
defaultModelsJSON, err := marshalJSONString(provider.DefaultModels)
if err != nil {
return sqlite.Provider{}, fmt.Errorf("marshal provider default models: %w", err)
}
groupTemplateJSON, err := marshalJSONString(provider.GroupTemplate)
if err != nil {
return sqlite.Provider{}, fmt.Errorf("marshal group template: %w", err)
}
channelTemplateJSON, err := marshalJSONString(provider.ChannelTemplate)
if err != nil {
return sqlite.Provider{}, fmt.Errorf("marshal channel template: %w", err)
}
planTemplateJSON, err := marshalJSONString(provider.PlanTemplate)
if err != nil {
return sqlite.Provider{}, fmt.Errorf("marshal plan template: %w", err)
}
importOptionsJSON, err := marshalJSONString(provider.Import)
if err != nil {
return sqlite.Provider{}, fmt.Errorf("marshal import options: %w", err)
}
manifestJSON, err := marshalJSONString(provider)
if err != nil {
return sqlite.Provider{}, fmt.Errorf("marshal provider manifest: %w", err)
}
return sqlite.Provider{
PackID: packID,
ProviderID: provider.ProviderID,
DisplayName: provider.DisplayName,
BaseURL: provider.BaseURL,
Platform: provider.Platform,
AccountType: provider.AccountType,
DefaultModelsJSON: defaultModelsJSON,
SmokeTestModel: provider.SmokeTestModel,
GroupTemplateJSON: groupTemplateJSON,
ChannelTemplateJSON: channelTemplateJSON,
PlanTemplateJSON: planTemplateJSON,
ImportOptionsJSON: importOptionsJSON,
ManifestJSON: manifestJSON,
}, nil
}
func marshalJSONString(value any) (string, error) {
body, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(body), nil
}

View File

@@ -0,0 +1,120 @@
package provision
import (
"context"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
func TestPackInstallServiceInstallPersistsPackAndProviders(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{}
loaded := sampleLoadedPack()
svc := NewPackInstallService(store, host)
result, err := svc.Install(context.Background(), PackInstallRequest{Pack: loaded})
if err != nil {
t.Fatalf("Install() error = %v", err)
}
if result.HostVersion != "0.1.126" {
t.Fatalf("HostVersion = %q, want 0.1.126", result.HostVersion)
}
if result.AlreadyInstalled {
t.Fatal("AlreadyInstalled = true, want false on first install")
}
if result.Pack.PackID != loaded.Manifest.PackID {
t.Fatalf("Pack.PackID = %q, want %q", result.Pack.PackID, loaded.Manifest.PackID)
}
if len(result.Providers) != 1 || result.Providers[0].ProviderID != loaded.Providers[0].ProviderID {
t.Fatalf("Providers = %#v, want one persisted provider", result.Providers)
}
if got := queryCount(t, store.SQLDB(), "packs"); got != 1 {
t.Fatalf("packs row count = %d, want 1", got)
}
if got := queryCount(t, store.SQLDB(), "providers"); got != 1 {
t.Fatalf("providers row count = %d, want 1", got)
}
repeat, err := svc.Install(context.Background(), PackInstallRequest{Pack: loaded})
if err != nil {
t.Fatalf("second Install() error = %v", err)
}
if !repeat.AlreadyInstalled {
t.Fatal("AlreadyInstalled = false, want true on re-install")
}
if got := queryCount(t, store.SQLDB(), "packs"); got != 1 {
t.Fatalf("packs row count after re-install = %d, want 1", got)
}
if got := queryCount(t, store.SQLDB(), "providers"); got != 1 {
t.Fatalf("providers row count after re-install = %d, want 1", got)
}
}
func TestPackInstallServiceInstallRejectsVersionAndChecksumDrift(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
svc := NewPackInstallService(store, &fakeHostAdapter{})
loaded := sampleLoadedPack()
if _, err := svc.Install(context.Background(), PackInstallRequest{Pack: loaded}); err != nil {
t.Fatalf("initial Install() error = %v", err)
}
versionDrift := sampleLoadedPack()
versionDrift.Manifest.Version = "2.0.0"
if _, err := svc.Install(context.Background(), PackInstallRequest{Pack: versionDrift}); err == nil || !strings.Contains(err.Error(), "upgrade lifecycle not implemented") {
t.Fatalf("Install() version drift error = %v, want upgrade lifecycle error", err)
}
checksumDrift := sampleLoadedPack()
checksumDrift.Checksum = "checksum-2"
if _, err := svc.Install(context.Background(), PackInstallRequest{Pack: checksumDrift}); err == nil || !strings.Contains(err.Error(), "checksum drift detected") {
t.Fatalf("Install() checksum drift error = %v, want checksum drift error", err)
}
}
func TestPackInstallServiceInstallValidatesDependencies(t *testing.T) {
loaded := sampleLoadedPack()
storeWithoutHost := openProvisionTestStore(t)
defer closeProvisionTestStore(t, storeWithoutHost)
storeWithoutPack := openProvisionTestStore(t)
defer closeProvisionTestStore(t, storeWithoutPack)
if _, err := (*PackInstallService)(nil).Install(context.Background(), PackInstallRequest{Pack: loaded}); err == nil || err.Error() != "store is required" {
t.Fatalf("nil service Install() error = %v, want store is required", err)
}
if _, err := (&PackInstallService{store: storeWithoutHost}).Install(context.Background(), PackInstallRequest{Pack: loaded}); err == nil || err.Error() != "host adapter is required" {
t.Fatalf("missing host Install() error = %v, want host adapter is required", err)
}
if _, err := NewPackInstallService(storeWithoutPack, &fakeHostAdapter{}).Install(context.Background(), PackInstallRequest{}); err == nil || err.Error() != "pack manifest is required" {
t.Fatalf("missing pack Install() error = %v, want pack manifest is required", err)
}
}
func sampleLoadedPack() pack.LoadedPack {
provider := sampleProviderManifest()
return pack.LoadedPack{
Manifest: pack.Manifest{
PackID: "openai-cn-pack",
Version: "1.0.0",
Vendor: "nous",
TargetHost: "sub2api",
MinHostVersion: "0.1.126",
MaxHostVersion: "0.2.x",
},
Providers: []pack.ProviderManifest{provider},
Checksum: "checksum-1",
}
}
func TestValidateExistingPack(t *testing.T) {
existing := sqlite.Pack{PackID: "openai-cn-pack", Version: "1.0.0", Checksum: "checksum-1"}
if err := validateExistingPack(existing, sampleLoadedPack()); err != nil {
t.Fatalf("validateExistingPack() error = %v, want nil", err)
}
}

View File

@@ -0,0 +1,90 @@
package provision
import (
"context"
"fmt"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
)
const (
PreviewActionCreate = "create"
PreviewActionReuse = "reuse"
PreviewActionConflict = "conflict"
)
type previewHost interface {
ListManagedResources(ctx context.Context, req sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error)
}
type PreviewRequest struct {
Provider pack.ProviderManifest
Mode string
Keys []string
}
type PreviewDecision struct {
Action string
Suggested string
ExistingID string
Reason string
}
type PreviewReport struct {
AcceptedKeys []string
Names ResourceNames
Decisions map[string]PreviewDecision
}
type PreviewService struct {
host previewHost
}
func NewPreviewService(host previewHost) *PreviewService {
return &PreviewService{host: host}
}
func (s *PreviewService) PreviewImport(ctx context.Context, req PreviewRequest) (PreviewReport, error) {
acceptedKeys, err := normalizeKeys(req.Keys)
if err != nil {
return PreviewReport{}, err
}
if err := validateMode(req.Mode); err != nil {
return PreviewReport{}, err
}
if s.host == nil {
return PreviewReport{}, fmt.Errorf("preview host is required")
}
names := SuggestResourceNames(req.Provider)
snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{
GroupName: names.Group,
ChannelName: names.Channel,
PlanName: names.Plan,
})
if err != nil {
return PreviewReport{}, fmt.Errorf("list managed resources: %w", err)
}
return PreviewReport{
AcceptedKeys: acceptedKeys,
Names: names,
Decisions: map[string]PreviewDecision{
"group": decideResource(names.Group, snapshot.Groups),
"channel": decideResource(names.Channel, snapshot.Channels),
"plan": decideResource(names.Plan, snapshot.Plans),
},
}, nil
}
func decideResource(suggested string, existing []sub2api.NamedResource) PreviewDecision {
switch len(existing) {
case 0:
return PreviewDecision{Action: PreviewActionCreate, Suggested: suggested}
case 1:
return PreviewDecision{Action: PreviewActionReuse, Suggested: suggested, ExistingID: existing[0].ID, Reason: "matching managed resource already exists"}
default:
return PreviewDecision{Action: PreviewActionConflict, Suggested: suggested, Reason: "multiple managed resources share the suggested name"}
}
}

View File

@@ -0,0 +1,87 @@
package provision
import (
"context"
"reflect"
"testing"
"sub2api-cn-relay-manager/internal/host/sub2api"
)
func TestSuggestResourceNames(t *testing.T) {
provider := sampleProviderManifest()
names := SuggestResourceNames(provider)
want := ResourceNames{
Group: "crm-deepseek-group",
Channel: "crm-deepseek-channel",
Plan: "crm-deepseek-plan",
}
if !reflect.DeepEqual(names, want) {
t.Fatalf("SuggestResourceNames() = %#v, want %#v", names, want)
}
}
func TestPreviewServiceReportsCreateActionsWhenHostHasNoResources(t *testing.T) {
host := &fakePreviewHost{}
svc := NewPreviewService(host)
report, err := svc.PreviewImport(context.Background(), PreviewRequest{
Provider: sampleProviderManifest(),
Mode: ImportModeStrict,
Keys: []string{" key-1 ", "key-2", "key-1"},
})
if err != nil {
t.Fatalf("PreviewImport() error = %v", err)
}
if !reflect.DeepEqual(report.AcceptedKeys, []string{"key-1", "key-2"}) {
t.Fatalf("AcceptedKeys = %#v, want normalized deduped keys", report.AcceptedKeys)
}
if got := report.Decisions["group"]; got.Action != PreviewActionCreate {
t.Fatalf("group action = %q, want %q", got.Action, PreviewActionCreate)
}
if got := report.Decisions["channel"]; got.Action != PreviewActionCreate {
t.Fatalf("channel action = %q, want %q", got.Action, PreviewActionCreate)
}
if got := report.Decisions["plan"]; got.Action != PreviewActionCreate {
t.Fatalf("plan action = %q, want %q", got.Action, PreviewActionCreate)
}
}
func TestPreviewServiceReportsReuseAndConflict(t *testing.T) {
host := &fakePreviewHost{snapshot: sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "crm-deepseek-group"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "crm-deepseek-channel"}, {ID: "channel_2", Name: "crm-deepseek-channel"}},
Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "crm-deepseek-plan"}},
}}
svc := NewPreviewService(host)
report, err := svc.PreviewImport(context.Background(), PreviewRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("PreviewImport() error = %v", err)
}
if got := report.Decisions["group"]; got.Action != PreviewActionReuse || got.ExistingID != "group_1" {
t.Fatalf("group decision = %#v, want reuse group_1", got)
}
if got := report.Decisions["plan"]; got.Action != PreviewActionReuse || got.ExistingID != "plan_1" {
t.Fatalf("plan decision = %#v, want reuse plan_1", got)
}
if got := report.Decisions["channel"]; got.Action != PreviewActionConflict {
t.Fatalf("channel decision = %#v, want conflict", got)
}
}
type fakePreviewHost struct {
snapshot sub2api.ManagedResourceSnapshot
}
func (f *fakePreviewHost) ListManagedResources(context.Context, sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) {
return f.snapshot, nil
}

View File

@@ -0,0 +1,150 @@
package provision
import (
"context"
"encoding/json"
"fmt"
"strings"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type ProviderQuery struct {
ProviderID string
PackID string
}
type ProviderSnapshot struct {
Host sqlite.Host
Pack sqlite.Pack
Provider sqlite.Provider
Batch sqlite.ImportBatch
ManagedResources []sqlite.ManagedResource
AccessClosures []sqlite.AccessClosureRecord
ReconcileRuns []sqlite.ReconcileRun
ProviderStatus string
LatestAccessStatus string
LatestReconcileStatus string
LatestReconcileSummary map[string]any
}
type ProviderStatusService struct {
store *sqlite.DB
}
func NewProviderStatusService(store *sqlite.DB) *ProviderStatusService {
return &ProviderStatusService{store: store}
}
func (s *ProviderStatusService) GetStatus(ctx context.Context, query ProviderQuery) (ProviderSnapshot, error) {
return s.snapshot(ctx, query)
}
func (s *ProviderStatusService) GetResources(ctx context.Context, query ProviderQuery) (ProviderSnapshot, error) {
return s.snapshot(ctx, query)
}
func (s *ProviderStatusService) snapshot(ctx context.Context, query ProviderQuery) (ProviderSnapshot, error) {
if s == nil || s.store == nil {
return ProviderSnapshot{}, fmt.Errorf("store is required")
}
provider, err := s.resolveProvider(ctx, query)
if err != nil {
return ProviderSnapshot{}, err
}
packRow, err := s.store.Packs().GetByID(ctx, provider.PackID)
if err != nil {
return ProviderSnapshot{}, err
}
batchRow, err := s.store.ImportBatches().GetLatestByProviderID(ctx, provider.ID)
if err != nil {
return ProviderSnapshot{}, err
}
hostRow, err := s.store.Hosts().GetByID(ctx, batchRow.HostID)
if err != nil {
return ProviderSnapshot{}, err
}
managedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return ProviderSnapshot{}, err
}
accessClosures, err := s.store.AccessClosures().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return ProviderSnapshot{}, err
}
reconcileRuns, err := s.store.ReconcileRuns().GetByProviderID(ctx, provider.ID)
if err != nil {
return ProviderSnapshot{}, err
}
latestAccessStatus := batchRow.AccessStatus
if len(accessClosures) > 0 {
latestAccessStatus = firstNonEmpty(accessClosures[len(accessClosures)-1].Status, latestAccessStatus)
}
latestReconcileStatus := "not_run"
latestReconcileSummary := map[string]any{}
if len(reconcileRuns) > 0 {
latestReconcileStatus = firstNonEmpty(reconcileRuns[0].Status, latestReconcileStatus)
if strings.TrimSpace(reconcileRuns[0].SummaryJSON) != "" {
if err := json.Unmarshal([]byte(reconcileRuns[0].SummaryJSON), &latestReconcileSummary); err != nil {
return ProviderSnapshot{}, fmt.Errorf("decode reconcile summary: %w", err)
}
}
}
providerStatus := deriveProviderStatus(batchRow.BatchStatus, latestReconcileStatus)
return ProviderSnapshot{
Host: hostRow,
Pack: packRow,
Provider: provider,
Batch: batchRow,
ManagedResources: managedResources,
AccessClosures: accessClosures,
ReconcileRuns: reconcileRuns,
ProviderStatus: providerStatus,
LatestAccessStatus: latestAccessStatus,
LatestReconcileStatus: latestReconcileStatus,
LatestReconcileSummary: latestReconcileSummary,
}, nil
}
func (s *ProviderStatusService) resolveProvider(ctx context.Context, query ProviderQuery) (sqlite.Provider, error) {
providerID := strings.TrimSpace(query.ProviderID)
packID := strings.TrimSpace(query.PackID)
if providerID == "" {
return sqlite.Provider{}, fmt.Errorf("provider_id is required")
}
if packID != "" {
packRow, err := s.store.Packs().GetByPackID(ctx, packID)
if err != nil {
return sqlite.Provider{}, err
}
return s.store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, providerID)
}
providers, err := s.store.Providers().ListByProviderID(ctx, providerID)
if err != nil {
return sqlite.Provider{}, err
}
if len(providers) == 0 {
return sqlite.Provider{}, fmt.Errorf("provider %q not found", providerID)
}
if len(providers) > 1 {
return sqlite.Provider{}, fmt.Errorf("provider %q exists in multiple packs; pack_id is required", providerID)
}
return providers[0], nil
}
func deriveProviderStatus(batchStatus, reconcileStatus string) string {
reconcileStatus = strings.TrimSpace(reconcileStatus)
if reconcileStatus != "" && reconcileStatus != "not_run" {
return reconcileStatus
}
switch strings.TrimSpace(batchStatus) {
case BatchStatusSucceeded:
return ProviderStatusActive
case BatchStatusFailed:
return ProviderStatusFailed
case "running":
return "running"
default:
return firstNonEmpty(batchStatus, "unknown")
}
}

View File

@@ -0,0 +1,98 @@
package provision
import (
"context"
"testing"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
func TestProviderStatusServiceReturnsLatestSnapshot(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(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", CapabilityProbeJSON: `{"supports_batch_accounts":true}`})
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", 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)
}
batchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostID, PackID: packID, ProviderID: providerID, Mode: ImportModeStrict, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady})
if err != nil {
t.Fatalf("ImportBatches().Create() error = %v", err)
}
if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "group", HostResourceID: "group-1", ResourceName: "deepseek-group"}); err != nil {
t.Fatalf("ManagedResources().Create(group) error = %v", err)
}
if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "account", HostResourceID: "account-1", ResourceName: "deepseek-account-1"}); err != nil {
t.Fatalf("ManagedResources().Create(account) error = %v", err)
}
if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batchID, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: `{"ok":true}`}); err != nil {
t.Fatalf("AccessClosures().Create() error = %v", err)
}
if _, err := store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{ProviderID: providerID, Status: "drifted", SummaryJSON: `{"missing_count":1}`}); err != nil {
t.Fatalf("ReconcileRuns().Create() error = %v", err)
}
snapshot, err := NewProviderStatusService(store).GetStatus(ctx, ProviderQuery{ProviderID: "deepseek"})
if err != nil {
t.Fatalf("GetStatus() error = %v", err)
}
if snapshot.Host.HostID != "host-1" {
t.Fatalf("Host.HostID = %q, want host-1", snapshot.Host.HostID)
}
if snapshot.Pack.PackID != "openai-cn-pack" {
t.Fatalf("Pack.PackID = %q, want openai-cn-pack", snapshot.Pack.PackID)
}
if snapshot.Provider.ProviderID != "deepseek" {
t.Fatalf("Provider.ProviderID = %q, want deepseek", snapshot.Provider.ProviderID)
}
if snapshot.ProviderStatus != "drifted" {
t.Fatalf("ProviderStatus = %q, want drifted", snapshot.ProviderStatus)
}
if snapshot.LatestAccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("LatestAccessStatus = %q, want %q", snapshot.LatestAccessStatus, AccessStatusSelfServiceReady)
}
if snapshot.LatestReconcileStatus != "drifted" {
t.Fatalf("LatestReconcileStatus = %q, want drifted", snapshot.LatestReconcileStatus)
}
if got := len(snapshot.ManagedResources); got != 2 {
t.Fatalf("len(ManagedResources) = %d, want 2", got)
}
if got, ok := snapshot.LatestReconcileSummary["missing_count"].(float64); !ok || got != 1 {
t.Fatalf("LatestReconcileSummary[missing_count] = %#v, want 1", snapshot.LatestReconcileSummary["missing_count"])
}
}
func TestProviderStatusServiceRequiresPackIDWhenProviderIDIsAmbiguous(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
ctx := context.Background()
pack1, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "pack-a", Version: "1.0.0", Checksum: "checksum-a"})
if err != nil {
t.Fatalf("Packs().Create(pack-a) error = %v", err)
}
pack2, err := store.Packs().Create(ctx, sqlite.Pack{PackID: "pack-b", Version: "1.0.0", Checksum: "checksum-b"})
if err != nil {
t.Fatalf("Packs().Create(pack-b) error = %v", err)
}
if _, err := store.Providers().Create(ctx, sqlite.Provider{PackID: pack1, ProviderID: "deepseek", DisplayName: "DeepSeek A", BaseURL: "https://a.example.com", Platform: "openai"}); err != nil {
t.Fatalf("Providers().Create(pack-a) error = %v", err)
}
if _, err := store.Providers().Create(ctx, sqlite.Provider{PackID: pack2, ProviderID: "deepseek", DisplayName: "DeepSeek B", BaseURL: "https://b.example.com", Platform: "openai"}); err != nil {
t.Fatalf("Providers().Create(pack-b) error = %v", err)
}
_, err = NewProviderStatusService(store).GetStatus(ctx, ProviderQuery{ProviderID: "deepseek"})
if err == nil || err.Error() != `provider "deepseek" exists in multiple packs; pack_id is required` {
t.Fatalf("GetStatus() error = %v, want ambiguous provider error", err)
}
}

View File

@@ -0,0 +1,187 @@
package provision
import (
"context"
"testing"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
func TestReconcileServiceReturnsActiveAfterProbeRerun(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
batchID := seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "g"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "c"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
}
result, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{
HostBaseURL: "https://sub2api.example.com",
AccessProbeAPIKey: "user-key",
Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}},
Provider: sampleProviderManifest(),
})
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if result.BatchID != batchID {
t.Fatalf("BatchID = %d, want %d", result.BatchID, batchID)
}
if result.Status != "active" {
t.Fatalf("Status = %q, want active", result.Status)
}
if result.ProbeFailureCount != 0 {
t.Fatalf("ProbeFailureCount = %d, want 0", result.ProbeFailureCount)
}
if result.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", result.AccessStatus, AccessStatusSelfServiceReady)
}
if got := queryCount(t, store.SQLDB(), "probe_results"); got != 4 {
t.Fatalf("probe_results row count = %d, want 4 after rerun", got)
}
if got := queryCount(t, store.SQLDB(), "access_closure_records"); got != 2 {
t.Fatalf("access_closure_records row count = %d, want 2 after rerun", got)
}
}
func TestReconcileServiceReturnsDegradedWhenProbeRerunFails(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: false, Status: "failed", Message: "bad key"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "g"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "c"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
}
result, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{
HostBaseURL: "https://sub2api.example.com",
AccessProbeAPIKey: "user-key",
Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}},
Provider: sampleProviderManifest(),
})
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if result.Status != "degraded" {
t.Fatalf("Status = %q, want degraded", result.Status)
}
if result.ProbeFailureCount != 1 {
t.Fatalf("ProbeFailureCount = %d, want 1", result.ProbeFailureCount)
}
}
func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
seedRuntimeImportForReconcile(t, store, host)
host.managedSnapshot = sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "g"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "c"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}},
}
result, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{
HostBaseURL: "https://sub2api.example.com",
AccessProbeAPIKey: "user-key",
Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}},
Provider: sampleProviderManifest(),
})
if err != nil {
t.Fatalf("Reconcile() error = %v", err)
}
if result.Status != "drifted" {
t.Fatalf("Status = %q, want drifted", result.Status)
}
if result.MissingCount != 1 {
t.Fatalf("MissingCount = %d, want 1", result.MissingCount)
}
}
func TestDeriveHealthyAccessStatus(t *testing.T) {
tests := []struct {
name string
closureType string
want string
}{
{name: "subscription", closureType: AccessModeSubscription, want: AccessStatusSubscriptionReady},
{name: "self-service", closureType: AccessModeSelfService, want: AccessStatusSelfServiceReady},
{name: "unknown", closureType: "other", want: "unknown"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := deriveHealthyAccessStatus(tc.closureType); got != tc.want {
t.Fatalf("deriveHealthyAccessStatus(%q) = %q, want %q", tc.closureType, got, tc.want)
}
})
}
}
func seedRuntimeImportForReconcile(t *testing.T, store *sqlite.DB, host *fakeHostAdapter) int64 {
t.Helper()
result, err := NewRuntimeImportService(store, host).Import(context.Background(), RuntimeImportRequest{
HostID: "host-1",
HostBaseURL: "https://sub2api.example.com",
Pack: pack.LoadedPack{
Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"},
Checksum: "checksum-1",
},
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Keys: []string{"key-1", "key-2"},
Access: AccessRequest{
Mode: AccessModeSelfService,
ProbeAPIKey: "user-key",
},
})
if err != nil {
t.Fatalf("seed RuntimeImportService.Import() error = %v", err)
}
return result.BatchID
}

View File

@@ -0,0 +1,90 @@
package provision
import (
"context"
"errors"
"fmt"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
)
type rollbackHost interface {
ListManagedResources(ctx context.Context, req sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error)
DeleteAccount(ctx context.Context, accountID string) error
DeletePlan(ctx context.Context, planID string) error
DeleteChannel(ctx context.Context, channelID string) error
DeleteGroup(ctx context.Context, groupID string) error
}
type RollbackRequest struct {
Provider pack.ProviderManifest
}
type RollbackReport struct {
AccountsDeleted int
PlansDeleted int
ChannelsDeleted int
GroupsDeleted int
}
type RollbackService struct {
host rollbackHost
}
func NewRollbackService(host rollbackHost) *RollbackService {
return &RollbackService{host: host}
}
func (s *RollbackService) Rollback(ctx context.Context, req RollbackRequest) (RollbackReport, error) {
if s.host == nil {
return RollbackReport{}, fmt.Errorf("rollback host is required")
}
names := SuggestResourceNames(req.Provider)
snapshot, err := s.host.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{
GroupName: names.Group,
ChannelName: names.Channel,
PlanName: names.Plan,
AccountNamePrefix: SuggestAccountNamePrefix(req.Provider),
})
if err != nil {
return RollbackReport{}, fmt.Errorf("list managed resources: %w", err)
}
var report RollbackReport
var errs []error
for index := len(snapshot.Accounts) - 1; index >= 0; index-- {
if err := s.host.DeleteAccount(ctx, snapshot.Accounts[index].ID); err != nil {
errs = append(errs, fmt.Errorf("delete account %s: %w", snapshot.Accounts[index].ID, err))
continue
}
report.AccountsDeleted++
}
for index := len(snapshot.Plans) - 1; index >= 0; index-- {
if err := s.host.DeletePlan(ctx, snapshot.Plans[index].ID); err != nil {
errs = append(errs, fmt.Errorf("delete plan %s: %w", snapshot.Plans[index].ID, err))
continue
}
report.PlansDeleted++
}
for index := len(snapshot.Channels) - 1; index >= 0; index-- {
if err := s.host.DeleteChannel(ctx, snapshot.Channels[index].ID); err != nil {
errs = append(errs, fmt.Errorf("delete channel %s: %w", snapshot.Channels[index].ID, err))
continue
}
report.ChannelsDeleted++
}
for index := len(snapshot.Groups) - 1; index >= 0; index-- {
if err := s.host.DeleteGroup(ctx, snapshot.Groups[index].ID); err != nil {
errs = append(errs, fmt.Errorf("delete group %s: %w", snapshot.Groups[index].ID, err))
continue
}
report.GroupsDeleted++
}
if len(errs) > 0 {
return report, errors.Join(errs...)
}
return report, nil
}

View File

@@ -0,0 +1,50 @@
package provision
import (
"context"
"reflect"
"testing"
"sub2api-cn-relay-manager/internal/host/sub2api"
)
func TestRollbackServiceDeletesManagedResourcesInDependencyOrder(t *testing.T) {
host := &fakeHostAdapter{
managedSnapshot: sub2api.ManagedResourceSnapshot{
Groups: []sub2api.NamedResource{{ID: "group_1", Name: "crm-deepseek-group"}},
Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "crm-deepseek-channel"}},
Plans: []sub2api.NamedResource{{ID: "plan_1", Name: "crm-deepseek-plan"}},
Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}},
},
}
svc := NewRollbackService(host)
report, err := svc.Rollback(context.Background(), RollbackRequest{Provider: sampleProviderManifest()})
if err != nil {
t.Fatalf("Rollback() error = %v", err)
}
if report.AccountsDeleted != 2 || report.PlansDeleted != 1 || report.ChannelsDeleted != 1 || report.GroupsDeleted != 1 {
t.Fatalf("Rollback() report = %+v, want all managed resources deleted", report)
}
want := []string{"account:account_2", "account:account_1", "plan:plan_1", "channel:channel_1", "group:group_1"}
if !reflect.DeepEqual(host.deletedResources, want) {
t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, want)
}
}
func TestRollbackServiceReturnsEmptyReportWhenNoManagedResourcesExist(t *testing.T) {
host := &fakeHostAdapter{}
svc := NewRollbackService(host)
report, err := svc.Rollback(context.Background(), RollbackRequest{Provider: sampleProviderManifest()})
if err != nil {
t.Fatalf("Rollback() error = %v", err)
}
if report.AccountsDeleted != 0 || report.PlansDeleted != 0 || report.ChannelsDeleted != 0 || report.GroupsDeleted != 0 {
t.Fatalf("Rollback() report = %+v, want zero deletions", report)
}
if len(host.deletedResources) != 0 {
t.Fatalf("deleted resources = %#v, want none", host.deletedResources)
}
}

View File

@@ -0,0 +1,259 @@
package provision
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type RuntimeImportRequest struct {
HostID string
HostBaseURL string
Pack pack.LoadedPack
Provider pack.ProviderManifest
Mode string
Access AccessRequest
Keys []string
}
type RuntimeImportResult struct {
BatchID int64
Report ImportReport
}
type RuntimeImportService struct {
store *sqlite.DB
host hostAdapter
}
func NewRuntimeImportService(store *sqlite.DB, host hostAdapter) *RuntimeImportService {
return &RuntimeImportService{store: store, host: host}
}
func (s *RuntimeImportService) Import(ctx context.Context, req RuntimeImportRequest) (RuntimeImportResult, error) {
if s == nil || s.store == nil {
return RuntimeImportResult{}, fmt.Errorf("store is required")
}
if s.host == nil {
return RuntimeImportResult{}, fmt.Errorf("host adapter is required")
}
req.HostBaseURL = strings.TrimSpace(req.HostBaseURL)
if req.HostBaseURL == "" {
return RuntimeImportResult{}, fmt.Errorf("host_base_url is required")
}
if strings.TrimSpace(req.HostID) == "" {
req.HostID = req.HostBaseURL
}
hostVersion, err := s.host.GetHostVersion(ctx)
if err != nil {
return RuntimeImportResult{}, fmt.Errorf("get host version: %w", err)
}
if err := pack.CheckHostCompatibility(req.Pack.Manifest, hostVersion); err != nil {
return RuntimeImportResult{}, err
}
capabilities, err := s.host.ProbeCapabilities(ctx)
if err != nil {
return RuntimeImportResult{}, fmt.Errorf("probe host capabilities: %w", err)
}
capabilityProbeJSON, err := json.Marshal(capabilities)
if err != nil {
return RuntimeImportResult{}, fmt.Errorf("marshal host capabilities: %w", err)
}
hostRow, err := s.ensureHost(ctx, req.HostID, req.HostBaseURL, hostVersion, string(capabilityProbeJSON))
if err != nil {
return RuntimeImportResult{}, err
}
packRow, err := s.ensurePack(ctx, req.Pack)
if err != nil {
return RuntimeImportResult{}, err
}
providerRow, err := s.ensureProvider(ctx, packRow.ID, req.Provider)
if err != nil {
return RuntimeImportResult{}, err
}
batchID, err := s.store.ImportBatches().Create(ctx, sqlite.ImportBatch{
HostID: hostRow.ID,
PackID: packRow.ID,
ProviderID: providerRow.ID,
Mode: req.Mode,
BatchStatus: "running",
AccessStatus: "pending",
})
if err != nil {
return RuntimeImportResult{}, err
}
report, importErr := NewImportService(s.host).Import(ctx, ImportRequest{
Provider: req.Provider,
Mode: req.Mode,
Access: req.Access,
Keys: req.Keys,
})
if report.BatchStatus == "" {
report.BatchStatus = BatchStatusFailed
}
if report.AccessStatus == "" {
report.AccessStatus = AccessStatusBroken
}
if persistErr := s.persistRuntimeArtifacts(ctx, batchID, req.Access.Mode, report, importErr == nil); persistErr != nil {
return RuntimeImportResult{}, persistErr
}
if err := s.store.ImportBatches().UpdateStatus(ctx, batchID, report.BatchStatus, report.AccessStatus); err != nil {
return RuntimeImportResult{}, err
}
if importErr != nil {
return RuntimeImportResult{BatchID: batchID, Report: report}, importErr
}
return RuntimeImportResult{BatchID: batchID, Report: report}, nil
}
func (s *RuntimeImportService) ensureHost(ctx context.Context, hostID, baseURL, hostVersion, capabilityProbeJSON string) (sqlite.Host, error) {
host, err := s.store.Hosts().GetByHostID(ctx, hostID)
if err == nil {
return host, nil
}
if _, createErr := s.store.Hosts().Create(ctx, sqlite.Host{
HostID: hostID,
BaseURL: baseURL,
HostVersion: hostVersion,
CapabilityProbeJSON: capabilityProbeJSON,
}); createErr != nil {
return sqlite.Host{}, createErr
}
return s.store.Hosts().GetByHostID(ctx, hostID)
}
func (s *RuntimeImportService) ensurePack(ctx context.Context, loaded pack.LoadedPack) (sqlite.Pack, error) {
packRow, err := s.store.Packs().GetByPackID(ctx, loaded.Manifest.PackID)
if err == nil {
if err := validateExistingPack(packRow, loaded); err != nil {
return sqlite.Pack{}, err
}
}
packRecord, err := buildPackRecord(loaded)
if err != nil {
return sqlite.Pack{}, err
}
if _, err := s.store.Packs().Upsert(ctx, packRecord); err != nil {
return sqlite.Pack{}, err
}
return s.store.Packs().GetByPackID(ctx, loaded.Manifest.PackID)
}
func (s *RuntimeImportService) ensureProvider(ctx context.Context, packID int64, provider pack.ProviderManifest) (sqlite.Provider, error) {
if _, err := s.store.Providers().GetByPackIDAndProviderID(ctx, packID, provider.ProviderID); err == nil {
// continue into upsert path so metadata stays fresh.
}
providerRecord, err := buildProviderRecord(packID, provider)
if err != nil {
return sqlite.Provider{}, err
}
if _, err := s.store.Providers().Upsert(ctx, providerRecord); err != nil {
return sqlite.Provider{}, err
}
return s.store.Providers().GetByPackIDAndProviderID(ctx, packID, provider.ProviderID)
}
func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batchID int64, accessMode string, report ImportReport, includeManagedResources bool) error {
for i, account := range report.Accounts {
payload, err := json.Marshal(map[string]any{
"account_id": account.Ref.ID,
"probe_ok": account.Probe.OK,
"probe_status": account.Probe.Status,
"probe_message": account.Probe.Message,
"models": account.Models,
"smoke_model_seen": account.SmokeModelSeen,
})
if err != nil {
return fmt.Errorf("marshal account probe summary: %w", err)
}
itemID, err := s.store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{
BatchID: batchID,
KeyFingerprint: fingerprintKey(report.AcceptedKeys, i),
AccountStatus: firstNonEmpty(account.Probe.Status, "unknown"),
ProbeSummaryJSON: string(payload),
})
if err != nil {
return err
}
if _, err := s.store.ProbeResults().Create(ctx, sqlite.ProbeResult{
BatchItemID: itemID,
ProbeType: "account_smoke",
Status: firstNonEmpty(account.Probe.Status, "unknown"),
SummaryJSON: string(payload),
}); err != nil {
return err
}
}
if includeManagedResources {
if report.Group.ID != "" {
if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "group", HostResourceID: report.Group.ID, ResourceName: firstNonEmpty(report.Group.Name, report.Group.ID)}); err != nil {
return err
}
}
if report.Channel.ID != "" {
if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "channel", HostResourceID: report.Channel.ID, ResourceName: firstNonEmpty(report.Channel.Name, report.Channel.ID)}); err != nil {
return err
}
}
if report.Plan != nil && report.Plan.ID != "" {
if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "plan", HostResourceID: report.Plan.ID, ResourceName: firstNonEmpty(report.Plan.Name, report.Plan.ID)}); err != nil {
return err
}
}
for _, account := range report.Accounts {
if _, err := s.store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: batchID, ResourceType: "account", HostResourceID: account.Ref.ID, ResourceName: firstNonEmpty(account.Ref.Name, account.Ref.ID)}); err != nil {
return err
}
}
}
accessPayload, err := json.Marshal(map[string]any{
"status_code": report.Gateway.StatusCode,
"ok": report.Gateway.OK,
"has_expected_model": report.Gateway.HasExpectedModel,
"models": report.Gateway.Models,
})
if err != nil {
return fmt.Errorf("marshal gateway access summary: %w", err)
}
if _, err := s.store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{
BatchID: batchID,
ClosureType: firstNonEmpty(strings.TrimSpace(accessMode), "unknown"),
Status: firstNonEmpty(report.AccessStatus, AccessStatusBroken),
DetailsJSON: string(accessPayload),
}); err != nil {
return err
}
return nil
}
func fingerprintKey(keys []string, index int) string {
if index >= 0 && index < len(keys) {
key := strings.TrimSpace(keys[index])
if key != "" {
sum := sha256.Sum256([]byte(key))
return fmt.Sprintf("sha256:%x", sum[:])
}
}
return fmt.Sprintf("key-%d", index+1)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View File

@@ -0,0 +1,196 @@
package provision
import (
"context"
"database/sql"
"fmt"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
func TestRuntimeImportServicePersistsOperationalState(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: true, Status: "passed"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
}
svc := NewRuntimeImportService(store, host)
result, err := svc.Import(context.Background(), RuntimeImportRequest{
HostID: "host-1",
HostBaseURL: "https://sub2api.example.com",
Pack: pack.LoadedPack{
Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"},
Checksum: "checksum-1",
},
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Keys: []string{" key-1 ", "key-2", "key-1"},
Access: AccessRequest{
Mode: AccessModeSelfService,
ProbeAPIKey: "user-key",
},
})
if err != nil {
t.Fatalf("RuntimeImportService.Import() error = %v", err)
}
if result.BatchID <= 0 {
t.Fatalf("BatchID = %d, want positive id", result.BatchID)
}
if result.Report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", result.Report.BatchStatus, BatchStatusSucceeded)
}
if got := queryCount(t, store.SQLDB(), "hosts"); got != 1 {
t.Fatalf("hosts row count = %d, want 1", got)
}
if got := queryCount(t, store.SQLDB(), "packs"); got != 1 {
t.Fatalf("packs row count = %d, want 1", got)
}
if got := queryCount(t, store.SQLDB(), "providers"); got != 1 {
t.Fatalf("providers row count = %d, want 1", got)
}
if got := queryCount(t, store.SQLDB(), "import_batches"); got != 1 {
t.Fatalf("import_batches row count = %d, want 1", got)
}
if got := queryCount(t, store.SQLDB(), "import_batch_items"); got != 2 {
t.Fatalf("import_batch_items row count = %d, want 2", got)
}
if got := queryCount(t, store.SQLDB(), "managed_resources"); got != 4 {
t.Fatalf("managed_resources row count = %d, want 4", got)
}
if got := queryCount(t, store.SQLDB(), "probe_results"); got != 2 {
t.Fatalf("probe_results row count = %d, want 2", got)
}
if got := queryCount(t, store.SQLDB(), "access_closure_records"); got != 1 {
t.Fatalf("access_closure_records row count = %d, want 1", got)
}
var batchStatus string
var accessStatus string
if err := store.SQLDB().QueryRowContext(context.Background(), "SELECT batch_status, access_status FROM import_batches WHERE id = ?", result.BatchID).Scan(&batchStatus, &accessStatus); err != nil {
t.Fatalf("query import batch state: %v", err)
}
if batchStatus != BatchStatusSucceeded {
t.Fatalf("persisted batch_status = %q, want %q", batchStatus, BatchStatusSucceeded)
}
if accessStatus != AccessStatusSelfServiceReady {
t.Fatalf("persisted access_status = %q, want %q", accessStatus, AccessStatusSelfServiceReady)
}
var fingerprint string
var accountStatus string
if err := store.SQLDB().QueryRowContext(context.Background(), "SELECT key_fingerprint, account_status FROM import_batch_items ORDER BY id LIMIT 1").Scan(&fingerprint, &accountStatus); err != nil {
t.Fatalf("query import batch item: %v", err)
}
if fingerprint == "key-1" || fingerprint == "key-2" || len(fingerprint) < 10 {
t.Fatalf("key_fingerprint = %q, want hashed fingerprint instead of raw key", fingerprint)
}
if accountStatus != "passed" {
t.Fatalf("account_status = %q, want passed", accountStatus)
}
}
func TestRuntimeImportServicePersistsFailedBatchAfterStrictRollback(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {OK: true, Status: "passed"},
"account_2": {OK: false, Status: "failed", Message: "bad key"},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
"account_2": {{ID: "deepseek-chat"}},
},
}
svc := NewRuntimeImportService(store, host)
result, err := svc.Import(context.Background(), RuntimeImportRequest{
HostID: "host-1",
HostBaseURL: "https://sub2api.example.com",
Pack: pack.LoadedPack{
Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"},
Checksum: "checksum-1",
},
Provider: sampleProviderManifest(),
Mode: ImportModeStrict,
Keys: []string{"key-1", "key-2"},
Access: AccessRequest{
Mode: AccessModeSelfService,
ProbeAPIKey: "user-key",
},
})
if err == nil {
t.Fatal("RuntimeImportService.Import() error = nil, want strict failure")
}
if result.BatchID <= 0 {
t.Fatalf("BatchID = %d, want positive id", result.BatchID)
}
var batchStatus string
var accessStatus string
if err := store.SQLDB().QueryRowContext(context.Background(), "SELECT batch_status, access_status FROM import_batches WHERE id = ?", result.BatchID).Scan(&batchStatus, &accessStatus); err != nil {
t.Fatalf("query failed import batch state: %v", err)
}
if batchStatus != BatchStatusFailed {
t.Fatalf("persisted batch_status = %q, want %q", batchStatus, BatchStatusFailed)
}
if accessStatus != AccessStatusBroken {
t.Fatalf("persisted access_status = %q, want %q", accessStatus, AccessStatusBroken)
}
if got := queryCount(t, store.SQLDB(), "managed_resources"); got != 0 {
t.Fatalf("managed_resources row count = %d, want 0 after strict rollback", got)
}
if got := queryCount(t, store.SQLDB(), "probe_results"); got != 2 {
t.Fatalf("probe_results row count = %d, want 2", got)
}
if got := queryCount(t, store.SQLDB(), "access_closure_records"); got != 1 {
t.Fatalf("access_closure_records row count = %d, want 1", got)
}
}
func openProvisionTestStore(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 closeProvisionTestStore(t *testing.T, store *sqlite.DB) {
t.Helper()
if err := store.Close(); err != nil {
t.Fatalf("store.Close() error = %v", err)
}
}
func queryCount(t *testing.T, db *sql.DB, table string) int {
t.Helper()
var count int
if err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&count); err != nil {
t.Fatalf("count rows for %s: %v", table, err)
}
return count
}

View File

@@ -0,0 +1,64 @@
CREATE TABLE import_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
pack_id INTEGER NOT NULL,
provider_id INTEGER NOT NULL,
mode TEXT NOT NULL,
batch_status TEXT NOT NULL,
access_status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_import_batches_host FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE,
CONSTRAINT fk_import_batches_pack FOREIGN KEY (pack_id) REFERENCES packs(id) ON DELETE CASCADE,
CONSTRAINT fk_import_batches_provider FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
);
CREATE TABLE import_batch_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL,
key_fingerprint TEXT NOT NULL,
account_status TEXT NOT NULL,
probe_summary_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_import_batch_items_batch FOREIGN KEY (batch_id) REFERENCES import_batches(id) ON DELETE CASCADE,
CONSTRAINT uq_import_batch_items_batch_fingerprint UNIQUE (batch_id, key_fingerprint)
);
CREATE TABLE managed_resources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL,
resource_type TEXT NOT NULL,
host_resource_id TEXT NOT NULL,
resource_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_managed_resources_batch FOREIGN KEY (batch_id) REFERENCES import_batches(id) ON DELETE CASCADE,
CONSTRAINT uq_managed_resources_host UNIQUE (resource_type, host_resource_id)
);
CREATE TABLE probe_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_item_id INTEGER NOT NULL,
probe_type TEXT NOT NULL,
status TEXT NOT NULL,
summary_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_probe_results_item FOREIGN KEY (batch_item_id) REFERENCES import_batch_items(id) ON DELETE CASCADE
);
CREATE TABLE access_closure_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL,
closure_type TEXT NOT NULL,
status TEXT NOT NULL,
details_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_access_closure_records_batch FOREIGN KEY (batch_id) REFERENCES import_batches(id) ON DELETE CASCADE
);
CREATE TABLE reconcile_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id INTEGER NOT NULL,
status TEXT NOT NULL,
summary_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_reconcile_runs_provider FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,14 @@
ALTER TABLE packs ADD COLUMN vendor TEXT NOT NULL DEFAULT '';
ALTER TABLE packs ADD COLUMN target_host TEXT NOT NULL DEFAULT '';
ALTER TABLE packs ADD COLUMN min_host_version TEXT NOT NULL DEFAULT '';
ALTER TABLE packs ADD COLUMN max_host_version TEXT NOT NULL DEFAULT '';
ALTER TABLE packs ADD COLUMN manifest_json TEXT NOT NULL DEFAULT '{}';
ALTER TABLE providers ADD COLUMN account_type TEXT NOT NULL DEFAULT '';
ALTER TABLE providers ADD COLUMN default_models_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE providers ADD COLUMN smoke_test_model TEXT NOT NULL DEFAULT '';
ALTER TABLE providers ADD COLUMN group_template_json TEXT NOT NULL DEFAULT '{}';
ALTER TABLE providers ADD COLUMN channel_template_json TEXT NOT NULL DEFAULT '{}';
ALTER TABLE providers ADD COLUMN plan_template_json TEXT NOT NULL DEFAULT '{}';
ALTER TABLE providers ADD COLUMN import_options_json TEXT NOT NULL DEFAULT '{}';
ALTER TABLE providers ADD COLUMN manifest_json TEXT NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,77 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type AccessClosureRecord struct {
ID int64
BatchID int64
ClosureType string
Status string
DetailsJSON string
}
type AccessClosureRecordsRepo struct {
db execQuerier
}
func newAccessClosureRecordsRepo(db execQuerier) *AccessClosureRecordsRepo {
return &AccessClosureRecordsRepo{db: db}
}
func (r *AccessClosureRecordsRepo) Create(ctx context.Context, record AccessClosureRecord) (int64, error) {
closureType := strings.TrimSpace(record.ClosureType)
status := strings.TrimSpace(record.Status)
detailsJSON := strings.TrimSpace(record.DetailsJSON)
if detailsJSON == "" {
detailsJSON = "{}"
}
switch {
case record.BatchID <= 0:
return 0, fmt.Errorf("batch_id is required")
case closureType == "":
return 0, fmt.Errorf("closure_type is required")
case status == "":
return 0, fmt.Errorf("status is required")
}
result, err := r.db.ExecContext(ctx, `INSERT INTO access_closure_records (batch_id, closure_type, status, details_json) VALUES (?, ?, ?, ?)`, record.BatchID, closureType, status, detailsJSON)
if err != nil {
return 0, fmt.Errorf("insert access closure record: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted access closure record id: %w", err)
}
return id, nil
}
func (r *AccessClosureRecordsRepo) GetByBatchID(ctx context.Context, batchID int64) ([]AccessClosureRecord, error) {
if batchID <= 0 {
return nil, fmt.Errorf("batch_id is required")
}
rows, err := r.db.QueryContext(ctx, `SELECT id, batch_id, closure_type, status, details_json FROM access_closure_records WHERE batch_id = ? ORDER BY id`, batchID)
if err != nil {
return nil, fmt.Errorf("query access closure records: %w", err)
}
defer rows.Close()
records := make([]AccessClosureRecord, 0)
for rows.Next() {
var record AccessClosureRecord
if err := rows.Scan(&record.ID, &record.BatchID, &record.ClosureType, &record.Status, &record.DetailsJSON); err != nil {
return nil, fmt.Errorf("scan access closure record: %w", err)
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate access closure records: %w", err)
}
return records, nil
}

View File

@@ -15,13 +15,20 @@ import (
type execQuerier interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type Queries struct {
Hosts *HostsRepo
Packs *PacksRepo
Providers *ProvidersRepo
Hosts *HostsRepo
Packs *PacksRepo
Providers *ProvidersRepo
ImportBatches *ImportBatchesRepo
ImportBatchItems *ImportBatchItemsRepo
ManagedResources *ManagedResourcesRepo
ProbeResults *ProbeResultsRepo
AccessClosures *AccessClosureRecordsRepo
ReconcileRuns *ReconcileRunsRepo
}
type DB struct {
@@ -76,6 +83,30 @@ func (db *DB) Providers() *ProvidersRepo {
return db.queries.Providers
}
func (db *DB) ImportBatches() *ImportBatchesRepo {
return db.queries.ImportBatches
}
func (db *DB) ImportBatchItems() *ImportBatchItemsRepo {
return db.queries.ImportBatchItems
}
func (db *DB) ManagedResources() *ManagedResourcesRepo {
return db.queries.ManagedResources
}
func (db *DB) ProbeResults() *ProbeResultsRepo {
return db.queries.ProbeResults
}
func (db *DB) AccessClosures() *AccessClosureRecordsRepo {
return db.queries.AccessClosures
}
func (db *DB) ReconcileRuns() *ReconcileRunsRepo {
return db.queries.ReconcileRuns
}
func (db *DB) WithTx(ctx context.Context, fn func(*Queries) error) error {
tx, err := db.sqlDB.BeginTx(ctx, nil)
if err != nil {
@@ -101,9 +132,15 @@ func (db *DB) WithTx(ctx context.Context, fn func(*Queries) error) error {
func newQueries(db execQuerier) *Queries {
return &Queries{
Hosts: newHostsRepo(db),
Packs: newPacksRepo(db),
Providers: newProvidersRepo(db),
Hosts: newHostsRepo(db),
Packs: newPacksRepo(db),
Providers: newProvidersRepo(db),
ImportBatches: newImportBatchesRepo(db),
ImportBatchItems: newImportBatchItemsRepo(db),
ManagedResources: newManagedResourcesRepo(db),
ProbeResults: newProbeResultsRepo(db),
AccessClosures: newAccessClosureRecordsRepo(db),
ReconcileRuns: newReconcileRunsRepo(db),
}
}

View File

@@ -0,0 +1,161 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
)
func TestOpenClose(t *testing.T) {
store := openTestDB(t)
if store == nil {
t.Fatal("Open() returned nil")
}
}
func TestOpenInvalidDSN(t *testing.T) {
_, err := Open(context.Background(), "file:/nonexistent/dir/test.db?_pragma=foreign_keys(0)")
if err == nil {
t.Fatal("Open() with invalid dsn error = nil, want error")
}
}
func TestWithTxCommit(t *testing.T) {
store := openTestDB(t)
err := store.WithTx(context.Background(), func(q *Queries) error {
_, err := q.Hosts.Create(context.Background(), Host{
HostID: "tx-host", BaseURL: "https://tx.com", HostVersion: "1.0",
})
return err
})
if err != nil {
t.Fatalf("WithTx() error = %v", err)
}
host, err := store.Hosts().GetByHostID(context.Background(), "tx-host")
if err != nil {
t.Fatalf("GetByHostID() after tx = %v", err)
}
if host.HostID != "tx-host" {
t.Fatalf("host = %+v, want tx-host", host)
}
}
func TestWithTxRollbackOnError(t *testing.T) {
store := openTestDB(t)
err := store.WithTx(context.Background(), func(q *Queries) error {
q.Hosts.Create(context.Background(), Host{
HostID: "rollback-host", BaseURL: "https://r.com", HostVersion: "1.0",
})
return errors.New("rollback")
})
if err == nil {
t.Fatal("WithTx() error = nil, want rollback error")
}
_, err = store.Hosts().GetByHostID(context.Background(), "rollback-host")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetByHostID() after rollback error = %v, want sql.ErrNoRows", err)
}
}
func TestTableExists(t *testing.T) {
store := openTestDB(t)
db := store.SQLDB()
found, err := tableExists(context.Background(), db, "hosts")
if err != nil {
t.Fatalf("tableExists('hosts') error = %v", err)
}
if !found {
t.Fatal("tableExists('hosts') = false, want true")
}
found, err = tableExists(context.Background(), db, "nonexistent")
if err != nil {
t.Fatalf("tableExists('nonexistent') error = %v", err)
}
if found {
t.Fatal("tableExists('nonexistent') = true, want false")
}
}
func TestDetectLegacy0001Schema(t *testing.T) {
store := openTestDB(t)
db := store.SQLDB()
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("BeginTx error = %v", err)
}
defer tx.Rollback()
// After migration all three host/packs/providers tables exist,
// so detectLegacy0001Schema reports complete=true.
complete, partial, err := detectLegacy0001Schema(context.Background(), tx)
if err != nil {
t.Fatalf("detectLegacy0001Schema() error = %v", err)
}
if !complete {
t.Fatalf("detectLegacy0001Schema() = (complete=%v, partial=%v), want (true, false)", complete, partial)
}
if partial {
t.Fatal("partial should be false when all 3 tables exist")
}
}
func TestWithForeignKeysEnabled(t *testing.T) {
if got := withForeignKeysEnabled("file:test.db"); got != "file:test.db?_pragma=foreign_keys(1)" {
t.Fatalf("withForeignKeysEnabled no query = %q", got)
}
if got := withForeignKeysEnabled("file:test.db?a=1"); got != "file:test.db?a=1&_pragma=foreign_keys(1)" {
t.Fatalf("withForeignKeysEnabled with query = %q", got)
}
}
func TestSQLDB(t *testing.T) {
store := openTestDB(t)
db := store.SQLDB()
if db == nil {
t.Fatal("SQLDB() returned nil")
}
if err := db.PingContext(context.Background()); err != nil {
t.Fatalf("Ping() error = %v", err)
}
}
func TestMigrationFileNames(t *testing.T) {
names, err := migrationFileNames()
if err != nil {
t.Fatalf("migrationFileNames() error = %v", err)
}
if len(names) == 0 {
t.Fatal("migrationFileNames() returned empty")
}
for _, name := range names {
if filepath.Ext(name) != ".sql" {
t.Fatalf("migrationFileNames() entry %q not .sql file", name)
}
}
}
func TestReadMigration(t *testing.T) {
content, err := readMigration("0001_init.sql")
if err != nil {
t.Fatalf("readMigration('0001_init.sql') error = %v", err)
}
if len(content) == 0 {
t.Fatal("readMigration() returned empty content")
}
}
func TestReadMigrationNotFound(t *testing.T) {
_, err := readMigration("nonexistent.sql")
if err == nil {
t.Fatal("readMigration('nonexistent.sql') error = nil, want error")
}
}

View File

@@ -7,6 +7,7 @@ import (
)
type Host struct {
ID int64
HostID string
BaseURL string
HostVersion string
@@ -21,6 +22,31 @@ func newHostsRepo(db execQuerier) *HostsRepo {
return &HostsRepo{db: db}
}
func (r *HostsRepo) GetByID(ctx context.Context, id int64) (Host, error) {
if id <= 0 {
return Host{}, fmt.Errorf("id is required")
}
var host Host
if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, base_url, host_version, capability_probe_json FROM hosts WHERE id = ?`, id).Scan(&host.ID, &host.HostID, &host.BaseURL, &host.HostVersion, &host.CapabilityProbeJSON); err != nil {
return Host{}, err
}
return host, nil
}
func (r *HostsRepo) GetByHostID(ctx context.Context, hostID string) (Host, error) {
hostID = strings.TrimSpace(hostID)
if hostID == "" {
return Host{}, fmt.Errorf("host_id is required")
}
var host Host
if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, base_url, host_version, capability_probe_json FROM hosts WHERE host_id = ?`, hostID).Scan(&host.ID, &host.HostID, &host.BaseURL, &host.HostVersion, &host.CapabilityProbeJSON); err != nil {
return Host{}, err
}
return host, nil
}
func (r *HostsRepo) Create(ctx context.Context, host Host) (int64, error) {
hostID := strings.TrimSpace(host.HostID)
baseURL := strings.TrimSpace(host.BaseURL)

View File

@@ -0,0 +1,199 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
)
// openTestDB creates a test database with foreign keys disabled.
func openTestDB(t *testing.T) *DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
dsn := "file:" + filepath.ToSlash(dbPath) + "?_pragma=foreign_keys(0)"
store, err := Open(context.Background(), dsn)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
// openTestDBWithFK creates a test database with foreign keys enforced.
func openTestDBWithFK(t *testing.T) *DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test-fk.db")
dsn := "file:" + filepath.ToSlash(dbPath)
store, err := Open(context.Background(), dsn)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
func createTestPack(t *testing.T, store *DB) int64 {
t.Helper()
id, err := store.Packs().Create(context.Background(), Pack{
PackID: "pack-" + sanitizeTestName(t.Name()), Version: "1.0.0", Checksum: "chk",
})
if err != nil {
t.Fatalf("createTestPack error = %v", err)
}
return id
}
func createTestHost(t *testing.T, store *DB) int64 {
t.Helper()
id, err := store.Hosts().Create(context.Background(), Host{
HostID: "host-" + sanitizeTestName(t.Name()), BaseURL: "https://h.com", HostVersion: "0.1.0",
})
if err != nil {
t.Fatalf("createTestHost error = %v", err)
}
return id
}
func createTestBatch(t *testing.T, store *DB) int64 {
t.Helper()
hostID := createTestHost(t, store)
packID := createTestPack(t, store)
providerID, err := store.Providers().Create(context.Background(), Provider{
PackID: packID, ProviderID: "test-provider", DisplayName: "Test",
BaseURL: "https://t.com", Platform: "openai",
})
if err != nil {
t.Fatalf("createTestBatch create provider error = %v", err)
}
id, err := store.ImportBatches().Create(context.Background(), ImportBatch{
HostID: hostID, PackID: packID, ProviderID: providerID,
Mode: "partial", BatchStatus: "running", AccessStatus: "pending",
})
if err != nil {
t.Fatalf("createTestBatch error = %v", err)
}
return id
}
func createTestBatchItem(t *testing.T, store *DB, batchID int64) int64 {
t.Helper()
id, err := store.ImportBatchItems().Create(context.Background(), ImportBatchItem{
BatchID: batchID, KeyFingerprint: "sha256:test", AccountStatus: "pending",
})
if err != nil {
t.Fatalf("createTestBatchItem error = %v", err)
}
return id
}
func sanitizeTestName(name string) string {
result := ""
for _, c := range name {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
result += string(c)
}
}
if result == "" {
result = "default"
}
return result
}
// --- Hosts Repo Tests ---
func TestHostsRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
id, err := store.Hosts().Create(context.Background(), Host{
HostID: "host-1",
BaseURL: "https://sub2api.example.com",
HostVersion: "0.1.126",
CapabilityProbeJSON: `{"groups":true}`,
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if id <= 0 {
t.Fatalf("Create() id = %d, want positive", id)
}
got, err := store.Hosts().GetByID(context.Background(), id)
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if got.HostID != "host-1" || got.BaseURL != "https://sub2api.example.com" {
t.Fatalf("GetByID() = %+v, want host-1", got)
}
got2, err := store.Hosts().GetByHostID(context.Background(), "host-1")
if err != nil {
t.Fatalf("GetByHostID() error = %v", err)
}
if got2.ID != id {
t.Fatalf("GetByHostID() id = %d, want %d", got2.ID, id)
}
}
func TestHostsRepoCreateDefaultsCapabilityProbe(t *testing.T) {
store := openTestDB(t)
id, _ := store.Hosts().Create(context.Background(), Host{
HostID: "host-empty", BaseURL: "https://example.com", HostVersion: "0.1.0",
})
got, _ := store.Hosts().GetByID(context.Background(), id)
if got.CapabilityProbeJSON != "{}" {
t.Fatalf("CapabilityProbeJSON = %q, want {}", got.CapabilityProbeJSON)
}
}
func TestHostsRepoValidationErrors(t *testing.T) {
store := openTestDB(t)
for _, tt := range []struct {
name string
host Host
}{
{"empty host_id", Host{BaseURL: "b", HostVersion: "v"}},
{"empty base_url", Host{HostID: "h", HostVersion: "v"}},
{"empty host_version", Host{HostID: "h", BaseURL: "b"}},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := store.Hosts().Create(context.Background(), tt.host)
if err == nil {
t.Fatal("Create() error = nil, want validation error")
}
})
}
}
func TestHostsRepoGetByIDZeroError(t *testing.T) {
store := openTestDB(t)
_, err := store.Hosts().GetByID(context.Background(), 0)
if err == nil {
t.Fatal("GetByID(0) error = nil, want error")
}
}
func TestHostsRepoGetByIDNotFound(t *testing.T) {
store := openTestDB(t)
_, err := store.Hosts().GetByID(context.Background(), 999)
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetByID(999) error = %v, want sql.ErrNoRows", err)
}
}
func TestHostsRepoGetByHostIDEmptyError(t *testing.T) {
store := openTestDB(t)
_, err := store.Hosts().GetByHostID(context.Background(), "")
if err == nil {
t.Fatal("GetByHostID('') error = nil, want error")
}
}
func TestHostsRepoGetByHostIDNotFound(t *testing.T) {
store := openTestDB(t)
_, err := store.Hosts().GetByHostID(context.Background(), "nonexistent")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetByHostID('nonexistent') error = %v, want sql.ErrNoRows", err)
}
}

View File

@@ -0,0 +1,187 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type ImportBatch struct {
ID int64
HostID int64
PackID int64
ProviderID int64
Mode string
BatchStatus string
AccessStatus string
}
type ImportBatchItem struct {
ID int64
BatchID int64
KeyFingerprint string
AccountStatus string
ProbeSummaryJSON string
}
type ImportBatchesRepo struct {
db execQuerier
}
type ImportBatchItemsRepo struct {
db execQuerier
}
func newImportBatchesRepo(db execQuerier) *ImportBatchesRepo {
return &ImportBatchesRepo{db: db}
}
func newImportBatchItemsRepo(db execQuerier) *ImportBatchItemsRepo {
return &ImportBatchItemsRepo{db: db}
}
func (r *ImportBatchesRepo) GetByID(ctx context.Context, id int64) (ImportBatch, error) {
if id <= 0 {
return ImportBatch{}, fmt.Errorf("id is required")
}
var batch ImportBatch
if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, pack_id, provider_id, mode, batch_status, access_status FROM import_batches WHERE id = ?`, id).Scan(&batch.ID, &batch.HostID, &batch.PackID, &batch.ProviderID, &batch.Mode, &batch.BatchStatus, &batch.AccessStatus); err != nil {
return ImportBatch{}, err
}
return batch, nil
}
func (r *ImportBatchesRepo) Create(ctx context.Context, batch ImportBatch) (int64, error) {
mode := strings.TrimSpace(batch.Mode)
batchStatus := strings.TrimSpace(batch.BatchStatus)
accessStatus := strings.TrimSpace(batch.AccessStatus)
switch {
case batch.HostID <= 0:
return 0, fmt.Errorf("host_id is required")
case batch.PackID <= 0:
return 0, fmt.Errorf("pack_id is required")
case batch.ProviderID <= 0:
return 0, fmt.Errorf("provider_id is required")
case mode == "":
return 0, fmt.Errorf("mode is required")
case batchStatus == "":
return 0, fmt.Errorf("batch_status is required")
case accessStatus == "":
return 0, fmt.Errorf("access_status is required")
}
result, err := r.db.ExecContext(ctx, `INSERT INTO import_batches (host_id, pack_id, provider_id, mode, batch_status, access_status) VALUES (?, ?, ?, ?, ?, ?)`, batch.HostID, batch.PackID, batch.ProviderID, mode, batchStatus, accessStatus)
if err != nil {
return 0, fmt.Errorf("insert import batch: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted import batch id: %w", err)
}
return id, nil
}
func (r *ImportBatchesRepo) UpdateStatus(ctx context.Context, id int64, batchStatus, accessStatus string) error {
if id <= 0 {
return fmt.Errorf("id is required")
}
batchStatus = strings.TrimSpace(batchStatus)
accessStatus = strings.TrimSpace(accessStatus)
if batchStatus == "" {
return fmt.Errorf("batch_status is required")
}
if accessStatus == "" {
return fmt.Errorf("access_status is required")
}
if _, err := r.db.ExecContext(ctx, `UPDATE import_batches SET batch_status = ?, access_status = ? WHERE id = ?`, batchStatus, accessStatus, id); err != nil {
return fmt.Errorf("update import batch %d: %w", id, err)
}
return nil
}
func (r *ImportBatchesRepo) GetLatestByProviderID(ctx context.Context, providerID int64) (ImportBatch, error) {
if providerID <= 0 {
return ImportBatch{}, fmt.Errorf("provider_id is required")
}
var batch ImportBatch
if err := r.db.QueryRowContext(ctx, `SELECT id, host_id, pack_id, provider_id, mode, batch_status, access_status FROM import_batches WHERE provider_id = ? ORDER BY id DESC LIMIT 1`, providerID).Scan(&batch.ID, &batch.HostID, &batch.PackID, &batch.ProviderID, &batch.Mode, &batch.BatchStatus, &batch.AccessStatus); err != nil {
return ImportBatch{}, err
}
return batch, nil
}
func (r *ImportBatchItemsRepo) GetByBatchID(ctx context.Context, batchID int64) ([]ImportBatchItem, error) {
if batchID <= 0 {
return nil, fmt.Errorf("batch_id is required")
}
rows, err := r.db.QueryContext(ctx, `SELECT id, batch_id, key_fingerprint, account_status, probe_summary_json FROM import_batch_items WHERE batch_id = ? ORDER BY id`, batchID)
if err != nil {
return nil, fmt.Errorf("query import batch items: %w", err)
}
defer rows.Close()
items := make([]ImportBatchItem, 0)
for rows.Next() {
var item ImportBatchItem
if err := rows.Scan(&item.ID, &item.BatchID, &item.KeyFingerprint, &item.AccountStatus, &item.ProbeSummaryJSON); err != nil {
return nil, fmt.Errorf("scan import batch item: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate import batch items: %w", err)
}
return items, nil
}
func (r *ImportBatchItemsRepo) Create(ctx context.Context, item ImportBatchItem) (int64, error) {
keyFingerprint := strings.TrimSpace(item.KeyFingerprint)
accountStatus := strings.TrimSpace(item.AccountStatus)
probeSummaryJSON := strings.TrimSpace(item.ProbeSummaryJSON)
if probeSummaryJSON == "" {
probeSummaryJSON = "{}"
}
switch {
case item.BatchID <= 0:
return 0, fmt.Errorf("batch_id is required")
case keyFingerprint == "":
return 0, fmt.Errorf("key_fingerprint is required")
case accountStatus == "":
return 0, fmt.Errorf("account_status is required")
}
result, err := r.db.ExecContext(ctx, `INSERT INTO import_batch_items (batch_id, key_fingerprint, account_status, probe_summary_json) VALUES (?, ?, ?, ?)`, item.BatchID, keyFingerprint, accountStatus, probeSummaryJSON)
if err != nil {
return 0, fmt.Errorf("insert import batch item: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted import batch item id: %w", err)
}
return id, nil
}
func (r *ImportBatchItemsRepo) UpdateResult(ctx context.Context, id int64, accountStatus, probeSummaryJSON string) error {
if id <= 0 {
return fmt.Errorf("id is required")
}
accountStatus = strings.TrimSpace(accountStatus)
probeSummaryJSON = strings.TrimSpace(probeSummaryJSON)
if accountStatus == "" {
return fmt.Errorf("account_status is required")
}
if probeSummaryJSON == "" {
probeSummaryJSON = "{}"
}
if _, err := r.db.ExecContext(ctx, `UPDATE import_batch_items SET account_status = ?, probe_summary_json = ? WHERE id = ?`, accountStatus, probeSummaryJSON, id); err != nil {
return fmt.Errorf("update import batch item %d: %w", id, err)
}
return nil
}

View File

@@ -0,0 +1,429 @@
package sqlite
import (
"context"
"testing"
)
func TestImportBatchesRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
hostID := createTestHost(t, store)
packID := createTestPack(t, store)
providerID := createTestProviderWithPack(t, store, packID)
id, err := store.ImportBatches().Create(context.Background(), ImportBatch{
HostID: hostID, PackID: packID, ProviderID: providerID,
Mode: "partial", BatchStatus: "running", AccessStatus: "pending",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if id <= 0 {
t.Fatalf("Create() id = %d, want positive", id)
}
got, _ := store.ImportBatches().GetByID(context.Background(), id)
if got.Mode != "partial" || got.BatchStatus != "running" {
t.Fatalf("GetByID() = %+v, want running batch", got)
}
}
func TestImportBatchesRepoUpdateStatus(t *testing.T) {
store := openTestDB(t)
hostID := createTestHost(t, store)
packID := createTestPack(t, store)
providerID := createTestProviderWithPack(t, store, packID)
id, _ := store.ImportBatches().Create(context.Background(), ImportBatch{
HostID: hostID, PackID: packID, ProviderID: providerID,
Mode: "partial", BatchStatus: "running", AccessStatus: "pending",
})
err := store.ImportBatches().UpdateStatus(context.Background(), id, "succeeded", "subscription_ready")
if err != nil {
t.Fatalf("UpdateStatus() error = %v", err)
}
got, _ := store.ImportBatches().GetByID(context.Background(), id)
if got.BatchStatus != "succeeded" || got.AccessStatus != "subscription_ready" {
t.Fatalf("status = %+v, want succeeded/subscription_ready", got)
}
}
func TestImportBatchesRepoGetLatestByProviderID(t *testing.T) {
store := openTestDB(t)
hostID := createTestHost(t, store)
packID := createTestPack(t, store)
providerID := createTestProviderWithPack(t, store, packID)
store.ImportBatches().Create(context.Background(), ImportBatch{
HostID: hostID, PackID: packID, ProviderID: providerID,
Mode: "partial", BatchStatus: "running", AccessStatus: "pending",
})
id2, _ := store.ImportBatches().Create(context.Background(), ImportBatch{
HostID: hostID, PackID: packID, ProviderID: providerID,
Mode: "strict", BatchStatus: "succeeded", AccessStatus: "subscription_ready",
})
got, _ := store.ImportBatches().GetLatestByProviderID(context.Background(), providerID)
if got.ID != id2 {
t.Fatalf("latest id = %d, want %d", got.ID, id2)
}
}
func TestImportBatchesRepoGetByIDNotFound(t *testing.T) {
store := openTestDB(t)
_, err := store.ImportBatches().GetByID(context.Background(), 999)
if err == nil {
t.Fatal("GetByID(999) error = nil, want error")
}
}
func TestImportBatchesRepoCreateValidationErrors(t *testing.T) {
store := openTestDB(t)
for _, tt := range []struct {
name string
batch ImportBatch
}{
{"host_id zero", ImportBatch{HostID: 0, PackID: 1, ProviderID: 1, Mode: "m", BatchStatus: "s", AccessStatus: "s"}},
{"pack_id zero", ImportBatch{HostID: 1, PackID: 0, ProviderID: 1, Mode: "m", BatchStatus: "s", AccessStatus: "s"}},
{"provider_id zero", ImportBatch{HostID: 1, PackID: 1, ProviderID: 0, Mode: "m", BatchStatus: "s", AccessStatus: "s"}},
{"empty mode", ImportBatch{HostID: 1, PackID: 1, ProviderID: 1, Mode: "", BatchStatus: "s", AccessStatus: "s"}},
{"empty batch_status", ImportBatch{HostID: 1, PackID: 1, ProviderID: 1, Mode: "m", BatchStatus: "", AccessStatus: "s"}},
{"empty access_status", ImportBatch{HostID: 1, PackID: 1, ProviderID: 1, Mode: "m", BatchStatus: "s", AccessStatus: ""}},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := store.ImportBatches().Create(context.Background(), tt.batch)
if err == nil {
t.Fatal("Create() error = nil")
}
})
}
}
func TestImportBatchesRepoUpdateStatusValidation(t *testing.T) {
store := openTestDB(t)
if err := store.ImportBatches().UpdateStatus(context.Background(), 0, "s", "s"); err == nil {
t.Fatal("UpdateStatus id=0 error = nil")
}
if err := store.ImportBatches().UpdateStatus(context.Background(), 1, "", "s"); err == nil {
t.Fatal("UpdateStatus empty batch_status error = nil")
}
}
// --- ImportBatchItems Repo Tests ---
func TestImportBatchItemsRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
id, err := store.ImportBatchItems().Create(context.Background(), ImportBatchItem{
BatchID: batchID,
KeyFingerprint: "sha256:abc",
AccountStatus: "passed",
ProbeSummaryJSON: `{"ok":true}`,
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if id <= 0 {
t.Fatalf("Create() id = %d, want positive", id)
}
items, _ := store.ImportBatchItems().GetByBatchID(context.Background(), batchID)
if len(items) != 1 || items[0].AccountStatus != "passed" {
t.Fatalf("items = %+v, want 1 with passed status", items)
}
}
func TestImportBatchItemsRepoMultipleItems(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
for _, status := range []string{"passed", "failed"} {
store.ImportBatchItems().Create(context.Background(), ImportBatchItem{
BatchID: batchID, KeyFingerprint: "sha256:" + status, AccountStatus: status,
})
}
items, _ := store.ImportBatchItems().GetByBatchID(context.Background(), batchID)
if len(items) != 2 {
t.Fatalf("count = %d, want 2", len(items))
}
}
func TestImportBatchItemsRepoUpdateResult(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
itemID, _ := store.ImportBatchItems().Create(context.Background(), ImportBatchItem{
BatchID: batchID, KeyFingerprint: "sha256:x", AccountStatus: "pending",
})
store.ImportBatchItems().UpdateResult(context.Background(), itemID, "passed", `{"ok":true}`)
items, _ := store.ImportBatchItems().GetByBatchID(context.Background(), batchID)
if items[0].AccountStatus != "passed" {
t.Fatalf("AccountStatus = %q, want passed", items[0].AccountStatus)
}
}
func TestImportBatchItemsRepoGetByBatchIDEmpty(t *testing.T) {
store := openTestDB(t)
items, err := store.ImportBatchItems().GetByBatchID(context.Background(), 999)
if err != nil {
t.Fatalf("GetByBatchID() error = %v, want empty result", err)
}
if len(items) != 0 {
t.Fatalf("count = %d, want 0", len(items))
}
}
func TestImportBatchItemsRepoValidation(t *testing.T) {
store := openTestDB(t)
_, err := store.ImportBatchItems().Create(context.Background(), ImportBatchItem{
BatchID: 0, KeyFingerprint: "k", AccountStatus: "s",
})
if err == nil {
t.Fatal("Create batch_id=0 error = nil")
}
}
// --- Managed Resources Repo Tests ---
func TestManagedResourcesRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
id, err := store.ManagedResources().Create(context.Background(), ManagedResource{
BatchID: batchID, ResourceType: "group", HostResourceID: "g_01", ResourceName: "test-group",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
resources, _ := store.ManagedResources().GetByBatchID(context.Background(), batchID)
if len(resources) != 1 || resources[0].HostResourceID != "g_01" {
t.Fatalf("resources = %+v, want 1 with g_01", resources)
}
_ = id
}
func TestManagedResourcesRepoMultipleResources(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
for _, r := range []ManagedResource{
{BatchID: batchID, ResourceType: "group", HostResourceID: "g_01", ResourceName: "group-1"},
{BatchID: batchID, ResourceType: "channel", HostResourceID: "c_01", ResourceName: "channel-1"},
{BatchID: batchID, ResourceType: "account", HostResourceID: "a_01", ResourceName: "account-1"},
} {
store.ManagedResources().Create(context.Background(), r)
}
resources, _ := store.ManagedResources().GetByBatchID(context.Background(), batchID)
if len(resources) != 3 {
t.Fatalf("count = %d, want 3", len(resources))
}
}
func TestManagedResourcesRepoGetByBatchIDEmpty(t *testing.T) {
store := openTestDB(t)
resources, _ := store.ManagedResources().GetByBatchID(context.Background(), 999)
if len(resources) != 0 {
t.Fatalf("count = %d, want 0", len(resources))
}
}
func TestManagedResourcesRepoValidationErrors(t *testing.T) {
store := openTestDB(t)
for _, tt := range []struct {
name string
r ManagedResource
}{
{"batch_id zero", ManagedResource{ResourceType: "g", HostResourceID: "h", ResourceName: "n"}},
{"empty resource_type", ManagedResource{BatchID: 1, HostResourceID: "h", ResourceName: "n"}},
{"empty host_resource_id", ManagedResource{BatchID: 1, ResourceType: "g", ResourceName: "n"}},
{"empty resource_name", ManagedResource{BatchID: 1, ResourceType: "g", HostResourceID: "h"}},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := store.ManagedResources().Create(context.Background(), tt.r)
if err == nil {
t.Fatal("Create() error = nil")
}
})
}
}
// --- Probe Results Repo Tests ---
func TestProbeResultsRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
itemID := createTestBatchItem(t, store, batchID)
id, err := store.ProbeResults().Create(context.Background(), ProbeResult{
BatchItemID: itemID, ProbeType: "account_smoke", Status: "passed", SummaryJSON: `{"ok":true}`,
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
results, _ := store.ProbeResults().GetByBatchItemID(context.Background(), itemID)
if len(results) != 1 || results[0].ProbeType != "account_smoke" {
t.Fatalf("results = %+v, want 1 with account_smoke", results)
}
_ = id
}
func TestProbeResultsRepoMultipleResults(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
itemID := createTestBatchItem(t, store, batchID)
for _, p := range []ProbeResult{
{BatchItemID: itemID, ProbeType: "account_smoke", Status: "passed", SummaryJSON: `{"ok":true}`},
{BatchItemID: itemID, ProbeType: "model_list", Status: "passed", SummaryJSON: `{"models":["m1"]}`},
} {
store.ProbeResults().Create(context.Background(), p)
}
results, _ := store.ProbeResults().GetByBatchItemID(context.Background(), itemID)
if len(results) != 2 {
t.Fatalf("count = %d, want 2", len(results))
}
}
func TestProbeResultsRepoGetByBatchItemIDEmpty(t *testing.T) {
store := openTestDB(t)
results, _ := store.ProbeResults().GetByBatchItemID(context.Background(), 999)
if len(results) != 0 {
t.Fatalf("count = %d, want 0", len(results))
}
}
func TestProbeResultsRepoValidationErrors(t *testing.T) {
store := openTestDB(t)
for _, tt := range []struct {
name string
probe ProbeResult
}{
{"batch_item_id zero", ProbeResult{ProbeType: "t", Status: "s"}},
{"empty probe_type", ProbeResult{BatchItemID: 1, Status: "s"}},
{"empty status", ProbeResult{BatchItemID: 1, ProbeType: "t"}},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := store.ProbeResults().Create(context.Background(), tt.probe)
if err == nil {
t.Fatal("Create() error = nil")
}
})
}
}
// --- Access Closures Repo Tests ---
func TestAccessClosureRecordsRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
id, err := store.AccessClosures().Create(context.Background(), AccessClosureRecord{
BatchID: batchID, ClosureType: "subscription", Status: "subscription_ready", DetailsJSON: `{"status_code":200}`,
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
records, _ := store.AccessClosures().GetByBatchID(context.Background(), batchID)
if len(records) != 1 || records[0].ClosureType != "subscription" {
t.Fatalf("records = %+v, want 1 subscription", records)
}
_ = id
}
func TestAccessClosureRecordsRepoMultiple(t *testing.T) {
store := openTestDB(t)
batchID := createTestBatch(t, store)
store.AccessClosures().Create(context.Background(), AccessClosureRecord{BatchID: batchID, ClosureType: "subscription", Status: "subscription_ready", DetailsJSON: "{}"})
store.AccessClosures().Create(context.Background(), AccessClosureRecord{BatchID: batchID, ClosureType: "self_service", Status: "self_service_ready", DetailsJSON: "{}"})
records, _ := store.AccessClosures().GetByBatchID(context.Background(), batchID)
if len(records) != 2 {
t.Fatalf("count = %d, want 2", len(records))
}
}
func TestAccessClosureRecordsRepoGetByBatchIDEmpty(t *testing.T) {
store := openTestDB(t)
records, _ := store.AccessClosures().GetByBatchID(context.Background(), 999)
if len(records) != 0 {
t.Fatalf("count = %d, want 0", len(records))
}
}
func TestAccessClosureRecordsRepoValidation(t *testing.T) {
store := openTestDB(t)
_, err := store.AccessClosures().Create(context.Background(), AccessClosureRecord{BatchID: 0, ClosureType: "t", Status: "s"})
if err == nil {
t.Fatal("Create batch_id=0 error = nil")
}
}
// --- Reconcile Runs Repo Tests ---
func createTestProviderWithPack(t *testing.T, store *DB, packID int64) int64 {
t.Helper()
id, err := store.Providers().Create(context.Background(), Provider{
PackID: packID, ProviderID: "test-provider-" + sanitizeTestName(t.Name()), DisplayName: "TP",
BaseURL: "https://tp.com", Platform: "openai",
})
if err != nil {
t.Fatalf("createTestProviderWithPack error = %v", err)
}
return id
}
func createTestProvider(t *testing.T, store *DB) int64 {
t.Helper()
packID := createTestPack(t, store)
return createTestProviderWithPack(t, store, packID)
}
func TestReconcileRunsRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
providerID := createTestProvider(t, store)
id, err := store.ReconcileRuns().Create(context.Background(), ReconcileRun{
ProviderID: providerID, Status: "active", SummaryJSON: `{"drifted":false}`,
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
runs, _ := store.ReconcileRuns().GetByProviderID(context.Background(), providerID)
if len(runs) != 1 || runs[0].Status != "active" {
t.Fatalf("runs = %+v, want 1 active", runs)
}
_ = id
}
func TestReconcileRunsRepoMultipleRunsOrderedDesc(t *testing.T) {
store := openTestDB(t)
providerID := createTestProvider(t, store)
id1, _ := store.ReconcileRuns().Create(context.Background(), ReconcileRun{ProviderID: providerID, Status: "first", SummaryJSON: "{}"})
id2, _ := store.ReconcileRuns().Create(context.Background(), ReconcileRun{ProviderID: providerID, Status: "second", SummaryJSON: "{}"})
runs, _ := store.ReconcileRuns().GetByProviderID(context.Background(), providerID)
if len(runs) != 2 || runs[0].ID != id2 || runs[1].ID != id1 {
t.Fatalf("order: got %d, %d; want %d, %d (DESC)", runs[0].ID, runs[1].ID, id2, id1)
}
}
func TestReconcileRunsRepoGetByProviderIDEmpty(t *testing.T) {
store := openTestDB(t)
runs, _ := store.ReconcileRuns().GetByProviderID(context.Background(), 999)
if len(runs) != 0 {
t.Fatalf("count = %d, want 0", len(runs))
}
}
func TestReconcileRunsRepoValidation(t *testing.T) {
store := openTestDB(t)
_, err := store.ReconcileRuns().Create(context.Background(), ReconcileRun{ProviderID: 0, Status: "s"})
if err == nil {
t.Fatal("Create provider_id=0 error = nil")
}
}

View File

@@ -0,0 +1,76 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type ManagedResource struct {
ID int64
BatchID int64
ResourceType string
HostResourceID string
ResourceName string
}
type ManagedResourcesRepo struct {
db execQuerier
}
func newManagedResourcesRepo(db execQuerier) *ManagedResourcesRepo {
return &ManagedResourcesRepo{db: db}
}
func (r *ManagedResourcesRepo) Create(ctx context.Context, resource ManagedResource) (int64, error) {
resourceType := strings.TrimSpace(resource.ResourceType)
hostResourceID := strings.TrimSpace(resource.HostResourceID)
resourceName := strings.TrimSpace(resource.ResourceName)
switch {
case resource.BatchID <= 0:
return 0, fmt.Errorf("batch_id is required")
case resourceType == "":
return 0, fmt.Errorf("resource_type is required")
case hostResourceID == "":
return 0, fmt.Errorf("host_resource_id is required")
case resourceName == "":
return 0, fmt.Errorf("resource_name is required")
}
result, err := r.db.ExecContext(ctx, `INSERT INTO managed_resources (batch_id, resource_type, host_resource_id, resource_name) VALUES (?, ?, ?, ?)`, resource.BatchID, resourceType, hostResourceID, resourceName)
if err != nil {
return 0, fmt.Errorf("insert managed resource %q: %w", hostResourceID, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted managed resource id for %q: %w", hostResourceID, err)
}
return id, nil
}
func (r *ManagedResourcesRepo) GetByBatchID(ctx context.Context, batchID int64) ([]ManagedResource, error) {
if batchID <= 0 {
return nil, fmt.Errorf("batch_id is required")
}
rows, err := r.db.QueryContext(ctx, `SELECT id, batch_id, resource_type, host_resource_id, resource_name FROM managed_resources WHERE batch_id = ? ORDER BY id`, batchID)
if err != nil {
return nil, fmt.Errorf("query managed resources: %w", err)
}
defer rows.Close()
resources := make([]ManagedResource, 0)
for rows.Next() {
var resource ManagedResource
if err := rows.Scan(&resource.ID, &resource.BatchID, &resource.ResourceType, &resource.HostResourceID, &resource.ResourceName); err != nil {
return nil, fmt.Errorf("scan managed resource: %w", err)
}
resources = append(resources, resource)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate managed resources: %w", err)
}
return resources, nil
}

View File

@@ -7,9 +7,15 @@ import (
)
type Pack struct {
PackID string
Version string
Checksum string
ID int64
PackID string
Version string
Checksum string
Vendor string
TargetHost string
MinHostVersion string
MaxHostVersion string
ManifestJSON string
}
type PacksRepo struct {
@@ -20,10 +26,59 @@ func newPacksRepo(db execQuerier) *PacksRepo {
return &PacksRepo{db: db}
}
func (r *PacksRepo) GetByID(ctx context.Context, id int64) (Pack, error) {
if id <= 0 {
return Pack{}, fmt.Errorf("id is required")
}
var pack Pack
if err := r.db.QueryRowContext(ctx, `SELECT id, pack_id, version, checksum, vendor, target_host, min_host_version, max_host_version, manifest_json FROM packs WHERE id = ?`, id).Scan(
&pack.ID,
&pack.PackID,
&pack.Version,
&pack.Checksum,
&pack.Vendor,
&pack.TargetHost,
&pack.MinHostVersion,
&pack.MaxHostVersion,
&pack.ManifestJSON,
); err != nil {
return Pack{}, err
}
return pack, nil
}
func (r *PacksRepo) GetByPackID(ctx context.Context, packID string) (Pack, error) {
packID = strings.TrimSpace(packID)
if packID == "" {
return Pack{}, fmt.Errorf("pack_id is required")
}
var pack Pack
if err := r.db.QueryRowContext(ctx, `SELECT id, pack_id, version, checksum, vendor, target_host, min_host_version, max_host_version, manifest_json FROM packs WHERE pack_id = ?`, packID).Scan(
&pack.ID,
&pack.PackID,
&pack.Version,
&pack.Checksum,
&pack.Vendor,
&pack.TargetHost,
&pack.MinHostVersion,
&pack.MaxHostVersion,
&pack.ManifestJSON,
); err != nil {
return Pack{}, err
}
return pack, nil
}
func (r *PacksRepo) Create(ctx context.Context, pack Pack) (int64, error) {
packID := strings.TrimSpace(pack.PackID)
version := strings.TrimSpace(pack.Version)
checksum := strings.TrimSpace(pack.Checksum)
manifestJSON := strings.TrimSpace(pack.ManifestJSON)
if manifestJSON == "" {
manifestJSON = "{}"
}
switch {
case packID == "":
@@ -36,11 +91,16 @@ func (r *PacksRepo) Create(ctx context.Context, pack Pack) (int64, error) {
result, err := r.db.ExecContext(
ctx,
`INSERT INTO packs (pack_id, version, checksum)
VALUES (?, ?, ?)`,
`INSERT INTO packs (pack_id, version, checksum, vendor, target_host, min_host_version, max_host_version, manifest_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
packID,
version,
checksum,
strings.TrimSpace(pack.Vendor),
strings.TrimSpace(pack.TargetHost),
strings.TrimSpace(pack.MinHostVersion),
strings.TrimSpace(pack.MaxHostVersion),
manifestJSON,
)
if err != nil {
return 0, fmt.Errorf("insert pack %q: %w", packID, err)
@@ -50,6 +110,62 @@ func (r *PacksRepo) Create(ctx context.Context, pack Pack) (int64, error) {
if err != nil {
return 0, fmt.Errorf("read inserted pack id for %q: %w", packID, err)
}
return id, nil
}
func (r *PacksRepo) Upsert(ctx context.Context, pack Pack) (int64, error) {
packID := strings.TrimSpace(pack.PackID)
version := strings.TrimSpace(pack.Version)
checksum := strings.TrimSpace(pack.Checksum)
manifestJSON := strings.TrimSpace(pack.ManifestJSON)
if manifestJSON == "" {
manifestJSON = "{}"
}
switch {
case packID == "":
return 0, fmt.Errorf("pack_id is required")
case version == "":
return 0, fmt.Errorf("version is required")
case checksum == "":
return 0, fmt.Errorf("checksum is required")
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO packs (pack_id, version, checksum, vendor, target_host, min_host_version, max_host_version, manifest_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pack_id) DO UPDATE SET
version = excluded.version,
checksum = excluded.checksum,
vendor = excluded.vendor,
target_host = excluded.target_host,
min_host_version = excluded.min_host_version,
max_host_version = excluded.max_host_version,
manifest_json = excluded.manifest_json`,
packID,
version,
checksum,
strings.TrimSpace(pack.Vendor),
strings.TrimSpace(pack.TargetHost),
strings.TrimSpace(pack.MinHostVersion),
strings.TrimSpace(pack.MaxHostVersion),
manifestJSON,
)
if err != nil {
return 0, fmt.Errorf("upsert pack %q: %w", packID, err)
}
id, err := result.LastInsertId()
if err == nil && id > 0 {
return id, nil
}
persisted, getErr := r.GetByPackID(ctx, packID)
if getErr != nil {
if err != nil {
return 0, fmt.Errorf("read upserted pack %q: %w", packID, getErr)
}
return 0, getErr
}
return persisted.ID, nil
}

View File

@@ -0,0 +1,159 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"testing"
)
func TestPacksRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
id, err := store.Packs().Create(context.Background(), Pack{
PackID: "test-pack",
Version: "1.0.0",
Checksum: "abc123",
Vendor: "test-vendor",
TargetHost: "sub2api",
MinHostVersion: "0.1.0",
MaxHostVersion: "0.2.x",
ManifestJSON: `{"name":"test"}`,
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if id <= 0 {
t.Fatalf("Create() id = %d, want positive", id)
}
got, err := store.Packs().GetByID(context.Background(), id)
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if got.PackID != "test-pack" || got.Version != "1.0.0" {
t.Fatalf("GetByID() = %+v, want pack test-pack", got)
}
got2, err := store.Packs().GetByPackID(context.Background(), "test-pack")
if err != nil {
t.Fatalf("GetByPackID() error = %v", err)
}
if got2.ID != id {
t.Fatalf("GetByPackID() id = %d, want %d", got2.ID, id)
}
}
func TestPacksRepoCreateDefaultsManifestJSON(t *testing.T) {
store := openTestDB(t)
id, err := store.Packs().Create(context.Background(), Pack{
PackID: "no-manifest",
Version: "1.0.0",
Checksum: "chk",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
got, err := store.Packs().GetByID(context.Background(), id)
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if got.ManifestJSON != "{}" {
t.Fatalf("ManifestJSON = %q, want {}", got.ManifestJSON)
}
}
func TestPacksRepoUpsertCreatesNew(t *testing.T) {
store := openTestDB(t)
id, err := store.Packs().Upsert(context.Background(), Pack{
PackID: "upsert-pack",
Version: "1.0.0",
Checksum: "chk1",
})
if err != nil {
t.Fatalf("Upsert() error = %v", err)
}
if id <= 0 {
t.Fatalf("Upsert() id = %d, want positive", id)
}
}
func TestPacksRepoUpsertUpdatesExisting(t *testing.T) {
store := openTestDB(t)
id1, err := store.Packs().Upsert(context.Background(), Pack{
PackID: "upsert-pack",
Version: "1.0.0",
Checksum: "chk1",
})
if err != nil {
t.Fatalf("Upsert() create error = %v", err)
}
id2, err := store.Packs().Upsert(context.Background(), Pack{
PackID: "upsert-pack",
Version: "2.0.0",
Checksum: "chk2",
})
if err != nil {
t.Fatalf("Upsert() update error = %v", err)
}
if id2 != id1 {
t.Fatalf("Upsert() update returned id %d, want original %d", id2, id1)
}
got, err := store.Packs().GetByID(context.Background(), id1)
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if got.Version != "2.0.0" {
t.Fatalf("Version after upsert = %q, want 2.0.0", got.Version)
}
}
func TestPacksRepoValidationErrors(t *testing.T) {
store := openTestDB(t)
tests := []struct {
name string
pack Pack
}{
{"empty pack_id", Pack{Version: "v", Checksum: "c"}},
{"empty version", Pack{PackID: "p", Checksum: "c"}},
{"empty checksum", Pack{PackID: "p", Version: "v"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := store.Packs().Create(context.Background(), tt.pack)
if err == nil {
t.Fatal("Create() error = nil, want validation error")
}
})
}
}
func TestPacksRepoGetByIDNotFound(t *testing.T) {
store := openTestDB(t)
_, err := store.Packs().GetByID(context.Background(), 999)
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetByID(999) error = %v, want sql.ErrNoRows", err)
}
}
func TestPacksRepoGetByPackIDEmptyError(t *testing.T) {
store := openTestDB(t)
_, err := store.Packs().GetByPackID(context.Background(), "")
if err == nil {
t.Fatal("GetByPackID('') error = nil, want error")
}
}
func TestPacksRepoGetByPackIDNotFound(t *testing.T) {
store := openTestDB(t)
_, err := store.Packs().GetByPackID(context.Background(), "nonexistent")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetByPackID() error = %v, want sql.ErrNoRows", err)
}
}

View File

@@ -0,0 +1,77 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type ProbeResult struct {
ID int64
BatchItemID int64
ProbeType string
Status string
SummaryJSON string
}
type ProbeResultsRepo struct {
db execQuerier
}
func newProbeResultsRepo(db execQuerier) *ProbeResultsRepo {
return &ProbeResultsRepo{db: db}
}
func (r *ProbeResultsRepo) Create(ctx context.Context, probe ProbeResult) (int64, error) {
probeType := strings.TrimSpace(probe.ProbeType)
status := strings.TrimSpace(probe.Status)
summaryJSON := strings.TrimSpace(probe.SummaryJSON)
if summaryJSON == "" {
summaryJSON = "{}"
}
switch {
case probe.BatchItemID <= 0:
return 0, fmt.Errorf("batch_item_id is required")
case probeType == "":
return 0, fmt.Errorf("probe_type is required")
case status == "":
return 0, fmt.Errorf("status is required")
}
result, err := r.db.ExecContext(ctx, `INSERT INTO probe_results (batch_item_id, probe_type, status, summary_json) VALUES (?, ?, ?, ?)`, probe.BatchItemID, probeType, status, summaryJSON)
if err != nil {
return 0, fmt.Errorf("insert probe result: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted probe result id: %w", err)
}
return id, nil
}
func (r *ProbeResultsRepo) GetByBatchItemID(ctx context.Context, batchItemID int64) ([]ProbeResult, error) {
if batchItemID <= 0 {
return nil, fmt.Errorf("batch_item_id is required")
}
rows, err := r.db.QueryContext(ctx, `SELECT id, batch_item_id, probe_type, status, summary_json FROM probe_results WHERE batch_item_id = ? ORDER BY id`, batchItemID)
if err != nil {
return nil, fmt.Errorf("query probe results: %w", err)
}
defer rows.Close()
probes := make([]ProbeResult, 0)
for rows.Next() {
var probe ProbeResult
if err := rows.Scan(&probe.ID, &probe.BatchItemID, &probe.ProbeType, &probe.Status, &probe.SummaryJSON); err != nil {
return nil, fmt.Errorf("scan probe result: %w", err)
}
probes = append(probes, probe)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate probe results: %w", err)
}
return probes, nil
}

View File

@@ -7,11 +7,20 @@ import (
)
type Provider struct {
PackID int64
ProviderID string
DisplayName string
BaseURL string
Platform string
ID int64
PackID int64
ProviderID string
DisplayName string
BaseURL string
Platform string
AccountType string
DefaultModelsJSON string
SmokeTestModel string
GroupTemplateJSON string
ChannelTemplateJSON string
PlanTemplateJSON string
ImportOptionsJSON string
ManifestJSON string
}
type ProvidersRepo struct {
@@ -22,11 +31,87 @@ func newProvidersRepo(db execQuerier) *ProvidersRepo {
return &ProvidersRepo{db: db}
}
func (r *ProvidersRepo) ListByProviderID(ctx context.Context, providerID string) ([]Provider, error) {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
return nil, fmt.Errorf("provider_id is required")
}
rows, err := r.db.QueryContext(ctx, `SELECT id, pack_id, provider_id, display_name, base_url, platform, account_type, default_models_json, smoke_test_model, group_template_json, channel_template_json, plan_template_json, import_options_json, manifest_json FROM providers WHERE provider_id = ? ORDER BY id`, providerID)
if err != nil {
return nil, fmt.Errorf("query providers by provider_id %q: %w", providerID, err)
}
defer rows.Close()
providers := make([]Provider, 0)
for rows.Next() {
var provider Provider
if err := rows.Scan(
&provider.ID,
&provider.PackID,
&provider.ProviderID,
&provider.DisplayName,
&provider.BaseURL,
&provider.Platform,
&provider.AccountType,
&provider.DefaultModelsJSON,
&provider.SmokeTestModel,
&provider.GroupTemplateJSON,
&provider.ChannelTemplateJSON,
&provider.PlanTemplateJSON,
&provider.ImportOptionsJSON,
&provider.ManifestJSON,
); err != nil {
return nil, fmt.Errorf("scan provider by provider_id %q: %w", providerID, err)
}
providers = append(providers, provider)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate providers by provider_id %q: %w", providerID, err)
}
return providers, nil
}
func (r *ProvidersRepo) GetByPackIDAndProviderID(ctx context.Context, packID int64, providerID string) (Provider, error) {
if packID <= 0 {
return Provider{}, fmt.Errorf("pack_id is required")
}
providerID = strings.TrimSpace(providerID)
if providerID == "" {
return Provider{}, fmt.Errorf("provider_id is required")
}
var provider Provider
if err := r.db.QueryRowContext(ctx, `SELECT id, pack_id, provider_id, display_name, base_url, platform, account_type, default_models_json, smoke_test_model, group_template_json, channel_template_json, plan_template_json, import_options_json, manifest_json FROM providers WHERE pack_id = ? AND provider_id = ?`, packID, providerID).Scan(
&provider.ID,
&provider.PackID,
&provider.ProviderID,
&provider.DisplayName,
&provider.BaseURL,
&provider.Platform,
&provider.AccountType,
&provider.DefaultModelsJSON,
&provider.SmokeTestModel,
&provider.GroupTemplateJSON,
&provider.ChannelTemplateJSON,
&provider.PlanTemplateJSON,
&provider.ImportOptionsJSON,
&provider.ManifestJSON,
); err != nil {
return Provider{}, err
}
return provider, nil
}
func (r *ProvidersRepo) Create(ctx context.Context, provider Provider) (int64, error) {
providerID := strings.TrimSpace(provider.ProviderID)
displayName := strings.TrimSpace(provider.DisplayName)
baseURL := strings.TrimSpace(provider.BaseURL)
platform := strings.TrimSpace(provider.Platform)
manifestJSON := strings.TrimSpace(provider.ManifestJSON)
if manifestJSON == "" {
manifestJSON = "{}"
}
switch {
case provider.PackID <= 0:
@@ -43,13 +128,21 @@ func (r *ProvidersRepo) Create(ctx context.Context, provider Provider) (int64, e
result, err := r.db.ExecContext(
ctx,
`INSERT INTO providers (pack_id, provider_id, display_name, base_url, platform)
VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO providers (pack_id, provider_id, display_name, base_url, platform, account_type, default_models_json, smoke_test_model, group_template_json, channel_template_json, plan_template_json, import_options_json, manifest_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
provider.PackID,
providerID,
displayName,
baseURL,
platform,
strings.TrimSpace(provider.AccountType),
defaultJSONArray(provider.DefaultModelsJSON),
strings.TrimSpace(provider.SmokeTestModel),
defaultJSONObject(provider.GroupTemplateJSON),
defaultJSONObject(provider.ChannelTemplateJSON),
defaultJSONObject(provider.PlanTemplateJSON),
defaultJSONObject(provider.ImportOptionsJSON),
manifestJSON,
)
if err != nil {
return 0, fmt.Errorf("insert provider %q: %w", providerID, err)
@@ -59,6 +152,90 @@ func (r *ProvidersRepo) Create(ctx context.Context, provider Provider) (int64, e
if err != nil {
return 0, fmt.Errorf("read inserted provider id for %q: %w", providerID, err)
}
return id, nil
}
func (r *ProvidersRepo) Upsert(ctx context.Context, provider Provider) (int64, error) {
providerID := strings.TrimSpace(provider.ProviderID)
displayName := strings.TrimSpace(provider.DisplayName)
baseURL := strings.TrimSpace(provider.BaseURL)
platform := strings.TrimSpace(provider.Platform)
manifestJSON := strings.TrimSpace(provider.ManifestJSON)
if manifestJSON == "" {
manifestJSON = "{}"
}
switch {
case provider.PackID <= 0:
return 0, fmt.Errorf("pack_id is required")
case providerID == "":
return 0, fmt.Errorf("provider_id is required")
case displayName == "":
return 0, fmt.Errorf("display_name is required")
case baseURL == "":
return 0, fmt.Errorf("base_url is required")
case platform == "":
return 0, fmt.Errorf("platform is required")
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO providers (pack_id, provider_id, display_name, base_url, platform, account_type, default_models_json, smoke_test_model, group_template_json, channel_template_json, plan_template_json, import_options_json, manifest_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pack_id, provider_id) DO UPDATE SET
display_name = excluded.display_name,
base_url = excluded.base_url,
platform = excluded.platform,
account_type = excluded.account_type,
default_models_json = excluded.default_models_json,
smoke_test_model = excluded.smoke_test_model,
group_template_json = excluded.group_template_json,
channel_template_json = excluded.channel_template_json,
plan_template_json = excluded.plan_template_json,
import_options_json = excluded.import_options_json,
manifest_json = excluded.manifest_json`,
provider.PackID,
providerID,
displayName,
baseURL,
platform,
strings.TrimSpace(provider.AccountType),
defaultJSONArray(provider.DefaultModelsJSON),
strings.TrimSpace(provider.SmokeTestModel),
defaultJSONObject(provider.GroupTemplateJSON),
defaultJSONObject(provider.ChannelTemplateJSON),
defaultJSONObject(provider.PlanTemplateJSON),
defaultJSONObject(provider.ImportOptionsJSON),
manifestJSON,
)
if err != nil {
return 0, fmt.Errorf("upsert provider %q: %w", providerID, err)
}
id, err := result.LastInsertId()
if err == nil && id > 0 {
return id, nil
}
persisted, getErr := r.GetByPackIDAndProviderID(ctx, provider.PackID, providerID)
if getErr != nil {
if err != nil {
return 0, fmt.Errorf("read upserted provider %q: %w", providerID, getErr)
}
return 0, getErr
}
return persisted.ID, nil
}
func defaultJSONObject(value string) string {
if strings.TrimSpace(value) == "" {
return "{}"
}
return value
}
func defaultJSONArray(value string) string {
if strings.TrimSpace(value) == "" {
return "[]"
}
return value
}

View File

@@ -0,0 +1,172 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"testing"
)
func TestProvidersRepoCreateAndGet(t *testing.T) {
store := openTestDB(t)
packID := createTestPack(t, store)
providerID, err := store.Providers().Create(context.Background(), Provider{
PackID: packID,
ProviderID: "deepseek",
DisplayName: "DeepSeek",
BaseURL: "https://api.deepseek.com",
Platform: "openai",
AccountType: "api",
SmokeTestModel: "deepseek-chat",
ManifestJSON: `{"models":["deepseek-chat"]}`,
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if providerID <= 0 {
t.Fatalf("Create() id = %d, want positive", providerID)
}
got, err := store.Providers().GetByPackIDAndProviderID(context.Background(), packID, "deepseek")
if err != nil {
t.Fatalf("GetByPackIDAndProviderID() error = %v", err)
}
if got.ProviderID != "deepseek" || got.DisplayName != "DeepSeek" {
t.Fatalf("GetByPackIDAndProviderID() = %+v, want deepseek", got)
}
}
func TestProvidersRepoListByProviderID(t *testing.T) {
store := openTestDB(t)
packID1 := createTestPackWithSuffix(t, store, "a")
packID2 := createTestPackWithSuffix(t, store, "b")
store.Providers().Create(context.Background(), Provider{PackID: packID1, ProviderID: "deepseek", DisplayName: "DS1", BaseURL: "https://a.com", Platform: "openai"})
store.Providers().Create(context.Background(), Provider{PackID: packID2, ProviderID: "deepseek", DisplayName: "DS2", BaseURL: "https://b.com", Platform: "openai"})
providers, err := store.Providers().ListByProviderID(context.Background(), "deepseek")
if err != nil {
t.Fatalf("ListByProviderID() error = %v", err)
}
if len(providers) != 2 {
t.Fatalf("ListByProviderID() count = %d, want 2", len(providers))
}
}
func createTestPackWithSuffix(t *testing.T, store *DB, suffix string) int64 {
t.Helper()
id, err := store.Packs().Create(context.Background(), Pack{
PackID: "pack-" + sanitizeTestName(t.Name()) + "-" + suffix, Version: "1.0.0", Checksum: "chk",
})
if err != nil {
t.Fatalf("createTestPackWithSuffix error = %v", err)
}
return id
}
func TestProvidersRepoListByProviderIDEmpty(t *testing.T) {
store := openTestDB(t)
providers, err := store.Providers().ListByProviderID(context.Background(), "nonexistent")
if err != nil {
t.Fatalf("ListByProviderID() error = %v", err)
}
if len(providers) != 0 {
t.Fatalf("ListByProviderID() count = %d, want 0", len(providers))
}
}
func TestProvidersRepoUpsertCreatesNew(t *testing.T) {
store := openTestDB(t)
packID := createTestPack(t, store)
id, err := store.Providers().Upsert(context.Background(), Provider{
PackID: packID, ProviderID: "upsert-p", DisplayName: "P", BaseURL: "https://u.com", Platform: "openai",
})
if err != nil {
t.Fatalf("Upsert() error = %v", err)
}
if id <= 0 {
t.Fatalf("Upsert() id = %d, want positive", id)
}
}
func TestProvidersRepoUpsertUpdatesExisting(t *testing.T) {
store := openTestDB(t)
packID := createTestPack(t, store)
id1, _ := store.Providers().Upsert(context.Background(), Provider{
PackID: packID, ProviderID: "upsert-p", DisplayName: "P1", BaseURL: "https://u1.com", Platform: "openai",
})
id2, _ := store.Providers().Upsert(context.Background(), Provider{
PackID: packID, ProviderID: "upsert-p", DisplayName: "P2", BaseURL: "https://u2.com", Platform: "openai",
})
if id2 != id1 {
t.Fatalf("Upsert update id = %d, want %d", id2, id1)
}
got, _ := store.Providers().GetByPackIDAndProviderID(context.Background(), packID, "upsert-p")
if got.DisplayName != "P2" {
t.Fatalf("DisplayName after upsert = %q, want P2", got.DisplayName)
}
}
func TestProvidersRepoValidationErrors(t *testing.T) {
store := openTestDB(t)
packID := createTestPack(t, store)
tests := []struct {
name string
provider Provider
}{
{"pack_id zero", Provider{ProviderID: "p", DisplayName: "d", BaseURL: "b", Platform: "openai"}},
{"empty provider_id", Provider{PackID: packID, DisplayName: "d", BaseURL: "b", Platform: "openai"}},
{"empty display_name", Provider{PackID: packID, ProviderID: "p", BaseURL: "b", Platform: "openai"}},
{"empty base_url", Provider{PackID: packID, ProviderID: "p", DisplayName: "d", Platform: "openai"}},
{"empty platform", Provider{PackID: packID, ProviderID: "p", DisplayName: "d", BaseURL: "b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := store.Providers().Create(context.Background(), tt.provider)
if err == nil {
t.Fatal("Create() error = nil, want validation error")
}
})
}
}
func TestProvidersRepoGetByPackIDAndProviderIDNotFound(t *testing.T) {
store := openTestDB(t)
_, err := store.Providers().GetByPackIDAndProviderID(context.Background(), 999, "p")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("GetByPackIDAndProviderID() error = %v, want sql.ErrNoRows", err)
}
}
func TestProvidersRepoGetByPackIDEmptyError(t *testing.T) {
store := openTestDB(t)
_, err := store.Providers().GetByPackIDAndProviderID(context.Background(), 0, "p")
if err == nil {
t.Fatal("GetByPackIDAndProviderID with packID=0 error = nil, want error")
}
}
func TestDefaultJSONObject(t *testing.T) {
if got := defaultJSONObject(""); got != "{}" {
t.Fatalf("defaultJSONObject('') = %q, want {}", got)
}
if got := defaultJSONObject(`{"a":1}`); got != `{"a":1}` {
t.Fatalf("defaultJSONObject() = %q, want input", got)
}
}
func TestDefaultJSONArray(t *testing.T) {
if got := defaultJSONArray(""); got != "[]" {
t.Fatalf("defaultJSONArray('') = %q, want []", got)
}
if got := defaultJSONArray(`["a"]`); got != `["a"]` {
t.Fatalf("defaultJSONArray() = %q, want input", got)
}
}

View File

@@ -0,0 +1,73 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type ReconcileRun struct {
ID int64
ProviderID int64
Status string
SummaryJSON string
}
type ReconcileRunsRepo struct {
db execQuerier
}
func newReconcileRunsRepo(db execQuerier) *ReconcileRunsRepo {
return &ReconcileRunsRepo{db: db}
}
func (r *ReconcileRunsRepo) Create(ctx context.Context, run ReconcileRun) (int64, error) {
status := strings.TrimSpace(run.Status)
summaryJSON := strings.TrimSpace(run.SummaryJSON)
if summaryJSON == "" {
summaryJSON = "{}"
}
switch {
case run.ProviderID <= 0:
return 0, fmt.Errorf("provider_id is required")
case status == "":
return 0, fmt.Errorf("status is required")
}
result, err := r.db.ExecContext(ctx, `INSERT INTO reconcile_runs (provider_id, status, summary_json) VALUES (?, ?, ?)`, run.ProviderID, status, summaryJSON)
if err != nil {
return 0, fmt.Errorf("insert reconcile run: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted reconcile run id: %w", err)
}
return id, nil
}
func (r *ReconcileRunsRepo) GetByProviderID(ctx context.Context, providerID int64) ([]ReconcileRun, error) {
if providerID <= 0 {
return nil, fmt.Errorf("provider_id is required")
}
rows, err := r.db.QueryContext(ctx, `SELECT id, provider_id, status, summary_json FROM reconcile_runs WHERE provider_id = ? ORDER BY id DESC`, providerID)
if err != nil {
return nil, fmt.Errorf("query reconcile runs: %w", err)
}
defer rows.Close()
runs := make([]ReconcileRun, 0)
for rows.Next() {
var run ReconcileRun
if err := rows.Scan(&run.ID, &run.ProviderID, &run.Status, &run.SummaryJSON); err != nil {
return nil, fmt.Errorf("scan reconcile run: %w", err)
}
runs = append(runs, run)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate reconcile runs: %w", err)
}
return runs, nil
}