chore: sync local project state
This commit is contained in:
229
internal/httpapi/admission_state_api_test.go
Normal file
229
internal/httpapi/admission_state_api_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"supply-intelligence/internal/admission"
|
||||
"supply-intelligence/internal/discovery"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/gatewayconsumer"
|
||||
"supply-intelligence/internal/probe"
|
||||
"supply-intelligence/internal/publish"
|
||||
"supply-intelligence/internal/repository"
|
||||
)
|
||||
|
||||
func TestAdmissionStateEndpointReturnsCurrentCandidateAndPackageTruth(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{
|
||||
CandidateID: "cand-1",
|
||||
AccountID: 301,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
Source: "manual_seed",
|
||||
Status: domain.DiscoveryCandidateStatusDiscovered,
|
||||
ReasonCode: "earlier_state",
|
||||
DiscoveredAt: time.Unix(90, 0).UTC(),
|
||||
UpdatedAt: time.Unix(90, 0).UTC(),
|
||||
Version: 1,
|
||||
})
|
||||
repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{
|
||||
CandidateID: "cand-2",
|
||||
AccountID: 301,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
Source: "manual_seed",
|
||||
Status: domain.DiscoveryCandidateStatusTestPassed,
|
||||
ReasonCode: "ready_for_package",
|
||||
DiscoveredAt: time.Unix(100, 0).UTC(),
|
||||
UpdatedAt: time.Unix(110, 0).UTC(),
|
||||
Version: 2,
|
||||
})
|
||||
repo.UpsertSupplyPackage(nil, domain.SupplyPackage{
|
||||
PackageID: 9,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
Status: "draft",
|
||||
Source: "manual_seed",
|
||||
})
|
||||
_, _ = repo.AppendPackageEventContext(nil, domain.PackageChangeEvent{
|
||||
EventID: "evt-other-newer",
|
||||
EventType: publish.PackagePublishedEventType,
|
||||
PackageID: 10,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1",
|
||||
OccurredAt: time.Unix(130, 0).UTC(),
|
||||
Version: 1,
|
||||
GatewaySyncStatus: domain.GatewaySyncStatusFailed,
|
||||
})
|
||||
_, _ = repo.AppendPackageEventContext(nil, domain.PackageChangeEvent{
|
||||
EventID: "evt-old",
|
||||
EventType: publish.PackagePublishedEventType,
|
||||
PackageID: 9,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
OccurredAt: time.Unix(100, 0).UTC(),
|
||||
Version: 1,
|
||||
GatewaySyncStatus: domain.GatewaySyncStatusPending,
|
||||
})
|
||||
_, _ = repo.AppendPackageEventContext(nil, domain.PackageChangeEvent{
|
||||
EventID: "evt-latest",
|
||||
EventType: publish.PackagePublishedEventType,
|
||||
PackageID: 9,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
OccurredAt: time.Unix(120, 0).UTC(),
|
||||
Version: 2,
|
||||
GatewaySyncStatus: domain.GatewaySyncStatusApplied,
|
||||
})
|
||||
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.MethodGet, "/internal/supply-intelligence/models/openai/gpt-4.1-mini/admission-state", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected implemented admission-state endpoint, got status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
Candidate *domain.DiscoveryCandidate `json:"candidate"`
|
||||
Package *domain.SupplyPackage `json:"package"`
|
||||
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
||||
LastEvent *domain.PackageChangeEvent `json:"last_event"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Candidate == nil || body.Candidate.CandidateID != "cand-2" || body.Candidate.Status != domain.DiscoveryCandidateStatusTestPassed {
|
||||
t.Fatalf("expected latest candidate truth, got %+v", body.Candidate)
|
||||
}
|
||||
if body.Package == nil || body.Package.Status != "draft" {
|
||||
t.Fatalf("expected package truth, got %+v", body.Package)
|
||||
}
|
||||
if body.LastEvent == nil || body.LastEvent.EventID != "evt-latest" {
|
||||
t.Fatalf("expected latest matching event truth, got %+v", body.LastEvent)
|
||||
}
|
||||
if body.GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("expected gateway sync status from latest matching event, got %q", body.GatewaySyncStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionStateEndpointReflectsPublishTransitionAndAck(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{
|
||||
CandidateID: "cand-publish",
|
||||
AccountID: 401,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
Source: "manual_seed",
|
||||
Status: domain.DiscoveryCandidateStatusTestPassed,
|
||||
DiscoveredAt: time.Unix(100, 0).UTC(),
|
||||
UpdatedAt: time.Unix(110, 0).UTC(),
|
||||
Version: 2,
|
||||
})
|
||||
repo.UpsertSupplyPackage(nil, domain.SupplyPackage{
|
||||
PackageID: 21,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
Status: "draft",
|
||||
Source: "manual_seed",
|
||||
UpdatedAt: time.Unix(110, 0).UTC(),
|
||||
Version: 1,
|
||||
})
|
||||
publishService := publish.NewService(repo)
|
||||
if _, err := publishService.PublishDraft(nil, publish.PublishDraftInput{EventID: "evt-publish", Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(120, 0).UTC()}); err != nil {
|
||||
t.Fatalf("publish draft: %v", err)
|
||||
}
|
||||
server := NewServer(repo, probe.NewService(repo), publishService, gatewayconsumer.NewService(repo), nil, discovery.NewService(repo), admission.NewService(nil, nil, []admission.TestSuite{}, nil, nil), nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/models/openai/gpt-4.1-mini/admission-state", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Candidate *domain.DiscoveryCandidate `json:"candidate"`
|
||||
Package *domain.SupplyPackage `json:"package"`
|
||||
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Candidate == nil || body.Candidate.Status != domain.DiscoveryCandidateStatusPublished {
|
||||
t.Fatalf("expected published candidate, got %+v", body.Candidate)
|
||||
}
|
||||
if body.Package == nil || body.Package.Status != "active" {
|
||||
t.Fatalf("expected active package, got %+v", body.Package)
|
||||
}
|
||||
if body.GatewaySyncStatus != domain.GatewaySyncStatusPending {
|
||||
t.Fatalf("expected pending sync status, got %q", body.GatewaySyncStatus)
|
||||
}
|
||||
|
||||
_, err := repo.AckPackageEvent(nil, "evt-publish", "gateway", domain.GatewayAckResultApplied, "ok", time.Unix(130, 0).UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("ack event: %v", err)
|
||||
}
|
||||
ackedReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/models/openai/gpt-4.1-mini/admission-state", nil)
|
||||
ackedRR := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(ackedRR, ackedReq)
|
||||
var ackedBody struct {
|
||||
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
||||
}
|
||||
if err := json.NewDecoder(ackedRR.Body).Decode(&ackedBody); err != nil {
|
||||
t.Fatalf("decode acked response: %v", err)
|
||||
}
|
||||
if ackedBody.GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("expected applied sync status after ack, got %q", ackedBody.GatewaySyncStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionStateEndpointOmitsForeignLatestEvent(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.UpsertSupplyPackage(nil, domain.SupplyPackage{
|
||||
PackageID: 9,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1-mini",
|
||||
Status: "draft",
|
||||
Source: "manual_seed",
|
||||
})
|
||||
_, _ = repo.AppendPackageEventContext(nil, domain.PackageChangeEvent{
|
||||
EventID: "evt-only-other-model",
|
||||
EventType: publish.PackagePublishedEventType,
|
||||
PackageID: 10,
|
||||
Platform: "openai",
|
||||
Model: "gpt-4.1",
|
||||
OccurredAt: time.Unix(130, 0).UTC(),
|
||||
Version: 1,
|
||||
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.MethodGet, "/internal/supply-intelligence/models/openai/gpt-4.1-mini/admission-state", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected implemented admission-state endpoint, got status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
||||
LastEvent *domain.PackageChangeEvent `json:"last_event"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.LastEvent != nil {
|
||||
t.Fatalf("expected no last event for unrelated latest event, got %+v", body.LastEvent)
|
||||
}
|
||||
if body.GatewaySyncStatus != "" {
|
||||
t.Fatalf("expected empty gateway sync status without matching event, got %q", body.GatewaySyncStatus)
|
||||
}
|
||||
}
|
||||
277
internal/httpapi/dashboard.go
Normal file
277
internal/httpapi/dashboard.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/repository"
|
||||
)
|
||||
|
||||
// DashboardHandler handles external-facing dashboard UI endpoints.
|
||||
type DashboardHandler struct {
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
// NewDashboardHandler creates a dashboard handler backed by the given repository.
|
||||
func NewDashboardHandler(repo repository.Repository) *DashboardHandler {
|
||||
return &DashboardHandler{repo: repo}
|
||||
}
|
||||
|
||||
// accountRow is a denormalized row for the accounts dashboard table.
|
||||
type accountRow struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
RoutingEnabled bool `json:"routing_enabled"`
|
||||
RiskScore int `json:"risk_score"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
LastProbeAt string `json:"last_probe_at"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
// modelRow is a denormalized row for the model catalog.
|
||||
type modelRow struct {
|
||||
PackageID int64 `json:"package_id"`
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// candidateRow is a denormalized row for the candidate management table.
|
||||
type candidateRow struct {
|
||||
CandidateID string `json:"candidate_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
DiscoveredAt string `json:"discovered_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
// ListAccounts returns all accounts grouped by platform.
|
||||
// GET /internal/supply-intelligence/dashboard/accounts
|
||||
// Query params: platform (optional)
|
||||
func (h *DashboardHandler) ListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
platform := r.URL.Query().Get("platform")
|
||||
var states []domain.AccountRoutingState
|
||||
if platform != "" {
|
||||
states = h.repo.ListRoutingStatesByPlatform(r.Context(), platform)
|
||||
} else {
|
||||
// No ListAllRoutingStates — use openai as default for now
|
||||
states = h.repo.ListRoutingStatesByPlatform(r.Context(), "openai")
|
||||
// TODO: batch for all known platforms
|
||||
}
|
||||
|
||||
rows := make([]accountRow, 0, len(states))
|
||||
for _, s := range states {
|
||||
rows = append(rows, accountRow{
|
||||
AccountID: s.AccountID,
|
||||
Platform: s.Platform,
|
||||
AccountStatus: string(s.AccountStatus),
|
||||
RoutingEnabled: s.RoutingEnabled,
|
||||
RiskScore: s.RiskScore,
|
||||
ReasonCode: s.ReasonCode,
|
||||
LastProbeAt: s.LastProbeAt.Format("2006-01-02T15:04:05Z"),
|
||||
Version: s.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// Group by platform for summary view
|
||||
byPlatform := make(map[string][]accountRow)
|
||||
for _, row := range rows {
|
||||
byPlatform[row.Platform] = append(byPlatform[row.Platform], row)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": rows,
|
||||
"by_platform": byPlatform,
|
||||
"total": len(rows),
|
||||
})
|
||||
}
|
||||
|
||||
// ListModels returns the model catalog from supply packages.
|
||||
// GET /internal/supply-intelligence/dashboard/models
|
||||
// Query params: status (optional: draft, active, deprecated)
|
||||
func (h *DashboardHandler) ListModels(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
pkgs := h.repo.ListSupplyPackages(r.Context(), status)
|
||||
|
||||
rows := make([]modelRow, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
rows = append(rows, modelRow{
|
||||
PackageID: p.PackageID,
|
||||
Platform: p.Platform,
|
||||
Model: p.Model,
|
||||
Status: p.Status,
|
||||
Source: p.Source,
|
||||
Version: p.Version,
|
||||
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
// Group by platform for summary
|
||||
byPlatform := make(map[string][]modelRow)
|
||||
for _, row := range rows {
|
||||
byPlatform[row.Platform] = append(byPlatform[row.Platform], row)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": rows,
|
||||
"by_platform": byPlatform,
|
||||
"total": len(rows),
|
||||
})
|
||||
}
|
||||
|
||||
// ListCandidates returns discovery candidates for management UI.
|
||||
// GET /internal/supply-intelligence/dashboard/candidates
|
||||
// Query params: status (optional), platform (optional), limit (optional, default 100)
|
||||
func (h *DashboardHandler) ListCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
statusStr := r.URL.Query().Get("status")
|
||||
platform := r.URL.Query().Get("platform")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
var domainStatus domain.DiscoveryCandidateStatus
|
||||
if statusStr != "" {
|
||||
domainStatus = domain.DiscoveryCandidateStatus(statusStr)
|
||||
}
|
||||
|
||||
candidates := h.repo.ListDiscoveryCandidates(r.Context(), domainStatus)
|
||||
|
||||
rows := make([]candidateRow, 0, len(candidates))
|
||||
count := 0
|
||||
for _, c := range candidates {
|
||||
if platform != "" && c.Platform != platform {
|
||||
continue
|
||||
}
|
||||
if limit > 0 && count >= limit {
|
||||
break
|
||||
}
|
||||
rows = append(rows, candidateRow{
|
||||
CandidateID: c.CandidateID,
|
||||
AccountID: c.AccountID,
|
||||
Platform: c.Platform,
|
||||
Model: c.Model,
|
||||
Status: string(c.Status),
|
||||
Source: c.Source,
|
||||
ReasonCode: c.ReasonCode,
|
||||
DiscoveredAt: c.DiscoveredAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
Version: c.Version,
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
// Status summary counts
|
||||
statusCounts := make(map[string]int)
|
||||
for _, c := range candidates {
|
||||
statusCounts[string(c.Status)]++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": rows,
|
||||
"total": len(rows),
|
||||
"status_counts": statusCounts,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProbeHistory returns probe execution history for an account.
|
||||
// GET /internal/supply-intelligence/dashboard/accounts/{account_id}/probe-history
|
||||
// Query params: limit (optional, default 20)
|
||||
func (h *DashboardHandler) GetProbeHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
prefix := "/internal/supply-intelligence/dashboard/accounts/"
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if !strings.HasSuffix(path, "/probe-history") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
accountIDStr := strings.TrimSuffix(path, "/probe-history")
|
||||
var accountID int64
|
||||
if _, err := strconv.ParseInt(accountIDStr, 10, 64); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 20
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := h.repo.ListProbeExecutionLogs(r.Context(), accountID, limit)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed_to_load_logs"})
|
||||
return
|
||||
}
|
||||
|
||||
type probeLogRow struct {
|
||||
LogID int64 `json:"log_id"`
|
||||
Platform string `json:"platform"`
|
||||
ProbeResult string `json:"probe_result"`
|
||||
FailureClass string `json:"failure_class,omitempty"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
LatencyMs int `json:"latency_ms,omitempty"`
|
||||
RiskScore int `json:"risk_score"`
|
||||
EvaluatedTransition string `json:"evaluated_transition"`
|
||||
ExecutedAt string `json:"executed_at"`
|
||||
RequestID string `json:"request_id"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
rows := make([]probeLogRow, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
rows = append(rows, probeLogRow{
|
||||
LogID: l.LogID,
|
||||
Platform: l.Platform,
|
||||
ProbeResult: l.ProbeResult,
|
||||
FailureClass: l.FailureClass,
|
||||
HTTPStatus: l.HTTPStatus,
|
||||
LatencyMs: l.LatencyMs,
|
||||
RiskScore: l.RiskScore,
|
||||
EvaluatedTransition: l.EvaluatedTransition,
|
||||
ExecutedAt: l.ExecutedAt.Format("2006-01-02T15:04:05Z"),
|
||||
RequestID: l.RequestID,
|
||||
Version: l.Version,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": rows, "total": len(rows)})
|
||||
}
|
||||
|
||||
353
internal/httpapi/postgres_e2e_test.go
Normal file
353
internal/httpapi/postgres_e2e_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"supply-intelligence/internal/app"
|
||||
"supply-intelligence/internal/domain"
|
||||
)
|
||||
|
||||
func requireDockerForPostgresE2E(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not installed")
|
||||
}
|
||||
if _, err := exec.LookPath("pg_isready"); err != nil {
|
||||
t.Skip("pg_isready not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func freeTCPPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("allocate free tcp port: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
addr, ok := ln.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected listener addr type: %T", ln.Addr())
|
||||
}
|
||||
return addr.Port
|
||||
}
|
||||
|
||||
func waitForPostgresReady(t *testing.T, port int, user, dbName, containerName string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(45 * time.Second)
|
||||
var lastOut string
|
||||
for time.Now().Before(deadline) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
cmd := exec.CommandContext(ctx, "pg_isready", "-h", "127.0.0.1", "-p", strconv.Itoa(port), "-U", user, "-d", dbName)
|
||||
out, err := cmd.CombinedOutput()
|
||||
cancel()
|
||||
lastOut = strings.TrimSpace(string(out))
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
logs, _ := exec.Command("docker", "logs", containerName).CombinedOutput()
|
||||
t.Fatalf("postgres container did not become ready on port %d within timeout; last pg_isready=%q logs=%s", port, lastOut, string(logs))
|
||||
}
|
||||
|
||||
func newPostgresApplicationForE2E(t *testing.T) *app.Application {
|
||||
t.Helper()
|
||||
requireDockerForPostgresE2E(t)
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve current test file")
|
||||
}
|
||||
projectRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", ".."))
|
||||
migrationsDir := filepath.Join(projectRoot, "migrations")
|
||||
|
||||
hostPort := freeTCPPort(t)
|
||||
containerName := fmt.Sprintf("supply-intelligence-e2e-%d", time.Now().UnixNano())
|
||||
dbName := "supply_intelligence"
|
||||
dbUser := "supply"
|
||||
dbPassword := "supply123"
|
||||
|
||||
runArgs := []string{
|
||||
"run", "-d",
|
||||
"--name", containerName,
|
||||
"-e", "POSTGRES_DB=" + dbName,
|
||||
"-e", "POSTGRES_USER=" + dbUser,
|
||||
"-e", "POSTGRES_PASSWORD=" + dbPassword,
|
||||
"-p", fmt.Sprintf("127.0.0.1:%d:5432", hostPort),
|
||||
"-v", migrationsDir + ":/docker-entrypoint-initdb.d:ro",
|
||||
"postgres:16-alpine",
|
||||
}
|
||||
runCmd := exec.Command("docker", runArgs...)
|
||||
runCmd.Dir = projectRoot
|
||||
if out, err := runCmd.CombinedOutput(); err != nil {
|
||||
t.Skipf("start isolated postgres container failed: %v output=%s", err, string(out))
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
rmCmd := exec.Command("docker", "rm", "-f", containerName)
|
||||
rmCmd.Dir = projectRoot
|
||||
_, _ = rmCmd.CombinedOutput()
|
||||
})
|
||||
|
||||
waitForPostgresReady(t, hostPort, dbUser, dbName, containerName)
|
||||
connString := fmt.Sprintf("postgres://%s:%s@127.0.0.1:%d/%s?sslmode=disable", dbUser, dbPassword, hostPort, dbName)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
application, err := app.NewWithPostgres(ctx, connString)
|
||||
if err != nil {
|
||||
t.Fatalf("connect isolated postgres app: %v", err)
|
||||
}
|
||||
application.GatewayConsumerService.SetConsumer("gateway")
|
||||
if application.GatewayConsumerService == nil {
|
||||
t.Fatal("expected gateway consumer service")
|
||||
}
|
||||
t.Cleanup(application.Close)
|
||||
return application
|
||||
}
|
||||
|
||||
func TestPostgresE2EPublishConsumeAckAdmissionState(t *testing.T) {
|
||||
application := newPostgresApplicationForE2E(t)
|
||||
handler := application.Server.Routes()
|
||||
|
||||
model := fmt.Sprintf("gpt-4.1-e2e-%d", time.Now().UnixNano())
|
||||
candidateID := fmt.Sprintf("cand-e2e-%d", time.Now().UnixNano())
|
||||
eventID := fmt.Sprintf("evt-e2e-%d", time.Now().UnixNano())
|
||||
|
||||
application.Repo.UpsertSupplyAccount(context.Background(), domain.SupplyAccount{
|
||||
AccountID: 8801,
|
||||
Platform: "openai",
|
||||
APIKey: "test-key",
|
||||
ConsumerTag: "gateway",
|
||||
Status: "active",
|
||||
CreatedAt: time.Unix(90, 0).UTC(),
|
||||
UpdatedAt: time.Unix(90, 0).UTC(),
|
||||
})
|
||||
application.Repo.UpsertDiscoveryCandidateContext(context.Background(), domain.DiscoveryCandidate{
|
||||
CandidateID: candidateID,
|
||||
AccountID: 8801,
|
||||
Platform: "openai",
|
||||
Model: model,
|
||||
Source: "admission",
|
||||
Status: domain.DiscoveryCandidateStatusTestPassed,
|
||||
DiscoveredAt: time.Unix(100, 0).UTC(),
|
||||
UpdatedAt: time.Unix(110, 0).UTC(),
|
||||
Version: 2,
|
||||
})
|
||||
application.Repo.UpsertSupplyPackage(context.Background(), domain.SupplyPackage{
|
||||
Platform: "openai",
|
||||
Model: model,
|
||||
Status: "draft",
|
||||
Source: "admission",
|
||||
CreatedAt: time.Unix(100, 0).UTC(),
|
||||
UpdatedAt: time.Unix(110, 0).UTC(),
|
||||
Version: 1,
|
||||
})
|
||||
|
||||
publishReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(fmt.Sprintf(`{"event_id":"%s","platform":"openai","model":"%s","occurred_at":"2026-05-06T20:40:00Z"}`, eventID, model)))
|
||||
publishRR := httptest.NewRecorder()
|
||||
handler.ServeHTTP(publishRR, publishReq)
|
||||
if publishRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected publish status: %d body=%s", publishRR.Code, publishRR.Body.String())
|
||||
}
|
||||
|
||||
consumeReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
||||
consumeRR := httptest.NewRecorder()
|
||||
handler.ServeHTTP(consumeRR, consumeReq)
|
||||
if consumeRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected consume status: %d body=%s", consumeRR.Code, consumeRR.Body.String())
|
||||
}
|
||||
var consumeBody struct {
|
||||
Items []struct {
|
||||
EventID string `json:"event_id"`
|
||||
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
||||
Result domain.GatewayAckResult `json:"result"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(consumeRR.Body).Decode(&consumeBody); err != nil {
|
||||
t.Fatalf("decode consume response: %v", err)
|
||||
}
|
||||
if len(consumeBody.Items) != 1 {
|
||||
t.Fatalf("expected one consumed item, got %+v", consumeBody.Items)
|
||||
}
|
||||
lastConsumed := consumeBody.Items[0]
|
||||
if lastConsumed.EventID != eventID {
|
||||
t.Fatalf("expected consumed event %s, got %+v", eventID, lastConsumed)
|
||||
}
|
||||
if lastConsumed.GatewaySyncStatus != domain.GatewaySyncStatusApplied || lastConsumed.Result != domain.GatewayAckResultApplied {
|
||||
t.Fatalf("expected applied consume result, got %+v", lastConsumed)
|
||||
}
|
||||
|
||||
stateReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/models/openai/"+model+"/admission-state", nil)
|
||||
stateRR := httptest.NewRecorder()
|
||||
handler.ServeHTTP(stateRR, stateReq)
|
||||
if stateRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected admission-state status after consume: %d body=%s", stateRR.Code, stateRR.Body.String())
|
||||
}
|
||||
var stateBody struct {
|
||||
Candidate *domain.DiscoveryCandidate `json:"candidate"`
|
||||
Package *domain.SupplyPackage `json:"package"`
|
||||
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
||||
LastEvent *domain.PackageChangeEvent `json:"last_event"`
|
||||
}
|
||||
if err := json.NewDecoder(stateRR.Body).Decode(&stateBody); err != nil {
|
||||
t.Fatalf("decode admission-state response: %v", err)
|
||||
}
|
||||
if stateBody.Candidate == nil || stateBody.Candidate.Status != domain.DiscoveryCandidateStatusPublished {
|
||||
t.Fatalf("expected published candidate, got %+v", stateBody.Candidate)
|
||||
}
|
||||
if stateBody.Package == nil || stateBody.Package.Status != "active" {
|
||||
t.Fatalf("expected active package, got %+v", stateBody.Package)
|
||||
}
|
||||
if stateBody.LastEvent == nil || stateBody.LastEvent.EventID != eventID {
|
||||
t.Fatalf("expected latest event %s, got %+v", eventID, stateBody.LastEvent)
|
||||
}
|
||||
if stateBody.GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("expected applied sync status after consume, got %q", stateBody.GatewaySyncStatus)
|
||||
}
|
||||
|
||||
ackReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/package-changes/"+eventID+"/ack", bytes.NewBufferString(`{"consumer":"gateway","result":"applied","detail":"manual confirm"}`))
|
||||
ackRR := httptest.NewRecorder()
|
||||
handler.ServeHTTP(ackRR, ackReq)
|
||||
if ackRR.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected ack status: %d body=%s", ackRR.Code, ackRR.Body.String())
|
||||
}
|
||||
|
||||
finalStateReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/models/openai/"+model+"/admission-state", nil)
|
||||
finalStateRR := httptest.NewRecorder()
|
||||
handler.ServeHTTP(finalStateRR, finalStateReq)
|
||||
if finalStateRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected final admission-state status: %d body=%s", finalStateRR.Code, finalStateRR.Body.String())
|
||||
}
|
||||
var finalStateBody struct {
|
||||
Candidate *domain.DiscoveryCandidate `json:"candidate"`
|
||||
Package *domain.SupplyPackage `json:"package"`
|
||||
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
||||
LastEvent *domain.PackageChangeEvent `json:"last_event"`
|
||||
}
|
||||
if err := json.NewDecoder(finalStateRR.Body).Decode(&finalStateBody); err != nil {
|
||||
t.Fatalf("decode final admission-state response: %v", err)
|
||||
}
|
||||
if finalStateBody.GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("expected applied sync status after explicit ack, got %q", finalStateBody.GatewaySyncStatus)
|
||||
}
|
||||
if finalStateBody.LastEvent == nil || finalStateBody.LastEvent.Consumer != "gateway" || finalStateBody.LastEvent.ConsumerDetail != "manual confirm" {
|
||||
t.Fatalf("expected ack details persisted, got %+v", finalStateBody.LastEvent)
|
||||
}
|
||||
|
||||
storedEvent, ok := application.Repo.GetLatestPackageEvent(context.Background(), "openai", model)
|
||||
if !ok {
|
||||
t.Fatal("expected stored package event")
|
||||
}
|
||||
if storedEvent.EventID != eventID || storedEvent.GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("unexpected stored event: %+v", storedEvent)
|
||||
}
|
||||
if storedEvent.AckedAt == nil {
|
||||
t.Fatalf("expected stored ack timestamp, got %+v", storedEvent)
|
||||
}
|
||||
|
||||
storedSnapshot, ok := application.Repo.GetGatewayAppliedSnapshot(context.Background(), "gateway")
|
||||
if !ok {
|
||||
t.Fatal("expected gateway applied snapshot")
|
||||
}
|
||||
if storedSnapshot.LastEventID != eventID || storedSnapshot.LastModel != model || storedSnapshot.LastResult != string(domain.GatewayAckResultApplied) {
|
||||
t.Fatalf("unexpected gateway snapshot: %+v", storedSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresE2EPublishConsumeAckAdmissionStateRequiresAuthorizedConsumer(t *testing.T) {
|
||||
application := newPostgresApplicationForE2E(t)
|
||||
handler := application.Server.Routes()
|
||||
|
||||
model := fmt.Sprintf("gpt-4.1-e2e-unauth-%d", time.Now().UnixNano())
|
||||
candidateID := fmt.Sprintf("cand-e2e-unauth-%d", time.Now().UnixNano())
|
||||
eventID := fmt.Sprintf("evt-e2e-unauth-%d", time.Now().UnixNano())
|
||||
|
||||
application.Repo.UpsertSupplyAccount(context.Background(), domain.SupplyAccount{
|
||||
AccountID: 9901,
|
||||
Platform: "openai",
|
||||
APIKey: "test-key",
|
||||
ConsumerTag: "other-consumer",
|
||||
Status: "active",
|
||||
CreatedAt: time.Unix(90, 0).UTC(),
|
||||
UpdatedAt: time.Unix(90, 0).UTC(),
|
||||
})
|
||||
application.Repo.UpsertDiscoveryCandidateContext(context.Background(), domain.DiscoveryCandidate{
|
||||
CandidateID: candidateID,
|
||||
AccountID: 9901,
|
||||
Platform: "openai",
|
||||
Model: model,
|
||||
Source: "admission",
|
||||
Status: domain.DiscoveryCandidateStatusTestPassed,
|
||||
DiscoveredAt: time.Unix(100, 0).UTC(),
|
||||
UpdatedAt: time.Unix(110, 0).UTC(),
|
||||
Version: 2,
|
||||
})
|
||||
application.Repo.UpsertSupplyPackage(context.Background(), domain.SupplyPackage{
|
||||
Platform: "openai",
|
||||
Model: model,
|
||||
Status: "draft",
|
||||
Source: "admission",
|
||||
CreatedAt: time.Unix(100, 0).UTC(),
|
||||
UpdatedAt: time.Unix(110, 0).UTC(),
|
||||
Version: 1,
|
||||
})
|
||||
|
||||
publishReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(fmt.Sprintf(`{"event_id":"%s","platform":"openai","model":"%s","occurred_at":"2026-05-06T20:45:00Z"}`, eventID, model)))
|
||||
publishRR := httptest.NewRecorder()
|
||||
handler.ServeHTTP(publishRR, publishReq)
|
||||
if publishRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected publish status: %d body=%s", publishRR.Code, publishRR.Body.String())
|
||||
}
|
||||
|
||||
authorizedAccounts := application.Repo.ListSupplyAccountsByConsumer(context.Background(), "gateway")
|
||||
if len(authorizedAccounts) != 0 {
|
||||
t.Fatalf("expected no accounts authorized for gateway, got %+v", authorizedAccounts)
|
||||
}
|
||||
consumeReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
||||
consumeRR := httptest.NewRecorder()
|
||||
handler.ServeHTTP(consumeRR, consumeReq)
|
||||
if consumeRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected consume status: %d body=%s", consumeRR.Code, consumeRR.Body.String())
|
||||
}
|
||||
var consumeBody struct {
|
||||
Items []any `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(consumeRR.Body).Decode(&consumeBody); err != nil {
|
||||
t.Fatalf("decode consume response: %v", err)
|
||||
}
|
||||
if len(consumeBody.Items) != 0 {
|
||||
t.Fatalf("expected unauthorized event to be skipped, got %+v", consumeBody.Items)
|
||||
}
|
||||
|
||||
stateReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/models/openai/"+model+"/admission-state", nil)
|
||||
stateRR := httptest.NewRecorder()
|
||||
handler.ServeHTTP(stateRR, stateReq)
|
||||
if stateRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected admission-state status: %d body=%s", stateRR.Code, stateRR.Body.String())
|
||||
}
|
||||
var stateBody struct {
|
||||
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
|
||||
LastEvent *domain.PackageChangeEvent `json:"last_event"`
|
||||
}
|
||||
if err := json.NewDecoder(stateRR.Body).Decode(&stateBody); err != nil {
|
||||
t.Fatalf("decode admission-state response: %v", err)
|
||||
}
|
||||
if stateBody.GatewaySyncStatus != domain.GatewaySyncStatusPending {
|
||||
t.Fatalf("expected pending sync status when unauthorized consumer skips event, got %q", stateBody.GatewaySyncStatus)
|
||||
}
|
||||
if stateBody.LastEvent == nil || !strings.EqualFold(stateBody.LastEvent.EventID, eventID) {
|
||||
t.Fatalf("expected last event to remain pending, got %+v", stateBody.LastEvent)
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,28 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"supply-intelligence/internal/admission"
|
||||
"supply-intelligence/internal/discovery"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/gatewayconsumer"
|
||||
"supply-intelligence/internal/poller"
|
||||
"supply-intelligence/internal/probe"
|
||||
"supply-intelligence/internal/publish"
|
||||
"supply-intelligence/internal/repository"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
repo *repository.MemoryRepository
|
||||
repo repository.Repository
|
||||
probeService *probe.Service
|
||||
publishService *publish.Service
|
||||
gatewayConsumerService *gatewayconsumer.Service
|
||||
gatewayRuntime *poller.Runtime
|
||||
discoveryService *discovery.Service
|
||||
admissionService *admission.Service
|
||||
discoveryScheduler *discovery.DiscoveryScheduler
|
||||
dashboardHandler *DashboardHandler
|
||||
}
|
||||
|
||||
type packageChangesResponse struct {
|
||||
@@ -35,13 +41,14 @@ type discoveryCandidatesResponse struct {
|
||||
Items []domain.DiscoveryCandidate `json:"items"`
|
||||
}
|
||||
|
||||
func NewServer(repo *repository.MemoryRepository, probeService *probe.Service, publishService *publish.Service, gatewayConsumerService *gatewayconsumer.Service, discoveryService *discovery.Service, admissionService *admission.Service) *Server {
|
||||
return &Server{repo: repo, probeService: probeService, publishService: publishService, gatewayConsumerService: gatewayConsumerService, discoveryService: discoveryService, admissionService: admissionService}
|
||||
func NewServer(repo repository.Repository, probeService *probe.Service, publishService *publish.Service, gatewayConsumerService *gatewayconsumer.Service, gatewayRuntime *poller.Runtime, discoveryService *discovery.Service, admissionService *admission.Service, discoveryScheduler *discovery.DiscoveryScheduler, dashboardHandler *DashboardHandler) *Server {
|
||||
return &Server{repo: repo, probeService: probeService, publishService: publishService, gatewayConsumerService: gatewayConsumerService, gatewayRuntime: gatewayRuntime, discoveryService: discoveryService, admissionService: admissionService, discoveryScheduler: discoveryScheduler, dashboardHandler: dashboardHandler}
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.handleHealth)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/internal/supply-intelligence/accounts/", s.handleGetRoutingState)
|
||||
mux.HandleFunc("/internal/supply-intelligence/probe/evaluate", s.handleEvaluateProbe)
|
||||
mux.HandleFunc("/internal/supply-intelligence/publish/package-event", s.handlePublishPackageEvent)
|
||||
@@ -49,8 +56,24 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/package-changes", s.handleListPackageChanges)
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/package-changes/", s.handleAckPackageChange)
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/consume-once", s.handleConsumeOnce)
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/runtime-status", s.handleGatewayRuntimeStatus)
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/runtime/pause", s.handleGatewayRuntimePause)
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/runtime/resume", s.handleGatewayRuntimeResume)
|
||||
mux.HandleFunc("/internal/supply-intelligence/admission/run", s.handleAdmissionRun)
|
||||
mux.HandleFunc("/internal/supply-intelligence/admission/candidates", s.handleAdmissionCandidates)
|
||||
mux.HandleFunc("/internal/supply-intelligence/models/", s.handleModelAdmissionState)
|
||||
// Dashboard endpoints
|
||||
if s.dashboardHandler != nil {
|
||||
mux.HandleFunc("/internal/supply-intelligence/dashboard/accounts", s.dashboardHandler.ListAccounts)
|
||||
mux.HandleFunc("/internal/supply-intelligence/dashboard/accounts/", s.dashboardHandler.GetProbeHistory)
|
||||
mux.HandleFunc("/internal/supply-intelligence/dashboard/models", s.dashboardHandler.ListModels)
|
||||
mux.HandleFunc("/internal/supply-intelligence/dashboard/candidates", s.dashboardHandler.ListCandidates)
|
||||
}
|
||||
// Discovery scan endpoints
|
||||
if s.discoveryScheduler != nil {
|
||||
mux.HandleFunc("/internal/supply-intelligence/discovery/scan", s.handleDiscoveryScan)
|
||||
mux.HandleFunc("/internal/supply-intelligence/discovery/scan-platform", s.handleDiscoveryScanPlatform)
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -75,7 +98,7 @@ func (s *Server) handleGetRoutingState(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"})
|
||||
return
|
||||
}
|
||||
state, ok := s.repo.GetRoutingState(accountID)
|
||||
state, ok := s.repo.GetRoutingState(r.Context(), accountID)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
@@ -148,10 +171,8 @@ func (s *Server) handlePublishPackageEvent(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
var payload struct {
|
||||
EventID string `json:"event_id"`
|
||||
PackageID int64 `json:"package_id"`
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
Version int64 `json:"version"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
@@ -169,23 +190,30 @@ func (s *Server) handlePublishPackageEvent(w http.ResponseWriter, r *http.Reques
|
||||
occurredAt = parsed
|
||||
}
|
||||
|
||||
event, err := s.publishService.RecordPackagePublished(r.Context(), publish.RecordPackagePublishedInput{
|
||||
out, err := s.publishService.PublishDraft(r.Context(), publish.PublishDraftInput{
|
||||
EventID: payload.EventID,
|
||||
PackageID: payload.PackageID,
|
||||
Platform: payload.Platform,
|
||||
Model: payload.Model,
|
||||
Version: payload.Version,
|
||||
OccurredAt: occurredAt,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, publish.ErrInvalidPublishInput) {
|
||||
switch {
|
||||
case errors.Is(err, publish.ErrInvalidPublishInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_publish_input"})
|
||||
return
|
||||
case errors.Is(err, publish.ErrCandidateOrPackageMissing):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "candidate_or_package_missing"})
|
||||
case errors.Is(err, publish.ErrDuplicatePublishRequest):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "duplicate_publish_request"})
|
||||
case errors.Is(err, publish.ErrPackageAlreadyPublished):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "publish_already_applied"})
|
||||
case errors.Is(err, publish.ErrCandidateNotPublishable), errors.Is(err, publish.ErrPackageNotPublishable):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "publish_precondition_failed"})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, event)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleDiscoveryCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -265,7 +293,15 @@ func parseDiscoveryCandidateStatus(raw string) (domain.DiscoveryCandidateStatus,
|
||||
}
|
||||
status := domain.DiscoveryCandidateStatus(raw)
|
||||
switch status {
|
||||
case domain.DiscoveryCandidateStatusPendingAdmission, domain.DiscoveryCandidateStatusAdmitted, domain.DiscoveryCandidateStatusRejected:
|
||||
case domain.DiscoveryCandidateStatusDiscovered,
|
||||
domain.DiscoveryCandidateStatusTesting,
|
||||
domain.DiscoveryCandidateStatusTestPassed,
|
||||
domain.DiscoveryCandidateStatusTestFailed,
|
||||
domain.DiscoveryCandidateStatusRetryPending,
|
||||
domain.DiscoveryCandidateStatusIgnored,
|
||||
domain.DiscoveryCandidateStatusPublished,
|
||||
domain.DiscoveryCandidateStatusDeprecated,
|
||||
domain.DiscoveryCandidateStatusClosed:
|
||||
return status, true
|
||||
default:
|
||||
return "", false
|
||||
@@ -277,7 +313,7 @@ func (s *Server) handleListPackageChanges(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
items, nextCursor := s.repo.ListPackageEventsAfter(strings.TrimSpace(r.URL.Query().Get("cursor")))
|
||||
items, nextCursor := s.repo.ListPackageEventsAfter(r.Context(), strings.TrimSpace(r.URL.Query().Get("cursor")))
|
||||
writeJSON(w, http.StatusOK, packageChangesResponse{Items: items, NextCursor: nextCursor})
|
||||
}
|
||||
|
||||
@@ -311,7 +347,7 @@ func (s *Server) handleAckPackageChange(w http.ResponseWriter, r *http.Request)
|
||||
if consumer == "" {
|
||||
consumer = "gateway"
|
||||
}
|
||||
_, err := s.repo.AckPackageEvent(eventID, consumer, ackResult, payload.Detail, time.Now().UTC())
|
||||
_, err := s.repo.AckPackageEvent(r.Context(), eventID, consumer, ackResult, payload.Detail, time.Now().UTC())
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrEventNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
@@ -350,6 +386,64 @@ func (s *Server) handleConsumeOnce(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleGatewayRuntimeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.gatewayRuntime == nil || s.repo == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gateway_runtime_unavailable"})
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
status := s.gatewayRuntime.Status()
|
||||
consumer := strings.TrimSpace(r.URL.Query().Get("consumer"))
|
||||
if consumer == "" {
|
||||
consumer = "gateway"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"started": status.Started,
|
||||
"paused": status.Paused,
|
||||
"cursor": status.Cursor,
|
||||
"last_poll_at": status.LastPollAt,
|
||||
"last_error": status.LastError,
|
||||
"pending_retry_events": s.repo.CountRetryablePendingPackageEvents(r.Context(), consumer, now),
|
||||
"failed_events": s.repo.CountPackageEventsBySyncStatus(r.Context(), domain.GatewaySyncStatusFailed),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGatewayRuntimePause(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.gatewayRuntime == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gateway_runtime_unavailable"})
|
||||
return
|
||||
}
|
||||
if !s.gatewayRuntime.Pause() {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "pause_failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"paused": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGatewayRuntimeResume(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.gatewayRuntime == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gateway_runtime_unavailable"})
|
||||
return
|
||||
}
|
||||
if !s.gatewayRuntime.Resume() {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "resume_failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"paused": false})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@@ -395,7 +489,7 @@ func (s *Server) handleAdmissionRun(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleAdmissionCandidates lists candidates pending admission testing
|
||||
// handleAdmissionCandidates lists candidates currently runnable for admission testing
|
||||
func (s *Server) handleAdmissionCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
@@ -410,6 +504,138 @@ func (s *Server) handleAdmissionCandidates(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": candidates})
|
||||
}
|
||||
|
||||
func (s *Server) handleModelAdmissionState(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
prefix := "/internal/supply-intelligence/models/"
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 3 || parts[2] != "admission-state" {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
|
||||
platform := strings.TrimSpace(parts[0])
|
||||
model := strings.TrimSpace(parts[1])
|
||||
if platform == "" || model == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_model_path"})
|
||||
return
|
||||
}
|
||||
|
||||
var candidate *domain.DiscoveryCandidate
|
||||
if latest, ok := s.repo.GetLatestDiscoveryCandidateContext(r.Context(), platform, model); ok {
|
||||
copyCandidate := latest
|
||||
candidate = ©Candidate
|
||||
}
|
||||
|
||||
pkg, hasPackage := s.repo.GetSupplyPackage(r.Context(), platform, model)
|
||||
var lastEvent *domain.PackageChangeEvent
|
||||
if hasPackage {
|
||||
if latestEvent, ok := s.repo.GetLatestPackageEvent(r.Context(), platform, model); ok {
|
||||
copyEvt := latestEvent
|
||||
lastEvent = ©Evt
|
||||
}
|
||||
}
|
||||
gatewaySyncStatus := domain.GatewaySyncStatus("")
|
||||
if lastEvent != nil {
|
||||
gatewaySyncStatus = lastEvent.GatewaySyncStatus
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"platform": platform,
|
||||
"model": model,
|
||||
"candidate": candidate,
|
||||
"package": packageOrNil(hasPackage, pkg),
|
||||
"gateway_sync_status": gatewaySyncStatus,
|
||||
"last_event": lastEvent,
|
||||
})
|
||||
}
|
||||
|
||||
func packageOrNil(ok bool, pkg domain.SupplyPackage) any {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func domainAccountStatus(raw string) domain.AccountStatus {
|
||||
return domain.AccountStatus(raw)
|
||||
}
|
||||
|
||||
// handleDiscoveryScan runs discovery across all registered platforms.
|
||||
// POST /internal/supply-intelligence/discovery/scan
|
||||
func (s *Server) handleDiscoveryScan(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.discoveryScheduler == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "discovery_scheduler_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := s.discoveryScheduler.ScanAllPlatforms(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type scanResultRow struct {
|
||||
Platform string `json:"platform"`
|
||||
NewModels int `json:"new_models"`
|
||||
RemovedModels []string `json:"removed_models,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
rows := make([]scanResultRow, 0, len(results))
|
||||
for _, r := range results {
|
||||
rows = append(rows, scanResultRow{
|
||||
Platform: r.Platform,
|
||||
NewModels: r.NewModels,
|
||||
RemovedModels: r.RemovedModels,
|
||||
Errors: r.Errors,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": rows, "total_platforms": len(results)})
|
||||
}
|
||||
|
||||
// handleDiscoveryScanPlatform runs discovery for a single platform.
|
||||
// POST /internal/supply-intelligence/discovery/scan-platform
|
||||
// Body: {"platform": "openai"}
|
||||
func (s *Server) handleDiscoveryScanPlatform(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.discoveryScheduler == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "discovery_scheduler_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(payload.Platform) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_platform"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.discoveryScheduler.ScanPlatform(r.Context(), payload.Platform)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"platform": result.Platform,
|
||||
"new_models": result.NewModels,
|
||||
"removed_models": result.RemovedModels,
|
||||
"errors": result.Errors,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"supply-intelligence/internal/app"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/probe"
|
||||
)
|
||||
|
||||
func domainTime(ts int64) time.Time {
|
||||
return time.Unix(ts, 0).UTC()
|
||||
}
|
||||
|
||||
func TestApplicationServerRoutes(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
@@ -41,8 +46,10 @@ func TestApplicationServerRoutes(t *testing.T) {
|
||||
|
||||
func TestPublishConsumeOnceListAppliedIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
application.Repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{CandidateID: "cand-integration-1", AccountID: 601, Platform: "openai", Model: "gpt-4.1-mini", Source: "admission", Status: domain.DiscoveryCandidateStatusTestPassed, DiscoveredAt: domainTime(100), UpdatedAt: domainTime(110), Version: 2})
|
||||
application.Repo.UpsertSupplyPackage(nil, domain.SupplyPackage{PackageID: 501, Platform: "openai", Model: "gpt-4.1-mini", Status: "draft", Source: "admission", UpdatedAt: domainTime(110), Version: 1})
|
||||
|
||||
publishReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(`{"event_id":"evt-integration-1","package_id":501,"platform":"openai","model":"gpt-4.1-mini","version":9,"occurred_at":"2026-05-06T20:30:00Z"}`))
|
||||
publishReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(`{"event_id":"evt-integration-1","platform":"openai","model":"gpt-4.1-mini","occurred_at":"2026-05-06T20:30:00Z"}`))
|
||||
publishRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(publishRR, publishReq)
|
||||
if publishRR.Code != http.StatusOK {
|
||||
@@ -72,7 +79,7 @@ func TestPublishConsumeOnceListAppliedIntegration(t *testing.T) {
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].EventID != "evt-integration-1" {
|
||||
t.Fatalf("unexpected list items: %+v", listResp.Items)
|
||||
}
|
||||
if listResp.NextCursor != "1" {
|
||||
if listResp.NextCursor != "" {
|
||||
t.Fatalf("unexpected next cursor: %+v", listResp)
|
||||
}
|
||||
if listResp.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
@@ -82,8 +89,10 @@ func TestPublishConsumeOnceListAppliedIntegration(t *testing.T) {
|
||||
|
||||
func TestPublishConsumeOnceListFailedIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
application.Repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{CandidateID: "cand-integration-failed", AccountID: 602, Platform: "openai", Model: "gpt-fail-model", Source: "admission", Status: domain.DiscoveryCandidateStatusTestPassed, DiscoveredAt: domainTime(100), UpdatedAt: domainTime(110), Version: 2})
|
||||
application.Repo.UpsertSupplyPackage(nil, domain.SupplyPackage{PackageID: 502, Platform: "openai", Model: "gpt-fail-model", Status: "draft", Source: "admission", UpdatedAt: domainTime(110), Version: 1})
|
||||
|
||||
publishReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(`{"event_id":"evt-integration-failed","package_id":502,"platform":"openai","model":"gpt-fail-model","version":10,"occurred_at":"2026-05-06T20:31:00Z"}`))
|
||||
publishReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(`{"event_id":"evt-integration-failed","platform":"openai","model":"gpt-fail-model","occurred_at":"2026-05-06T20:31:00Z"}`))
|
||||
publishRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(publishRR, publishReq)
|
||||
if publishRR.Code != http.StatusOK {
|
||||
@@ -113,7 +122,7 @@ func TestPublishConsumeOnceListFailedIntegration(t *testing.T) {
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].EventID != "evt-integration-failed" {
|
||||
t.Fatalf("unexpected list items: %+v", listResp.Items)
|
||||
}
|
||||
if listResp.NextCursor != "1" {
|
||||
if listResp.NextCursor != "" {
|
||||
t.Fatalf("unexpected next cursor: %+v", listResp)
|
||||
}
|
||||
if listResp.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusFailed {
|
||||
@@ -121,6 +130,54 @@ func TestPublishConsumeOnceListFailedIntegration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishEndpointDuplicateReplayReturnsStableAlreadyApplied(t *testing.T) {
|
||||
application := app.New()
|
||||
application.Repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{CandidateID: "cand-dup-stable", AccountID: 603, Platform: "openai", Model: "gpt-4.1-stable", Source: "admission", Status: domain.DiscoveryCandidateStatusTestPassed, DiscoveredAt: domainTime(100), UpdatedAt: domainTime(110), Version: 2})
|
||||
application.Repo.UpsertSupplyPackage(nil, domain.SupplyPackage{PackageID: 503, Platform: "openai", Model: "gpt-4.1-stable", Status: "draft", Source: "admission", UpdatedAt: domainTime(110), Version: 1})
|
||||
|
||||
body := `{"event_id":"evt-stable-1","platform":"openai","model":"gpt-4.1-stable","occurred_at":"2026-05-06T20:32:00Z"}`
|
||||
firstReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(body))
|
||||
firstRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(firstRR, firstReq)
|
||||
if firstRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected first publish status: %d body=%s", firstRR.Code, firstRR.Body.String())
|
||||
}
|
||||
|
||||
replayReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(body))
|
||||
replayRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(replayRR, replayReq)
|
||||
if replayRR.Code != http.StatusConflict {
|
||||
t.Fatalf("unexpected replay status: %d body=%s", replayRR.Code, replayRR.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(replayRR.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode replay error: %v", err)
|
||||
}
|
||||
if payload["error"] != "publish_already_applied" {
|
||||
t.Fatalf("expected stable replay error publish_already_applied, got %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishEndpointHalfAppliedStateReturnsStableAlreadyApplied(t *testing.T) {
|
||||
application := app.New()
|
||||
application.Repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{CandidateID: "cand-half-state", AccountID: 604, Platform: "openai", Model: "gpt-4.1-half-state", Source: "admission", Status: domain.DiscoveryCandidateStatusPublished, DiscoveredAt: domainTime(100), UpdatedAt: domainTime(110), Version: 2})
|
||||
application.Repo.UpsertSupplyPackage(nil, domain.SupplyPackage{PackageID: 504, Platform: "openai", Model: "gpt-4.1-half-state", Status: "draft", Source: "admission", UpdatedAt: domainTime(110), Version: 1})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(`{"event_id":"evt-half-state","platform":"openai","model":"gpt-4.1-half-state","occurred_at":"2026-05-06T20:33:00Z"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode half-applied error: %v", err)
|
||||
}
|
||||
if payload["error"] != "publish_already_applied" {
|
||||
t.Fatalf("expected stable half-applied error publish_already_applied, got %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryCandidateCreateAndListIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
@@ -131,7 +188,7 @@ func TestDiscoveryCandidateCreateAndListIntegration(t *testing.T) {
|
||||
t.Fatalf("unexpected create status: %d body=%s", createRR.Code, createRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/discovery/candidates?status=pending_admission", nil)
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/discovery/candidates", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(listRR, listReq)
|
||||
if listRR.Code != http.StatusOK {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import "context"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
@@ -8,9 +10,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"supply-intelligence/internal/admission"
|
||||
"supply-intelligence/internal/discovery"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/gatewayconsumer"
|
||||
"supply-intelligence/internal/poller"
|
||||
"supply-intelligence/internal/probe"
|
||||
"supply-intelligence/internal/publish"
|
||||
"supply-intelligence/internal/repository"
|
||||
@@ -18,7 +22,7 @@ import (
|
||||
|
||||
func TestServerRoutingStateEndpoint(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.UpsertRoutingState(domain.AccountRoutingState{
|
||||
repo.UpsertRoutingState(context.Background(), domain.AccountRoutingState{
|
||||
AccountID: 101,
|
||||
Platform: "openai",
|
||||
AccountStatus: domain.AccountStatusActive,
|
||||
@@ -28,7 +32,7 @@ func TestServerRoutingStateEndpoint(t *testing.T) {
|
||||
LastProbeAt: time.Unix(100, 0).UTC(),
|
||||
Version: 3,
|
||||
})
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
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.MethodGet, "/internal/supply-intelligence/accounts/101/routing-state", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -88,7 +92,7 @@ func TestServerProbeEvaluateEndpointPaths(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
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/probe/evaluate", bytes.NewBufferString(tt.body))
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -118,9 +122,21 @@ func TestServerProbeEvaluateEndpointPaths(t *testing.T) {
|
||||
|
||||
func TestServerPublishPackageEventEndpoint(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
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)
|
||||
|
||||
body := bytes.NewBufferString(`{"event_id":"evt-1","package_id":1001,"platform":"openai","model":"gpt-4.1-mini","version":7,"occurred_at":"2026-05-06T20:30:00Z"}`)
|
||||
body := bytes.NewBufferString(`{"event_id":"evt-1","platform":"openai","model":"gpt-4.1-mini","occurred_at":"2026-05-06T20:30:00Z"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", body)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
@@ -128,22 +144,33 @@ func TestServerPublishPackageEventEndpoint(t *testing.T) {
|
||||
t.Fatalf("unexpected publish status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var event domain.PackageChangeEvent
|
||||
if err := json.NewDecoder(rr.Body).Decode(&event); err != nil {
|
||||
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 {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if event.EventID != "evt-1" || event.EventType != publish.PackagePublishedEventType {
|
||||
t.Fatalf("unexpected event: %+v", event)
|
||||
if out.Candidate.Status != domain.DiscoveryCandidateStatusPublished {
|
||||
t.Fatalf("unexpected candidate: %+v", out.Candidate)
|
||||
}
|
||||
if event.GatewaySyncStatus != domain.GatewaySyncStatusPending {
|
||||
t.Fatalf("unexpected sync status: %q", event.GatewaySyncStatus)
|
||||
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)
|
||||
}
|
||||
if out.GatewaySyncStatus != domain.GatewaySyncStatusPending {
|
||||
t.Fatalf("unexpected sync status: %q", out.GatewaySyncStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPackageChangeListAndAck(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.AppendPackageEvent(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), discovery.NewService(repo), nil)
|
||||
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)
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
@@ -158,7 +185,7 @@ func TestServerPackageChangeListAndAck(t *testing.T) {
|
||||
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list error: %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.NextCursor != "1" {
|
||||
if len(listResp.Items) != 1 || listResp.NextCursor != "" {
|
||||
t.Fatalf("unexpected list response: %+v", listResp)
|
||||
}
|
||||
|
||||
@@ -168,19 +195,58 @@ func TestServerPackageChangeListAndAck(t *testing.T) {
|
||||
if ackRR.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected ack status: %d body=%s", ackRR.Code, ackRR.Body.String())
|
||||
}
|
||||
updated, _ := repo.ListPackageEventsAfter("")
|
||||
updated, _ := repo.ListPackageEventsAfter(context.Background(), "")
|
||||
if len(updated) != 1 || updated[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("unexpected ack state: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPackageChangeListWithCursor(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.AppendPackageEvent(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(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), discovery.NewService(repo), nil)
|
||||
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)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes?cursor=1", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes?cursor=evt-1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
@@ -193,16 +259,16 @@ func TestServerPackageChangeListWithCursor(t *testing.T) {
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 || resp.Items[0].EventID != "evt-2" || resp.NextCursor != "2" {
|
||||
if len(resp.Items) != 1 || resp.Items[0].EventID != "evt-2" || resp.NextCursor != "" {
|
||||
t.Fatalf("unexpected cursor response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConsumeOnceEndpoint(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.AppendPackageEvent(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(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), discovery.NewService(repo), nil)
|
||||
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)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -225,9 +291,146 @@ func TestServerConsumeOnceEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDiscoveryCandidateCreateAndList(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
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)
|
||||
|
||||
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()
|
||||
@@ -236,7 +439,7 @@ func TestServerDiscoveryCandidateCreateAndList(t *testing.T) {
|
||||
t.Fatalf("unexpected create status: %d body=%s", createRR.Code, createRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/discovery/candidates?status=pending_admission", nil)
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/discovery/candidates", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(listRR, listReq)
|
||||
if listRR.Code != http.StatusOK {
|
||||
@@ -248,14 +451,14 @@ func TestServerDiscoveryCandidateCreateAndList(t *testing.T) {
|
||||
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list error: %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].CandidateID != "cand-1" || listResp.Items[0].Status != domain.DiscoveryCandidateStatusPendingAdmission {
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].CandidateID != "cand-1" || listResp.Items[0].Status != domain.DiscoveryCandidateStatusDiscovered {
|
||||
t.Fatalf("unexpected discovery list response: %+v", listResp.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDiscoveryCandidateRejectsInvalidInput(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
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/discovery/candidates", bytes.NewBufferString(`{"candidate_id":"","account_id":0}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
Reference in New Issue
Block a user