- add batch-scoped reconcile_runs persistence and queries - route batch detail and reconcile writes through batch_id/host_id - refresh production boards with host-scope acceptance artifacts - include latest real-host acceptance evidence for self_service and subscription
540 lines
15 KiB
Go
540 lines
15 KiB
Go
package integration_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sub2api-cn-relay-manager/internal/host/sub2api"
|
|
)
|
|
|
|
func TestSub2APIHostAdapterGetHostVersion(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"), sub2api.WithBearerToken("bearer-token"))
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
version, err := client.GetHostVersion(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetHostVersion() error = %v", err)
|
|
}
|
|
|
|
if version != "0.1.126" {
|
|
t.Fatalf("version = %q, want %q", version, "0.1.126")
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterProbeCapabilities(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
caps, err := client.ProbeCapabilities(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ProbeCapabilities() error = %v", err)
|
|
}
|
|
|
|
if !caps.Groups || !caps.Channels || !caps.Plans || !caps.Accounts || !caps.AccountTest || !caps.AccountModels || !caps.Subscriptions {
|
|
t.Fatalf("ProbeCapabilities() = %+v, want all capabilities true", caps)
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterMapsUnauthorizedErrors(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
_, err = client.GetHostVersion(context.Background())
|
|
if err == nil {
|
|
t.Fatal("GetHostVersion() error = nil, want 401 error")
|
|
}
|
|
|
|
var httpErr *sub2api.HTTPError
|
|
if !errors.As(err, &httpErr) {
|
|
t.Fatalf("GetHostVersion() error type = %T, want *sub2api.HTTPError", err)
|
|
}
|
|
|
|
if httpErr.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("StatusCode = %d, want %d", httpErr.StatusCode, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterMapsNotFoundErrors(t *testing.T) {
|
|
server := httptest.NewServer(http.NotFoundHandler())
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
_, err = client.GetHostVersion(context.Background())
|
|
if err == nil {
|
|
t.Fatal("GetHostVersion() error = nil, want 404 error")
|
|
}
|
|
|
|
var httpErr *sub2api.HTTPError
|
|
if !errors.As(err, &httpErr) {
|
|
t.Fatalf("GetHostVersion() error type = %T, want *sub2api.HTTPError", err)
|
|
}
|
|
|
|
if httpErr.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("StatusCode = %d, want %d", httpErr.StatusCode, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterCreateGroup(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"), sub2api.WithBearerToken("bearer-token"))
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
group, err := client.CreateGroup(context.Background(), sub2api.CreateGroupRequest{
|
|
Name: "relay-group",
|
|
RateMultiplier: 1.5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateGroup() error = %v", err)
|
|
}
|
|
|
|
if group.ID != "group_1" {
|
|
t.Fatalf("group.ID = %q, want %q", group.ID, "group_1")
|
|
}
|
|
|
|
if group.Name != "relay-group" {
|
|
t.Fatalf("group.Name = %q, want %q", group.Name, "relay-group")
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterTestAccountParsesSSE(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
result, err := client.TestAccount(context.Background(), "account_1")
|
|
if err != nil {
|
|
t.Fatalf("TestAccount() error = %v", err)
|
|
}
|
|
|
|
if !result.OK {
|
|
t.Fatal("TestAccount() OK = false, want true")
|
|
}
|
|
|
|
if result.Status != "passed" {
|
|
t.Fatalf("TestAccount() Status = %q, want %q", result.Status, "passed")
|
|
}
|
|
|
|
if result.Message != "smoke passed" {
|
|
t.Fatalf("TestAccount() Message = %q, want %q", result.Message, "smoke passed")
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterGetAccountModelsParsesEnvelope(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
models, err := client.GetAccountModels(context.Background(), "account_1")
|
|
if err != nil {
|
|
t.Fatalf("GetAccountModels() error = %v", err)
|
|
}
|
|
|
|
if len(models) != 2 {
|
|
t.Fatalf("len(models) = %d, want 2", len(models))
|
|
}
|
|
|
|
if models[0].ID != "deepseek-chat" || models[0].DisplayName != "DeepSeek Chat" || models[0].Type != "chat" {
|
|
t.Fatalf("first model = %+v, want id/display_name/type from envelope", models[0])
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterChecksGatewayAccess(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
result, err := client.CheckGatewayAccess(context.Background(), sub2api.GatewayAccessCheckRequest{
|
|
APIKey: "user-api-key",
|
|
ExpectedModel: "deepseek-chat",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CheckGatewayAccess() error = %v", err)
|
|
}
|
|
if !result.OK || !result.HasExpectedModel {
|
|
t.Fatalf("CheckGatewayAccess() = %+v, want ok with expected model", result)
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterDeletesManagedResources(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
if err := client.DeleteAccount(context.Background(), "account_1"); err != nil {
|
|
t.Fatalf("DeleteAccount() error = %v", err)
|
|
}
|
|
if err := client.DeleteChannel(context.Background(), "channel_1"); err != nil {
|
|
t.Fatalf("DeleteChannel() error = %v", err)
|
|
}
|
|
if err := client.DeleteGroup(context.Background(), "group_1"); err != nil {
|
|
t.Fatalf("DeleteGroup() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSub2APIHostAdapterListManagedResources(t *testing.T) {
|
|
server := newSub2APIStubServer(t, sub2APIStubConfig{
|
|
requireAPIKey: true,
|
|
version: "0.1.126",
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := sub2api.NewClient(server.URL, sub2api.WithAPIKey("api-key"))
|
|
if err != nil {
|
|
t.Fatalf("NewClient() error = %v", err)
|
|
}
|
|
|
|
snapshot, err := client.ListManagedResources(context.Background(), sub2api.ListManagedResourcesRequest{
|
|
GroupName: "crm-deepseek-group",
|
|
ChannelName: "crm-deepseek-channel",
|
|
PlanName: "crm-deepseek-plan",
|
|
AccountNamePrefix: "deepseek-",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListManagedResources() error = %v", err)
|
|
}
|
|
|
|
if len(snapshot.Groups) != 1 || snapshot.Groups[0].ID != "group_1" {
|
|
t.Fatalf("Groups = %+v, want one group_1 match", snapshot.Groups)
|
|
}
|
|
if len(snapshot.Channels) != 2 || snapshot.Channels[0].ID != "channel_1" || snapshot.Channels[1].ID != "channel_2" {
|
|
t.Fatalf("Channels = %+v, want two matching channels", snapshot.Channels)
|
|
}
|
|
if len(snapshot.Plans) != 1 || snapshot.Plans[0].ID != "plan_1" {
|
|
t.Fatalf("Plans = %+v, want one plan_1 match", snapshot.Plans)
|
|
}
|
|
if len(snapshot.Accounts) != 2 || snapshot.Accounts[0].ID != "account_1" || snapshot.Accounts[1].ID != "account_2" {
|
|
t.Fatalf("Accounts = %+v, want two deepseek account matches", snapshot.Accounts)
|
|
}
|
|
}
|
|
|
|
type sub2APIStubConfig struct {
|
|
requireAPIKey bool
|
|
version string
|
|
}
|
|
|
|
func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server {
|
|
t.Helper()
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/admin/system/version", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": map[string]any{
|
|
"version": cfg.version,
|
|
},
|
|
})
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/groups", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": []map[string]any{
|
|
{"id": "group_1", "name": "crm-deepseek-group"},
|
|
{"id": "group_2", "name": "other-group"},
|
|
},
|
|
})
|
|
case http.MethodPost:
|
|
var payload map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
|
if payload["name"] == "" || payload["rate_multiplier"] == nil {
|
|
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
|
return
|
|
}
|
|
writeJSON(t, w, http.StatusCreated, map[string]any{
|
|
"data": map[string]any{
|
|
"id": "group_1",
|
|
"name": payload["name"],
|
|
},
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/groups/", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
if r.Method != http.MethodDelete {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/channels", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": []map[string]any{
|
|
{"id": "channel_1", "name": "crm-deepseek-channel"},
|
|
{"id": "channel_2", "name": "crm-deepseek-channel"},
|
|
{"id": "channel_3", "name": "other-channel"},
|
|
},
|
|
})
|
|
case http.MethodPost:
|
|
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/channels/", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
if r.Method != http.MethodDelete {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/payment/plans", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": []map[string]any{
|
|
{"id": "plan_1", "name": "crm-deepseek-plan"},
|
|
{"id": "plan_2", "name": "other-plan"},
|
|
},
|
|
})
|
|
case http.MethodPost:
|
|
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/accounts", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": []map[string]any{
|
|
{"id": "account_1", "name": "deepseek-01"},
|
|
{"id": "account_2", "name": "deepseek-02"},
|
|
{"id": "account_3", "name": "other-01"},
|
|
},
|
|
})
|
|
case http.MethodPost:
|
|
writeJSON(t, w, http.StatusUnprocessableEntity, map[string]any{"error": "validation failed"})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/accounts/batch", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": []map[string]any{
|
|
{"id": "account_1"},
|
|
{"id": "account_2"},
|
|
},
|
|
})
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/accounts/", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/admin/accounts/"), "/")
|
|
if len(parts) == 1 {
|
|
if r.Method != http.MethodDelete {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if len(parts) != 2 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
accountID, action := parts[0], parts[1]
|
|
switch action {
|
|
case "test":
|
|
if r.Method == http.MethodGet {
|
|
writeJSON(t, w, http.StatusOK, map[string]any{"data": map[string]any{"supported": true}})
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, "event: result\n")
|
|
fmt.Fprintf(w, "data: {\"status\":\"passed\",\"message\":\"smoke passed\",\"ok\":true,\"account_id\":\"%s\"}\n\n", accountID)
|
|
case "models":
|
|
if r.Method != http.MethodGet {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": map[string]any{
|
|
"items": []map[string]any{
|
|
{"id": "deepseek-chat", "display_name": "DeepSeek Chat", "type": "chat"},
|
|
{"id": "deepseek-reasoner", "display_name": "DeepSeek Reasoner", "type": "reasoning"},
|
|
},
|
|
},
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
mux.HandleFunc("/api/v1/admin/subscriptions/assign", func(w http.ResponseWriter, r *http.Request) {
|
|
if !mustStubAuth(t, w, r, cfg.requireAPIKey) {
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet {
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": map[string]any{"supported": true},
|
|
})
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": map[string]any{
|
|
"id": "subscription_1",
|
|
},
|
|
})
|
|
})
|
|
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("x-api-key"); got != "user-api-key" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
|
return
|
|
}
|
|
writeJSON(t, w, http.StatusOK, map[string]any{
|
|
"data": []map[string]any{
|
|
{"id": "deepseek-chat"},
|
|
{"id": "deepseek-reasoner"},
|
|
},
|
|
})
|
|
})
|
|
|
|
return httptest.NewServer(mux)
|
|
}
|
|
|
|
func mustStubAuth(t *testing.T, w http.ResponseWriter, r *http.Request, requireAPIKey bool) bool {
|
|
t.Helper()
|
|
|
|
if !requireAPIKey {
|
|
return true
|
|
}
|
|
|
|
if got := r.Header.Get("x-api-key"); got == "api-key" {
|
|
if r.Header.Get("Authorization") != "" {
|
|
t.Fatalf("Authorization header = %q, want empty when x-api-key is configured", r.Header.Get("Authorization"))
|
|
}
|
|
return true
|
|
}
|
|
|
|
if got := r.Header.Get("Authorization"); got == "Bearer bearer-token" {
|
|
return true
|
|
}
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
|
return false
|
|
}
|
|
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, statusCode int, payload any) {
|
|
t.Helper()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
|
t.Fatalf("json.Encode() error = %v", err)
|
|
}
|
|
}
|