2026-05-07 10:16:46 +08:00
|
|
|
package httpapi
|
|
|
|
|
|
2026-05-12 18:49:52 +08:00
|
|
|
import "context"
|
|
|
|
|
|
2026-05-07 10:16:46 +08:00
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-05-12 18:49:52 +08:00
|
|
|
"supply-intelligence/internal/admission"
|
2026-05-07 10:16:46 +08:00
|
|
|
"supply-intelligence/internal/discovery"
|
|
|
|
|
"supply-intelligence/internal/domain"
|
|
|
|
|
"supply-intelligence/internal/gatewayconsumer"
|
2026-05-12 18:49:52 +08:00
|
|
|
"supply-intelligence/internal/poller"
|
2026-05-07 10:16:46 +08:00
|
|
|
"supply-intelligence/internal/probe"
|
|
|
|
|
"supply-intelligence/internal/publish"
|
|
|
|
|
"supply-intelligence/internal/repository"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestServerRoutingStateEndpoint(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
2026-05-12 18:49:52 +08:00
|
|
|
repo.UpsertRoutingState(context.Background(), domain.AccountRoutingState{
|
2026-05-07 10:16:46 +08:00
|
|
|
AccountID: 101,
|
|
|
|
|
Platform: "openai",
|
|
|
|
|
AccountStatus: domain.AccountStatusActive,
|
|
|
|
|
RoutingEnabled: true,
|
|
|
|
|
RiskScore: 10,
|
|
|
|
|
ReasonCode: "ok",
|
|
|
|
|
LastProbeAt: time.Unix(100, 0).UTC(),
|
|
|
|
|
Version: 3,
|
|
|
|
|
})
|
2026-05-12 18:49:52 +08:00
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/accounts/101/routing-state", nil)
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var got domain.AccountRoutingState
|
|
|
|
|
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
|
|
|
|
t.Fatalf("decode error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.AccountID != 101 || got.AccountStatus != domain.AccountStatusActive {
|
|
|
|
|
t.Fatalf("unexpected payload: %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerProbeEvaluateEndpointPaths(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
body string
|
|
|
|
|
wantStatus int
|
|
|
|
|
wantClassification domain.ProbeClassification
|
|
|
|
|
wantAccountStatus domain.AccountStatus
|
|
|
|
|
wantReasonCode string
|
|
|
|
|
wantRoutingEnabled bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "success",
|
|
|
|
|
body: `{"account_id":201,"platform":"openai","current_status":"suspended","status_code":200}`,
|
|
|
|
|
wantStatus: http.StatusOK,
|
|
|
|
|
wantClassification: domain.ProbeClassificationSuccess,
|
|
|
|
|
wantAccountStatus: domain.AccountStatusActive,
|
|
|
|
|
wantReasonCode: "ok",
|
|
|
|
|
wantRoutingEnabled: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "explicit_failure",
|
|
|
|
|
body: `{"account_id":202,"platform":"openai","current_status":"active","status_code":401}`,
|
|
|
|
|
wantStatus: http.StatusOK,
|
|
|
|
|
wantClassification: domain.ProbeClassificationExplicitFailure,
|
|
|
|
|
wantAccountStatus: domain.AccountStatusSuspended,
|
|
|
|
|
wantReasonCode: "auth_rejected",
|
|
|
|
|
wantRoutingEnabled: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "inconclusive",
|
|
|
|
|
body: `{"account_id":203,"platform":"openai","current_status":"suspended","transport_error":"dial tcp timeout"}`,
|
|
|
|
|
wantStatus: http.StatusOK,
|
|
|
|
|
wantClassification: domain.ProbeClassificationInconclusive,
|
|
|
|
|
wantAccountStatus: domain.AccountStatusSuspended,
|
|
|
|
|
wantReasonCode: "transport_error",
|
|
|
|
|
wantRoutingEnabled: false,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
2026-05-12 18:49:52 +08:00
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/probe/evaluate", bytes.NewBufferString(tt.body))
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != tt.wantStatus {
|
|
|
|
|
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var got probe.EvaluateOutput
|
|
|
|
|
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
|
|
|
|
t.Fatalf("decode error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.Classification != tt.wantClassification {
|
|
|
|
|
t.Fatalf("unexpected classification: %q", got.Classification)
|
|
|
|
|
}
|
|
|
|
|
if got.RoutingState.AccountStatus != tt.wantAccountStatus {
|
|
|
|
|
t.Fatalf("unexpected account status: %q", got.RoutingState.AccountStatus)
|
|
|
|
|
}
|
|
|
|
|
if got.RoutingState.ReasonCode != tt.wantReasonCode {
|
|
|
|
|
t.Fatalf("unexpected reason code: %q", got.RoutingState.ReasonCode)
|
|
|
|
|
}
|
|
|
|
|
if got.RoutingState.RoutingEnabled != tt.wantRoutingEnabled {
|
|
|
|
|
t.Fatalf("unexpected routing enabled: %v", got.RoutingState.RoutingEnabled)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerPublishPackageEventEndpoint(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
2026-05-12 18:49:52 +08:00
|
|
|
repo.UpsertDiscoveryCandidateContext(context.Background(), domain.DiscoveryCandidate{
|
|
|
|
|
CandidateID: "cand-http-publish",
|
|
|
|
|
AccountID: 501,
|
|
|
|
|
Platform: "openai",
|
|
|
|
|
Model: "gpt-4.1-mini",
|
|
|
|
|
Source: "admission",
|
|
|
|
|
Status: domain.DiscoveryCandidateStatusTestPassed,
|
|
|
|
|
DiscoveredAt: time.Unix(100, 0).UTC(),
|
|
|
|
|
UpdatedAt: time.Unix(110, 0).UTC(),
|
|
|
|
|
Version: 2,
|
|
|
|
|
})
|
|
|
|
|
repo.UpsertSupplyPackage(context.Background(), domain.SupplyPackage{PackageID: 1001, Platform: "openai", Model: "gpt-4.1-mini", Status: "draft", Source: "admission", UpdatedAt: time.Unix(110, 0).UTC(), Version: 1})
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
|
2026-05-12 18:49:52 +08:00
|
|
|
body := bytes.NewBufferString(`{"event_id":"evt-1","platform":"openai","model":"gpt-4.1-mini","occurred_at":"2026-05-06T20:30:00Z"}`)
|
2026-05-07 10:16:46 +08:00
|
|
|
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", body)
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected publish status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 18:49:52 +08:00
|
|
|
var out struct {
|
|
|
|
|
Candidate domain.DiscoveryCandidate `json:"candidate"`
|
|
|
|
|
Package domain.SupplyPackage `json:"package"`
|
|
|
|
|
Event domain.PackageChangeEvent `json:"event"`
|
|
|
|
|
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
2026-05-07 10:16:46 +08:00
|
|
|
t.Fatalf("decode error: %v", err)
|
|
|
|
|
}
|
2026-05-12 18:49:52 +08:00
|
|
|
if out.Candidate.Status != domain.DiscoveryCandidateStatusPublished {
|
|
|
|
|
t.Fatalf("unexpected candidate: %+v", out.Candidate)
|
|
|
|
|
}
|
|
|
|
|
if out.Package.Status != "active" {
|
|
|
|
|
t.Fatalf("unexpected package: %+v", out.Package)
|
|
|
|
|
}
|
|
|
|
|
if out.Event.EventID != "evt-1" || out.Event.EventType != publish.PackagePublishedEventType {
|
|
|
|
|
t.Fatalf("unexpected event: %+v", out.Event)
|
2026-05-07 10:16:46 +08:00
|
|
|
}
|
2026-05-12 18:49:52 +08:00
|
|
|
if out.GatewaySyncStatus != domain.GatewaySyncStatusPending {
|
|
|
|
|
t.Fatalf("unexpected sync status: %q", out.GatewaySyncStatus)
|
2026-05-07 10:16:46 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerPackageChangeListAndAck(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
2026-05-12 18:49:52 +08:00
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-1", EventType: publish.PackagePublishedEventType, PackageID: 1001, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(5, 0).UTC(), Version: 7, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
|
|
|
|
|
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes", nil)
|
|
|
|
|
listRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(listRR, listReq)
|
|
|
|
|
if listRR.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected list status: %d body=%s", listRR.Code, listRR.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var listResp struct {
|
|
|
|
|
Items []domain.PackageChangeEvent `json:"items"`
|
|
|
|
|
NextCursor string `json:"next_cursor"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
|
|
|
|
t.Fatalf("decode list error: %v", err)
|
|
|
|
|
}
|
2026-05-12 18:49:52 +08:00
|
|
|
if len(listResp.Items) != 1 || listResp.NextCursor != "" {
|
2026-05-07 10:16:46 +08:00
|
|
|
t.Fatalf("unexpected list response: %+v", listResp)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ackReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/package-changes/evt-1/ack", bytes.NewBufferString(`{"consumer":"gateway","result":"applied","detail":"ok"}`))
|
|
|
|
|
ackRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(ackRR, ackReq)
|
|
|
|
|
if ackRR.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("unexpected ack status: %d body=%s", ackRR.Code, ackRR.Body.String())
|
|
|
|
|
}
|
2026-05-12 18:49:52 +08:00
|
|
|
updated, _ := repo.ListPackageEventsAfter(context.Background(), "")
|
2026-05-07 10:16:46 +08:00
|
|
|
if len(updated) != 1 || updated[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
|
|
|
|
t.Fatalf("unexpected ack state: %+v", updated)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 18:49:52 +08:00
|
|
|
func TestServerPackageChangeAckMissingEventReturnsNotFound(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
|
|
|
|
|
|
|
|
|
ackReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/package-changes/evt-missing/ack", bytes.NewBufferString(`{"consumer":"gateway","result":"applied","detail":"ok"}`))
|
|
|
|
|
ackRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(ackRR, ackReq)
|
|
|
|
|
if ackRR.Code != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("unexpected ack status: %d body=%s", ackRR.Code, ackRR.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var payload map[string]string
|
|
|
|
|
if err := json.NewDecoder(ackRR.Body).Decode(&payload); err != nil {
|
|
|
|
|
t.Fatalf("decode ack missing error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if payload["error"] != "not_found" {
|
|
|
|
|
t.Fatalf("unexpected ack missing payload: %+v", payload)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerPackageChangeAckRejectsInvalidResult(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-ack-invalid", EventType: publish.PackagePublishedEventType, PackageID: 1003, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(7, 0).UTC(), Version: 9, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
|
|
|
|
|
|
|
|
|
ackReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/package-changes/evt-ack-invalid/ack", bytes.NewBufferString(`{"consumer":"gateway","result":"unknown","detail":"bad"}`))
|
|
|
|
|
ackRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(ackRR, ackReq)
|
|
|
|
|
if ackRR.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("unexpected invalid-result ack status: %d body=%s", ackRR.Code, ackRR.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var payload map[string]string
|
|
|
|
|
if err := json.NewDecoder(ackRR.Body).Decode(&payload); err != nil {
|
|
|
|
|
t.Fatalf("decode invalid-result ack error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if payload["error"] != "invalid_result" {
|
|
|
|
|
t.Fatalf("unexpected invalid-result ack payload: %+v", payload)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 10:16:46 +08:00
|
|
|
func TestServerPackageChangeListWithCursor(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
2026-05-12 18:49:52 +08:00
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-1", EventType: publish.PackagePublishedEventType, PackageID: 1001, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(5, 0).UTC(), Version: 7, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-2", EventType: publish.PackagePublishedEventType, PackageID: 1002, Platform: "openai", Model: "gpt-4.1", OccurredAt: time.Unix(6, 0).UTC(), Version: 8, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
|
2026-05-12 18:49:52 +08:00
|
|
|
req := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes?cursor=evt-1", nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var resp struct {
|
|
|
|
|
Items []domain.PackageChangeEvent `json:"items"`
|
|
|
|
|
NextCursor string `json:"next_cursor"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
|
|
|
t.Fatalf("decode error: %v", err)
|
|
|
|
|
}
|
2026-05-12 18:49:52 +08:00
|
|
|
if len(resp.Items) != 1 || resp.Items[0].EventID != "evt-2" || resp.NextCursor != "" {
|
2026-05-07 10:16:46 +08:00
|
|
|
t.Fatalf("unexpected cursor response: %+v", resp)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerConsumeOnceEndpoint(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
2026-05-12 18:49:52 +08:00
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-apply", EventType: publish.PackagePublishedEventType, PackageID: 1001, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(5, 0).UTC(), Version: 7, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-fail", EventType: publish.PackagePublishedEventType, PackageID: 1002, Platform: "openai", Model: "gpt-fail-model", OccurredAt: time.Unix(6, 0).UTC(), Version: 8, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected consume status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var out gatewayconsumer.ConsumeOnceOutput
|
|
|
|
|
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
|
|
|
|
t.Fatalf("decode error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(out.Items) != 2 {
|
|
|
|
|
t.Fatalf("unexpected consume output length: %+v", out)
|
|
|
|
|
}
|
|
|
|
|
if out.Items[0].Result != domain.GatewayAckResultApplied || out.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied || out.Items[0].Detail == "" {
|
|
|
|
|
t.Fatalf("unexpected first consume item: %+v", out.Items[0])
|
|
|
|
|
}
|
|
|
|
|
if out.Items[1].Result != domain.GatewayAckResultFailed || out.Items[1].GatewaySyncStatus != domain.GatewaySyncStatusFailed || out.Items[1].Detail == "" {
|
|
|
|
|
t.Fatalf("unexpected second consume item: %+v", out.Items[1])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 18:49:52 +08:00
|
|
|
func TestServerConsumeOnceSkipsUnauthorizedAndLeavesPending(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
|
|
|
|
repo.UpsertSupplyAccount(context.Background(), domain.SupplyAccount{AccountID: 2001, Platform: "openai", APIKey: "key-other", ConsumerTag: "other-consumer", Status: "active", CreatedAt: time.Unix(1, 0).UTC(), UpdatedAt: time.Unix(1, 0).UTC()})
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-unauthorized", EventType: publish.PackagePublishedEventType, PackageID: 2001, AccountID: 2001, Platform: "openai", Model: "gpt-4.1-unauthorized", OccurredAt: time.Unix(8, 0).UTC(), Version: 10, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected consume status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var out gatewayconsumer.ConsumeOnceOutput
|
|
|
|
|
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
|
|
|
|
t.Fatalf("decode error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(out.Items) != 0 {
|
|
|
|
|
t.Fatalf("expected unauthorized event to be skipped, got %+v", out.Items)
|
|
|
|
|
}
|
|
|
|
|
items, _ := repo.ListPackageEventsAfter(context.Background(), "")
|
|
|
|
|
if len(items) != 1 || items[0].GatewaySyncStatus != domain.GatewaySyncStatusPending {
|
|
|
|
|
t.Fatalf("expected unauthorized event to remain pending, got %+v", items)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerConsumeOnceSkipsNonPendingEvents(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-applied-existing", EventType: publish.PackagePublishedEventType, PackageID: 2002, Platform: "openai", Model: "gpt-4.1-applied", OccurredAt: time.Unix(9, 0).UTC(), Version: 11, GatewaySyncStatus: domain.GatewaySyncStatusApplied})
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-failed-existing", EventType: publish.PackagePublishedEventType, PackageID: 2003, Platform: "openai", Model: "gpt-4.1-failed-existing", OccurredAt: time.Unix(10, 0).UTC(), Version: 12, GatewaySyncStatus: domain.GatewaySyncStatusFailed})
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected consume status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var out gatewayconsumer.ConsumeOnceOutput
|
|
|
|
|
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
|
|
|
|
t.Fatalf("decode error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(out.Items) != 0 {
|
|
|
|
|
t.Fatalf("expected no items for non-pending events, got %+v", out.Items)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerConsumeOnceFailedDoesNotDriftSnapshot(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-apply-first", EventType: publish.PackagePublishedEventType, PackageID: 2004, Platform: "openai", Model: "gpt-4.1-first", OccurredAt: time.Unix(11, 0).UTC(), Version: 13, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-fail-second", EventType: publish.PackagePublishedEventType, PackageID: 2005, Platform: "openai", Model: "gpt-fail-second", OccurredAt: time.Unix(12, 0).UTC(), Version: 14, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected consume status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
snapshot, ok := repo.GetGatewayAppliedSnapshot(context.Background(), "gateway")
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected gateway snapshot")
|
|
|
|
|
}
|
|
|
|
|
if snapshot.LastEventID != "evt-apply-first" || snapshot.LastPackageID != 2004 || snapshot.LastResult != string(domain.GatewayAckResultApplied) {
|
|
|
|
|
t.Fatalf("expected snapshot to stay on last applied event, got %+v", snapshot)
|
|
|
|
|
}
|
|
|
|
|
items, _ := repo.ListPackageEventsAfter(context.Background(), "")
|
|
|
|
|
statusByID := map[string]domain.GatewaySyncStatus{}
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
statusByID[item.EventID] = item.GatewaySyncStatus
|
|
|
|
|
}
|
|
|
|
|
if statusByID["evt-apply-first"] != domain.GatewaySyncStatusApplied || statusByID["evt-fail-second"] != domain.GatewaySyncStatusFailed {
|
|
|
|
|
t.Fatalf("unexpected event statuses after consume: %+v", statusByID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerGatewayRuntimeStatusReportsCountsAndPauseResumeEndpoints(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
|
|
|
|
nextRetryAt := time.Unix(1, 0).UTC()
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-runtime-retry", EventType: publish.PackagePublishedEventType, PackageID: 3001, Platform: "openai", Model: "gpt-4.1-retry", OccurredAt: time.Unix(20, 0).UTC(), Version: 15, GatewaySyncStatus: domain.GatewaySyncStatusPending, RetryCount: 1, NextRetryAt: &nextRetryAt, LastFailureCategory: domain.GatewayFailureCategoryTemporaryTimeout})
|
|
|
|
|
repo.AppendPackageEvent(context.Background(), domain.PackageChangeEvent{EventID: "evt-runtime-failed", EventType: publish.PackagePublishedEventType, PackageID: 3002, Platform: "openai", Model: "gpt-4.1-failed", OccurredAt: time.Unix(21, 0).UTC(), Version: 16, GatewaySyncStatus: domain.GatewaySyncStatusFailed, LastFailureCategory: domain.GatewayFailureCategoryContractInvalid})
|
|
|
|
|
service := gatewayconsumer.NewService(repo)
|
|
|
|
|
runtime := poller.NewRuntime(poller.NewGatewayPackagePoller(service), time.Second)
|
|
|
|
|
if !runtime.Pause() {
|
|
|
|
|
t.Fatal("expected pause before start to succeed")
|
|
|
|
|
}
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
defer cancel()
|
|
|
|
|
if !runtime.Start(ctx) {
|
|
|
|
|
t.Fatal("expected runtime to start")
|
|
|
|
|
}
|
|
|
|
|
defer runtime.Stop()
|
|
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), service, runtime, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
|
|
|
|
|
|
|
|
|
statusReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/runtime-status", nil)
|
|
|
|
|
statusRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(statusRR, statusReq)
|
|
|
|
|
if statusRR.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected runtime-status status: %d body=%s", statusRR.Code, statusRR.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var statusBody struct {
|
|
|
|
|
Started bool `json:"started"`
|
|
|
|
|
Paused bool `json:"paused"`
|
|
|
|
|
PendingRetryEvents int `json:"pending_retry_events"`
|
|
|
|
|
FailedEvents int `json:"failed_events"`
|
|
|
|
|
LastError string `json:"last_error"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(statusRR.Body).Decode(&statusBody); err != nil {
|
|
|
|
|
t.Fatalf("decode runtime-status response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !statusBody.Started || !statusBody.Paused {
|
|
|
|
|
t.Fatalf("expected started and paused runtime, got %+v", statusBody)
|
|
|
|
|
}
|
|
|
|
|
if statusBody.PendingRetryEvents != 1 || statusBody.FailedEvents != 1 {
|
|
|
|
|
t.Fatalf("unexpected runtime counters: %+v", statusBody)
|
|
|
|
|
}
|
|
|
|
|
if statusBody.LastError != "" {
|
|
|
|
|
t.Fatalf("expected empty last_error, got %+v", statusBody)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pauseReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/runtime/pause", nil)
|
|
|
|
|
pauseRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(pauseRR, pauseReq)
|
|
|
|
|
if pauseRR.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected pause status: %d body=%s", pauseRR.Code, pauseRR.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resumeReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/runtime/resume", nil)
|
|
|
|
|
resumeRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(resumeRR, resumeReq)
|
|
|
|
|
if resumeRR.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected resume status: %d body=%s", resumeRR.Code, resumeRR.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if runtime.Status().Paused {
|
|
|
|
|
t.Fatalf("expected runtime resumed, got %+v", runtime.Status())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 10:16:46 +08:00
|
|
|
func TestServerDiscoveryCandidateCreateAndList(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
2026-05-12 18:49:52 +08:00
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
|
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/discovery/candidates", bytes.NewBufferString(`{"candidate_id":"cand-1","account_id":301,"platform":"openai","model":"gpt-4.1-mini","source":"manual_seed","reason_code":"new_model","discovered_at":"2026-05-06T20:30:00Z"}`))
|
|
|
|
|
createRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(createRR, createReq)
|
|
|
|
|
if createRR.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected create status: %d body=%s", createRR.Code, createRR.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 18:49:52 +08:00
|
|
|
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/discovery/candidates", nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
listRR := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(listRR, listReq)
|
|
|
|
|
if listRR.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unexpected list status: %d body=%s", listRR.Code, listRR.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var listResp struct {
|
|
|
|
|
Items []domain.DiscoveryCandidate `json:"items"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
|
|
|
|
t.Fatalf("decode list error: %v", err)
|
|
|
|
|
}
|
2026-05-12 18:49:52 +08:00
|
|
|
if len(listResp.Items) != 1 || listResp.Items[0].CandidateID != "cand-1" || listResp.Items[0].Status != domain.DiscoveryCandidateStatusDiscovered {
|
2026-05-07 10:16:46 +08:00
|
|
|
t.Fatalf("unexpected discovery list response: %+v", listResp.Items)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServerDiscoveryCandidateRejectsInvalidInput(t *testing.T) {
|
|
|
|
|
repo := repository.NewMemoryRepository()
|
2026-05-12 18:49:52 +08:00
|
|
|
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
2026-05-07 10:16:46 +08:00
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/discovery/candidates", bytes.NewBufferString(`{"candidate_id":"","account_id":0}`))
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
server.Routes().ServeHTTP(rr, req)
|
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|