feat: bootstrap supply intelligence baseline
This commit is contained in:
12
internal/httpapi/parse.go
Normal file
12
internal/httpapi/parse.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package httpapi
|
||||
|
||||
import "strconv"
|
||||
|
||||
func parseInt64(input string, target *int64) (int64, error) {
|
||||
value, err := strconv.ParseInt(input, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
*target = value
|
||||
return value, nil
|
||||
}
|
||||
415
internal/httpapi/server.go
Normal file
415
internal/httpapi/server.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
repo *repository.MemoryRepository
|
||||
probeService *probe.Service
|
||||
publishService *publish.Service
|
||||
gatewayConsumerService *gatewayconsumer.Service
|
||||
discoveryService *discovery.Service
|
||||
admissionService *admission.Service
|
||||
}
|
||||
|
||||
type packageChangesResponse struct {
|
||||
Items []domain.PackageChangeEvent `json:"items"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
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 (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.handleHealth)
|
||||
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)
|
||||
mux.HandleFunc("/internal/supply-intelligence/discovery/candidates", s.handleDiscoveryCandidates)
|
||||
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/admission/run", s.handleAdmissionRun)
|
||||
mux.HandleFunc("/internal/supply-intelligence/admission/candidates", s.handleAdmissionCandidates)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetRoutingState(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/accounts/"
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if !strings.HasSuffix(path, "/routing-state") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
accountIDPart := strings.TrimSuffix(path, "/routing-state")
|
||||
var accountID int64
|
||||
if _, err := parseInt64(accountIDPart, &accountID); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"})
|
||||
return
|
||||
}
|
||||
state, ok := s.repo.GetRoutingState(accountID)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (s *Server) handleEvaluateProbe(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.probeService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "probe_service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
CurrentStatus string `json:"current_status"`
|
||||
StatusCode int `json:"status_code"`
|
||||
TransportError string `json:"transport_error"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
if payload.AccountID <= 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"})
|
||||
return
|
||||
}
|
||||
if payload.Platform == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_platform"})
|
||||
return
|
||||
}
|
||||
if payload.CurrentStatus == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_current_status"})
|
||||
return
|
||||
}
|
||||
|
||||
var transportErr error
|
||||
if payload.TransportError != "" {
|
||||
transportErr = errors.New(payload.TransportError)
|
||||
}
|
||||
|
||||
result, err := s.probeService.EvaluateHTTPResult(context.Background(), probe.EvaluateInput{
|
||||
AccountID: payload.AccountID,
|
||||
Platform: payload.Platform,
|
||||
CurrentStatus: domainAccountStatus(payload.CurrentStatus),
|
||||
StatusCode: payload.StatusCode,
|
||||
TransportError: transportErr,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishPackageEvent(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.publishService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "publish_service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
|
||||
var occurredAt time.Time
|
||||
if payload.OccurredAt != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, payload.OccurredAt)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_occurred_at"})
|
||||
return
|
||||
}
|
||||
occurredAt = parsed
|
||||
}
|
||||
|
||||
event, err := s.publishService.RecordPackagePublished(r.Context(), publish.RecordPackagePublishedInput{
|
||||
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) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_publish_input"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, event)
|
||||
}
|
||||
|
||||
func (s *Server) handleDiscoveryCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
s.handleCreateDiscoveryCandidate(w, r)
|
||||
case http.MethodGet:
|
||||
s.handleListDiscoveryCandidates(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateDiscoveryCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.discoveryService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "discovery_service_unavailable"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
CandidateID string `json:"candidate_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
DiscoveredAt string `json:"discovered_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
var discoveredAt time.Time
|
||||
if strings.TrimSpace(payload.DiscoveredAt) != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, payload.DiscoveredAt)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_discovered_at"})
|
||||
return
|
||||
}
|
||||
discoveredAt = parsed
|
||||
}
|
||||
out, err := s.discoveryService.RecordCandidate(r.Context(), discovery.RecordCandidateInput{
|
||||
CandidateID: payload.CandidateID,
|
||||
AccountID: payload.AccountID,
|
||||
Platform: payload.Platform,
|
||||
Model: payload.Model,
|
||||
Source: payload.Source,
|
||||
ReasonCode: payload.ReasonCode,
|
||||
DiscoveredAt: discoveredAt,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, discovery.ErrInvalidCandidateInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_candidate_input"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleListDiscoveryCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
if s.discoveryService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "discovery_service_unavailable"})
|
||||
return
|
||||
}
|
||||
status, ok := parseDiscoveryCandidateStatus(strings.TrimSpace(r.URL.Query().Get("status")))
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_status"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, discoveryCandidatesResponse{Items: s.discoveryService.ListCandidates(r.Context(), status)})
|
||||
}
|
||||
|
||||
func parseDiscoveryCandidateStatus(raw string) (domain.DiscoveryCandidateStatus, bool) {
|
||||
if raw == "" {
|
||||
return "", true
|
||||
}
|
||||
status := domain.DiscoveryCandidateStatus(raw)
|
||||
switch status {
|
||||
case domain.DiscoveryCandidateStatusPendingAdmission, domain.DiscoveryCandidateStatusAdmitted, domain.DiscoveryCandidateStatusRejected:
|
||||
return status, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListPackageChanges(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
items, nextCursor := s.repo.ListPackageEventsAfter(strings.TrimSpace(r.URL.Query().Get("cursor")))
|
||||
writeJSON(w, http.StatusOK, packageChangesResponse{Items: items, NextCursor: nextCursor})
|
||||
}
|
||||
|
||||
func (s *Server) handleAckPackageChange(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
prefix := "/internal/supply-intelligence/gateway/package-changes/"
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if !strings.HasSuffix(path, "/ack") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
eventID := strings.TrimSuffix(path, "/ack")
|
||||
var payload struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Result string `json:"result"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
ackResult := domain.GatewayAckResult(payload.Result)
|
||||
if !repository.IsGatewayAckResult(ackResult) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_result"})
|
||||
return
|
||||
}
|
||||
consumer := strings.TrimSpace(payload.Consumer)
|
||||
if consumer == "" {
|
||||
consumer = "gateway"
|
||||
}
|
||||
_, err := s.repo.AckPackageEvent(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"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleConsumeOnce(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.gatewayConsumerService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gateway_consumer_unavailable"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil && err.Error() != "EOF" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
}
|
||||
out, err := s.gatewayConsumerService.ConsumeOnce(r.Context(), gatewayconsumer.ConsumeOnceInput{Consumer: payload.Consumer, Cursor: payload.Cursor})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "consume_failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// handleAdmissionRun runs admission test for a specific candidate
|
||||
func (s *Server) handleAdmissionRun(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.admissionService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
CandidateID string `json:"candidate_id"`
|
||||
}
|
||||
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.CandidateID) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_candidate_id"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.admissionService.RunAdmission(r.Context(), payload.CandidateID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, admission.ErrCandidateNotFound):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "candidate_not_found"})
|
||||
case errors.Is(err, admission.ErrCandidateNotRunnable):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "candidate_not_runnable"})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_run_failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleAdmissionCandidates lists candidates pending 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"})
|
||||
return
|
||||
}
|
||||
if s.admissionService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
candidates := s.admissionService.GetRunnableCandidates(r.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": candidates})
|
||||
}
|
||||
|
||||
func domainAccountStatus(raw string) domain.AccountStatus {
|
||||
return domain.AccountStatus(raw)
|
||||
}
|
||||
149
internal/httpapi/server_integration_test.go
Normal file
149
internal/httpapi/server_integration_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"supply-intelligence/internal/app"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/probe"
|
||||
)
|
||||
|
||||
func TestApplicationServerRoutes(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/probe/evaluate", bytes.NewBufferString(`{"account_id":7,"platform":"openai","current_status":"active","status_code":401}`))
|
||||
rr := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result probe.EvaluateOutput
|
||||
if err := json.NewDecoder(rr.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if result.RoutingState.AccountID != 7 || result.RoutingState.AccountStatus != "suspended" {
|
||||
t.Fatalf("unexpected state: %+v", result.RoutingState)
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/accounts/7/routing-state", nil)
|
||||
getRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(getRR, getReq)
|
||||
if getRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected get status: %d body=%s", getRR.Code, getRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConsumeOnceListAppliedIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
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"}`))
|
||||
publishRR := httptest.NewRecorder()
|
||||
application.Server.Routes().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()
|
||||
application.Server.Routes().ServeHTTP(consumeRR, consumeReq)
|
||||
if consumeRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected consume status: %d body=%s", consumeRR.Code, consumeRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
application.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)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].EventID != "evt-integration-1" {
|
||||
t.Fatalf("unexpected list items: %+v", listResp.Items)
|
||||
}
|
||||
if listResp.NextCursor != "1" {
|
||||
t.Fatalf("unexpected next cursor: %+v", listResp)
|
||||
}
|
||||
if listResp.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("unexpected sync status: %+v", listResp.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConsumeOnceListFailedIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
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"}`))
|
||||
publishRR := httptest.NewRecorder()
|
||||
application.Server.Routes().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()
|
||||
application.Server.Routes().ServeHTTP(consumeRR, consumeReq)
|
||||
if consumeRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected consume status: %d body=%s", consumeRR.Code, consumeRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
application.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)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].EventID != "evt-integration-failed" {
|
||||
t.Fatalf("unexpected list items: %+v", listResp.Items)
|
||||
}
|
||||
if listResp.NextCursor != "1" {
|
||||
t.Fatalf("unexpected next cursor: %+v", listResp)
|
||||
}
|
||||
if listResp.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusFailed {
|
||||
t.Fatalf("unexpected sync status: %+v", listResp.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryCandidateCreateAndListIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/discovery/candidates", bytes.NewBufferString(`{"candidate_id":"cand-int-1","account_id":701,"platform":"openai","model":"gpt-4.1-mini","source":"manual_seed","reason_code":"new_model","discovered_at":"2026-05-06T20:30:00Z"}`))
|
||||
createRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(createRR, createReq)
|
||||
if createRR.Code != http.StatusOK {
|
||||
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)
|
||||
listRR := httptest.NewRecorder()
|
||||
application.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)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].CandidateID != "cand-int-1" {
|
||||
t.Fatalf("unexpected discovery list items: %+v", listResp.Items)
|
||||
}
|
||||
}
|
||||
266
internal/httpapi/server_test.go
Normal file
266
internal/httpapi/server_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 TestServerRoutingStateEndpoint(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.UpsertRoutingState(domain.AccountRoutingState{
|
||||
AccountID: 101,
|
||||
Platform: "openai",
|
||||
AccountStatus: domain.AccountStatusActive,
|
||||
RoutingEnabled: true,
|
||||
RiskScore: 10,
|
||||
ReasonCode: "ok",
|
||||
LastProbeAt: time.Unix(100, 0).UTC(),
|
||||
Version: 3,
|
||||
})
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
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()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
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()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), 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"}`)
|
||||
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())
|
||||
}
|
||||
|
||||
var event domain.PackageChangeEvent
|
||||
if err := json.NewDecoder(rr.Body).Decode(&event); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if event.EventID != "evt-1" || event.EventType != publish.PackagePublishedEventType {
|
||||
t.Fatalf("unexpected event: %+v", event)
|
||||
}
|
||||
if event.GatewaySyncStatus != domain.GatewaySyncStatusPending {
|
||||
t.Fatalf("unexpected sync status: %q", event.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)
|
||||
|
||||
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)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.NextCursor != "1" {
|
||||
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())
|
||||
}
|
||||
updated, _ := repo.ListPackageEventsAfter("")
|
||||
if len(updated) != 1 || updated[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("unexpected ack state: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes?cursor=1", 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 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)
|
||||
}
|
||||
if len(resp.Items) != 1 || resp.Items[0].EventID != "evt-2" || resp.NextCursor != "2" {
|
||||
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)
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDiscoveryCandidateCreateAndList(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), 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()
|
||||
server.Routes().ServeHTTP(createRR, createReq)
|
||||
if createRR.Code != http.StatusOK {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].CandidateID != "cand-1" || listResp.Items[0].Status != domain.DiscoveryCandidateStatusPendingAdmission {
|
||||
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)
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user