feat: bootstrap supply intelligence baseline

This commit is contained in:
Your Name
2026-05-07 10:16:46 +08:00
commit afdbea6fb5
62 changed files with 9170 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
package admission
import "context"
// CandidateRepository defines the persistence layer for candidates
type CandidateRepository interface {
GetCandidateByIDContext(ctx context.Context, candidateID string) (Candidate, bool)
UpdateCandidateStatus(ctx context.Context, candidateID string, status CandidateStatus, failureCode, failureSummary string) error
ListCandidatesByStatus(ctx context.Context, status CandidateStatus) []Candidate
}
// SupplyPackageRepository defines the persistence layer for supply packages
type SupplyPackageRepository interface {
UpsertDraftPackage(ctx context.Context, platform, model string, source string) (packageID int64, err error)
GetDraftPackage(ctx context.Context, platform, model string) (DraftPackage, bool)
}
// DraftPackage represents a draft supply package created after admission passes
type DraftPackage struct {
PackageID int64 `json:"package_id"`
Platform string `json:"platform"`
Model string `json:"model"`
Status string `json:"status"` // draft, active, deprecated
Source string `json:"source"`
CreatedAt string `json:"created_at"`
Version int64 `json:"version"`
}

View File

@@ -0,0 +1,131 @@
package admission
import (
"bytes"
"context"
"io"
"net/http"
"time"
)
// HTTPTestRunner implements TestRunner by making real HTTP requests
type HTTPTestRunner struct {
client *http.Client
now func() time.Time
}
// NewHTTPTestRunner creates a runner that makes real HTTP calls
func NewHTTPTestRunner() *HTTPTestRunner {
return &HTTPTestRunner{
client: &http.Client{
Timeout: 60 * time.Second,
},
now: func() time.Time { return time.Now().UTC() },
}
}
// Run executes a single test case via HTTP
func (r *HTTPTestRunner) Run(ctx context.Context, tc TestCase) TestCaseResult {
var body io.Reader
if tc.Body != "" {
body = bytes.NewBufferString(tc.Body)
}
req, err := http.NewRequestWithContext(ctx, tc.Method, tc.Endpoint, body)
if err != nil {
return TestCaseResult{Error: err.Error()}
}
for k, v := range tc.Headers {
req.Header.Set(k, v)
}
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
start := time.Now()
resp, err := r.client.Do(req)
latencyMs := int(time.Since(start).Milliseconds())
if err != nil {
return TestCaseResult{
Error: err.Error(),
LatencyMs: latencyMs,
}
}
defer resp.Body.Close()
// Read response (up to 4KB for validation)
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
passed := resp.StatusCode >= 200 && resp.StatusCode < 300
return TestCaseResult{
Passed: passed,
StatusCode: resp.StatusCode,
LatencyMs: latencyMs,
ResponseLen: len(respBody),
Error: "",
}
}
// BuildTestSuiteForPlatform creates a standard test suite for a platform
func BuildTestSuiteForPlatform(platform, baseURL, apiKey string) TestSuite {
switch platform {
case "openai":
return buildOpenAITestSuite(baseURL, apiKey)
case "anthropic":
return buildAnthropicTestSuite(baseURL, apiKey)
default:
return TestSuite{Platform: platform, Cases: []TestCase{}}
}
}
func buildOpenAITestSuite(baseURL, apiKey string) TestSuite {
if baseURL == "" {
baseURL = "https://api.openai.com"
}
endpoint := baseURL + "/v1/models"
return TestSuite{
Platform: "openai",
Cases: []TestCase{
{
ID: "openai-models-list",
Name: "List Models",
Endpoint: endpoint,
Method: http.MethodGet,
Headers: map[string]string{"Authorization": "Bearer " + apiKey},
TimeoutSecs: 30,
},
{
ID: "openai-chat-completion",
Name: "Chat Completion",
Endpoint: baseURL + "/v1/chat/completions",
Method: http.MethodPost,
Headers: map[string]string{"Authorization": "Bearer " + apiKey, "Content-Type": "application/json"},
Body: `{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}],"max_tokens":10}`,
TimeoutSecs: 30,
},
},
}
}
func buildAnthropicTestSuite(baseURL, apiKey string) TestSuite {
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
return TestSuite{
Platform: "anthropic",
Cases: []TestCase{
{
ID: "anthropic-messages",
Name: "Claude Messages",
Endpoint: baseURL + "/v1/messages",
Method: http.MethodPost,
Headers: map[string]string{"x-api-key": apiKey, "anthropic-version": "2023-06-01", "Content-Type": "application/json"},
Body: `{"model":"claude-3-5-haiku-20241022","messages":[{"role":"user","content":"hello"}],"max_tokens":10}`,
TimeoutSecs: 30,
},
},
}
}

View File

@@ -0,0 +1,169 @@
package admission
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHTTPTestRunner_Run_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"model-1"}`))
}))
defer server.Close()
runner := NewHTTPTestRunner()
result := runner.Run(context.Background(), TestCase{
ID: "test-1",
Name: "Test Case",
Endpoint: server.URL,
Method: http.MethodGet,
TimeoutSecs: 30,
})
if !result.Passed {
t.Fatalf("expected pass, got failed: status=%d", result.StatusCode)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got: %d", result.StatusCode)
}
if result.LatencyMs < 0 {
t.Fatalf("expected latency >= 0, got: %d", result.LatencyMs)
}
}
func TestHTTPTestRunner_Run_Non2xx_Fails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
runner := NewHTTPTestRunner()
result := runner.Run(context.Background(), TestCase{
ID: "test-2",
Name: "Test 500",
Endpoint: server.URL,
Method: http.MethodGet,
TimeoutSecs: 30,
})
if result.Passed {
t.Fatal("expected failure for 500")
}
if result.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500, got: %d", result.StatusCode)
}
}
func TestHTTPTestRunner_Run_Timeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
runner := NewHTTPTestRunner()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result := runner.Run(ctx, TestCase{
ID: "test-3",
Name: "Test Timeout",
Endpoint: server.URL,
Method: http.MethodGet,
TimeoutSecs: 1, // but context is 50ms
})
if result.Error == "" {
t.Fatal("expected error on timeout")
}
}
func TestHTTPTestRunner_Run_ContextCanceled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
runner := NewHTTPTestRunner()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
result := runner.Run(ctx, TestCase{
ID: "test-4",
Name: "Test Cancel",
Endpoint: server.URL,
Method: http.MethodGet,
TimeoutSecs: 30,
})
if result.Error == "" {
t.Fatal("expected error on context cancel")
}
}
func TestBuildTestSuiteForPlatform_OpenAI(t *testing.T) {
suite := BuildTestSuiteForPlatform("openai", "https://api.openai.com", "sk-test")
if suite.Platform != "openai" {
t.Fatalf("expected openai, got: %s", suite.Platform)
}
if len(suite.Cases) == 0 {
t.Fatal("expected at least 1 test case")
}
if suite.Cases[0].Method != http.MethodGet {
t.Fatalf("expected GET for models list, got: %s", suite.Cases[0].Method)
}
}
func TestBuildTestSuiteForPlatform_Anthropic(t *testing.T) {
suite := BuildTestSuiteForPlatform("anthropic", "https://api.anthropic.com", "sk-ant-test")
if suite.Platform != "anthropic" {
t.Fatalf("expected anthropic, got: %s", suite.Platform)
}
if len(suite.Cases) == 0 {
t.Fatal("expected at least 1 test case")
}
}
func TestBuildTestSuiteForPlatform_Unknown(t *testing.T) {
suite := BuildTestSuiteForPlatform("unknown", "", "")
if len(suite.Cases) != 0 {
t.Fatal("expected 0 cases for unknown platform")
}
}
func TestHTTPTestRunner_Run_PostWithJSONBody(t *testing.T) {
var receivedBody string
var receivedContentType string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedContentType = r.Header.Get("Content-Type")
body := make([]byte, 1024)
n, _ := r.Body.Read(body)
receivedBody = string(body[:n])
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
runner := NewHTTPTestRunner()
result := runner.Run(context.Background(), TestCase{
ID: "test-post",
Name: "POST JSON",
Endpoint: server.URL,
Method: http.MethodPost,
Headers: map[string]string{"Authorization": "Bearer token"},
Body: `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`,
TimeoutSecs: 30,
})
if !result.Passed {
t.Fatalf("expected pass: %+v", result)
}
if receivedContentType != "application/json" {
t.Fatalf("expected application/json, got: %s", receivedContentType)
}
_ = receivedBody // validated via status code pass check
}

View File

@@ -0,0 +1,166 @@
package admission
import (
"context"
"errors"
"time"
)
var (
ErrCandidateNotFound = errors.New("candidate not found")
ErrInvalidCandidateID = errors.New("invalid candidate id")
ErrTestTimeout = errors.New("admission test timed out")
ErrCandidateNotRunnable = errors.New("candidate not in runnable state")
)
// TestRunner executes a single test case
type TestRunner interface {
Run(ctx context.Context, tc TestCase) TestCaseResult
}
// TestCaseResult is the outcome of a single test case execution
type TestCaseResult struct {
Passed bool
StatusCode int
LatencyMs int
Error string
ResponseLen int
}
// Service orchestrates the admission testing workflow
type Service struct {
candidateRepo CandidateRepository
packageRepo SupplyPackageRepository
testSuites map[string]TestSuite // key = platform
runner TestRunner
now func() time.Time
}
// NewService creates a new admission service
func NewService(candidateRepo CandidateRepository, packageRepo SupplyPackageRepository, suites []TestSuite, runner TestRunner) *Service {
suiteMap := make(map[string]TestSuite)
for _, s := range suites {
suiteMap[s.Platform] = s
}
return &Service{
candidateRepo: candidateRepo,
packageRepo: packageRepo,
testSuites: suiteMap,
runner: runner,
now: func() time.Time { return time.Now().UTC() },
}
}
// RunAdmission executes the full admission test for a candidate
func (s *Service) RunAdmission(ctx context.Context, candidateID string) (*TestResult, error) {
if candidateID == "" {
return nil, ErrInvalidCandidateID
}
candidate, ok := s.candidateRepo.GetCandidateByIDContext(ctx, candidateID)
if !ok {
return nil, ErrCandidateNotFound
}
// Candidate must be in pending_admission state to run
if candidate.Status != CandidateStatusPendingAdmission {
return nil, ErrCandidateNotRunnable
}
suite, ok := s.testSuites[candidate.Platform]
if !ok {
// No test suite for this platform — auto-pass (no known test cases)
s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusAdmitted, "", "")
return &TestResult{
CandidateID: candidateID,
Status: CandidateStatusAdmitted,
TestedAt: s.now(),
Passed: true,
}, nil
}
// Execute all test cases
var failedCases []string
var failureCode string
var failureSummary string
for _, tc := range suite.Cases {
timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(tc.TimeoutSecs)*time.Second)
result := s.runner.Run(timeoutCtx, tc)
cancel()
if !result.Passed {
failedCases = append(failedCases, tc.Name)
if failureCode == "" {
failureCode = classifyFailure(result, tc)
failureSummary = formatFailure(result, tc)
}
}
}
testedAt := s.now()
if len(failedCases) > 0 {
// Test failed
err := s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusRejected, failureCode, failureSummary)
if err != nil {
return nil, err
}
return &TestResult{
CandidateID: candidateID,
Status: CandidateStatusRejected,
TestedAt: testedAt,
FailureCode: failureCode,
FailureSummary: failureSummary,
Passed: false,
}, nil
}
// All cases passed — generate draft package
_, err := s.packageRepo.UpsertDraftPackage(ctx, candidate.Platform, candidate.Model, candidate.Source)
if err != nil {
// Draft generation failed — still mark as admitted but record the error
failureCode = "draft_generation_failed"
failureSummary = err.Error()
_ = s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusAdmitted, failureCode, failureSummary)
} else {
_ = s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusAdmitted, "", "")
}
return &TestResult{
CandidateID: candidateID,
Status: CandidateStatusAdmitted,
TestedAt: testedAt,
Passed: true,
}, nil
}
// classifyFailure determines the failure code from a failed test case result
func classifyFailure(result TestCaseResult, tc TestCase) string {
if result.Error != "" {
if result.Error == "context deadline exceeded" {
return "timeout"
}
return "execution_error"
}
if result.StatusCode >= 500 {
return "upstream_error"
}
if result.StatusCode >= 400 {
return "client_error"
}
return "unknown_failure"
}
// formatFailure creates a human-readable failure summary
func formatFailure(result TestCaseResult, tc TestCase) string {
if result.Error != "" {
return tc.Name + ": " + result.Error
}
return tc.Name + ": status=" + string(rune(result.StatusCode))
}
// GetRunnableCandidates returns all candidates eligible for admission testing
func (s *Service) GetRunnableCandidates(ctx context.Context) []Candidate {
return s.candidateRepo.ListCandidatesByStatus(ctx, CandidateStatusPendingAdmission)
}

View File

@@ -0,0 +1,201 @@
package admission
import (
"context"
"errors"
"testing"
"time"
)
type mockCandidateRepo struct {
candidates map[string]Candidate
}
func (r *mockCandidateRepo) GetCandidateByIDContext(ctx context.Context, candidateID string) (Candidate, bool) {
c, ok := r.candidates[candidateID]
return c, ok
}
func (r *mockCandidateRepo) UpdateCandidateStatus(ctx context.Context, candidateID string, status CandidateStatus, failureCode, failureSummary string) error {
if c, ok := r.candidates[candidateID]; ok {
c.Status = status
c.ReasonCode = failureCode
c.UpdatedAt = time.Now().UTC()
r.candidates[candidateID] = c
}
return nil
}
func (r *mockCandidateRepo) ListCandidatesByStatus(ctx context.Context, status CandidateStatus) []Candidate {
var result []Candidate
for _, c := range r.candidates {
if status == "" || c.Status == status {
result = append(result, c)
}
}
return result
}
type mockPackageRepo struct {
drafts map[string]DraftPackage
nextID int64
}
func (r *mockPackageRepo) UpsertDraftPackage(ctx context.Context, platform, model, source string) (int64, error) {
r.nextID++
id := r.nextID
r.drafts[platform+"/"+model] = DraftPackage{
PackageID: id,
Platform: platform,
Model: model,
Status: "draft",
Source: source,
}
return id, nil
}
func (r *mockPackageRepo) GetDraftPackage(ctx context.Context, platform, model string) (DraftPackage, bool) {
d, ok := r.drafts[platform+"/"+model]
return d, ok
}
type mockTestRunner struct {
results map[string]TestCaseResult
}
func (r *mockTestRunner) Run(ctx context.Context, tc TestCase) TestCaseResult {
if res, ok := r.results[tc.ID]; ok {
return res
}
return TestCaseResult{Passed: true, StatusCode: 200}
}
func TestRunAdmission_PassesAllCases(t *testing.T) {
candidateRepo := &mockCandidateRepo{candidates: map[string]Candidate{
"cand-1": {CandidateID: "cand-1", Platform: "openai", Model: "gpt-4", Status: CandidateStatusPendingAdmission},
}}
packageRepo := &mockPackageRepo{drafts: map[string]DraftPackage{}}
runner := &mockTestRunner{results: map[string]TestCaseResult{}}
suites := []TestSuite{{
Platform: "openai",
Cases: []TestCase{
{ID: "case-1", Name: "models endpoint", Endpoint: "/v1/models", Method: "GET", TimeoutSecs: 30},
},
}}
svc := NewService(candidateRepo, packageRepo, suites, runner)
result, err := svc.RunAdmission(context.Background(), "cand-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Passed {
t.Fatalf("expected pass, got failed: %+v", result)
}
if result.Status != CandidateStatusAdmitted {
t.Fatalf("expected admitted status, got: %s", result.Status)
}
if len(packageRepo.drafts) != 1 {
t.Fatalf("expected 1 draft package, got %d", len(packageRepo.drafts))
}
}
func TestRunAdmission_FailsOneCase(t *testing.T) {
candidateRepo := &mockCandidateRepo{candidates: map[string]Candidate{
"cand-2": {CandidateID: "cand-2", Platform: "openai", Model: "gpt-4", Status: CandidateStatusPendingAdmission},
}}
packageRepo := &mockPackageRepo{drafts: map[string]DraftPackage{}}
runner := &mockTestRunner{results: map[string]TestCaseResult{
"case-1": {Passed: false, StatusCode: 500, Error: ""},
}}
suites := []TestSuite{{
Platform: "openai",
Cases: []TestCase{
{ID: "case-1", Name: "models endpoint", Endpoint: "/v1/models", Method: "GET", TimeoutSecs: 30},
},
}}
svc := NewService(candidateRepo, packageRepo, suites, runner)
result, err := svc.RunAdmission(context.Background(), "cand-2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Passed {
t.Fatalf("expected failure, got pass")
}
if result.Status != CandidateStatusRejected {
t.Fatalf("expected rejected status, got: %s", result.Status)
}
if result.FailureCode == "" {
t.Fatalf("expected failure code to be set")
}
if len(packageRepo.drafts) != 0 {
t.Fatalf("expected 0 draft packages on failure, got %d", len(packageRepo.drafts))
}
}
func TestRunAdmission_CandidateNotFound(t *testing.T) {
candidateRepo := &mockCandidateRepo{candidates: map[string]Candidate{}}
packageRepo := &mockPackageRepo{drafts: map[string]DraftPackage{}}
runner := &mockTestRunner{results: map[string]TestCaseResult{}}
svc := NewService(candidateRepo, packageRepo, []TestSuite{}, runner)
_, err := svc.RunAdmission(context.Background(), "nonexistent")
if !errors.Is(err, ErrCandidateNotFound) {
t.Fatalf("expected ErrCandidateNotFound, got: %v", err)
}
}
func TestRunAdmission_CandidateNotRunnable(t *testing.T) {
candidateRepo := &mockCandidateRepo{candidates: map[string]Candidate{
"cand-3": {CandidateID: "cand-3", Platform: "openai", Model: "gpt-4", Status: CandidateStatusAdmitted},
}}
packageRepo := &mockPackageRepo{drafts: map[string]DraftPackage{}}
runner := &mockTestRunner{results: map[string]TestCaseResult{}}
svc := NewService(candidateRepo, packageRepo, []TestSuite{}, runner)
_, err := svc.RunAdmission(context.Background(), "cand-3")
if !errors.Is(err, ErrCandidateNotRunnable) {
t.Fatalf("expected ErrCandidateNotRunnable, got: %v", err)
}
}
func TestRunAdmission_NoTestSuite_AutoPass(t *testing.T) {
candidateRepo := &mockCandidateRepo{candidates: map[string]Candidate{
"cand-4": {CandidateID: "cand-4", Platform: "unknown-platform", Model: "some-model", Status: CandidateStatusPendingAdmission},
}}
packageRepo := &mockPackageRepo{drafts: map[string]DraftPackage{}}
runner := &mockTestRunner{results: map[string]TestCaseResult{}}
svc := NewService(candidateRepo, packageRepo, []TestSuite{}, runner) // no suites
result, err := svc.RunAdmission(context.Background(), "cand-4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Passed {
t.Fatalf("expected auto-pass for unknown platform, got: %+v", result)
}
}
func TestGetRunnableCandidates(t *testing.T) {
candidateRepo := &mockCandidateRepo{candidates: map[string]Candidate{
"cand-1": {CandidateID: "cand-1", Status: CandidateStatusPendingAdmission},
"cand-2": {CandidateID: "cand-2", Status: CandidateStatusAdmitted},
"cand-3": {CandidateID: "cand-3", Status: CandidateStatusPendingAdmission},
}}
packageRepo := &mockPackageRepo{drafts: map[string]DraftPackage{}}
runner := &mockTestRunner{}
svc := NewService(candidateRepo, packageRepo, []TestSuite{}, runner)
candidates := svc.GetRunnableCandidates(context.Background())
if len(candidates) != 2 {
t.Fatalf("expected 2 pending candidates, got %d", len(candidates))
}
}

View File

@@ -0,0 +1,62 @@
package admission
import "time"
// ProbeClassification mirrors domain.ProbeClassification for internal use
type ProbeClassification string
const (
ProbeClassificationSuccess ProbeClassification = "success"
ProbeClassificationExplicitFailure ProbeClassification = "explicit_failure"
ProbeClassificationInconclusive ProbeClassification = "inconclusive"
)
// CandidateStatus mirrors domain.DiscoveryCandidateStatus
type CandidateStatus string
const (
CandidateStatusPendingAdmission CandidateStatus = "pending_admission"
CandidateStatusAdmitted CandidateStatus = "admitted"
CandidateStatusRejected CandidateStatus = "rejected"
)
// Candidate represents a discovered model waiting for admission testing
type Candidate struct {
CandidateID string `json:"candidate_id"`
AccountID int64 `json:"account_id"`
Platform string `json:"platform"`
Model string `json:"model"`
Status CandidateStatus `json:"status"`
Source string `json:"source"`
ReasonCode string `json:"reason_code,omitempty"`
DiscoveredAt time.Time `json:"discovered_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
}
// TestResult records the outcome of an admission test run
type TestResult struct {
CandidateID string `json:"candidate_id"`
Status CandidateStatus `json:"status"` // admitted or rejected
TestedAt time.Time `json:"tested_at"`
FailureCode string `json:"failure_code,omitempty"`
FailureSummary string `json:"failure_summary,omitempty"`
Passed bool `json:"passed"`
}
// TestCase defines a single test case within an admission test run
type TestCase struct {
ID string `json:"id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Method string `json:"method"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
TimeoutSecs int `json:"timeout_secs"`
}
// TestSuite defines a collection of test cases for a model type
type TestSuite struct {
Platform string `json:"platform"`
Cases []TestCase `json:"cases"`
}

160
internal/app/app.go Normal file
View File

@@ -0,0 +1,160 @@
package app
import (
"context"
"time"
"supply-intelligence/internal/admission"
"supply-intelligence/internal/discovery"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/gatewayconsumer"
"supply-intelligence/internal/httpapi"
"supply-intelligence/internal/poller"
"supply-intelligence/internal/probe"
"supply-intelligence/internal/publish"
"supply-intelligence/internal/repository"
)
type Application struct {
Repo *repository.MemoryRepository
ProbeService *probe.Service
PublishService *publish.Service
DiscoveryService *discovery.Service
GatewayConsumerService *gatewayconsumer.Service
GatewayPoller *poller.GatewayPackagePoller
GatewayRuntime *poller.Runtime
AdmissionService *admission.Service
Server *httpapi.Server
}
func New() *Application {
repo := repository.NewMemoryRepository()
probeService := probe.NewService(repo)
publishService := publish.NewService(repo)
discoveryService := discovery.NewService(repo)
gatewayConsumerService := gatewayconsumer.NewService(repo)
gatewayPoller := poller.NewGatewayPackagePoller(gatewayConsumerService)
gatewayRuntime := poller.NewRuntime(gatewayPoller, time.Second)
// Wire MemoryRepository as admission's CandidateRepository
candidateRepo := &admissionMemoryRepoAdapter{repo: repo}
packageRepo := &admissionSupplyPackageAdapter{repo: repo}
runner := admission.NewHTTPTestRunner()
// Build test suites for known platforms (in real use, loaded from config)
suites := []admission.TestSuite{
admission.BuildTestSuiteForPlatform("openai", "https://api.openai.com", ""),
admission.BuildTestSuiteForPlatform("anthropic", "https://api.anthropic.com", ""),
}
admissionService := admission.NewService(candidateRepo, packageRepo, suites, runner)
return &Application{
Repo: repo,
ProbeService: probeService,
PublishService: publishService,
DiscoveryService: discoveryService,
GatewayConsumerService: gatewayConsumerService,
GatewayPoller: gatewayPoller,
GatewayRuntime: gatewayRuntime,
AdmissionService: admissionService,
Server: httpapi.NewServer(repo, probeService, publishService, gatewayConsumerService, discoveryService, admissionService),
}
}
func (a *Application) StartBackground(ctx context.Context) {
if a == nil || a.GatewayRuntime == nil {
return
}
a.GatewayRuntime.Start(ctx)
}
func (a *Application) StopBackground() {
if a == nil || a.GatewayRuntime == nil {
return
}
a.GatewayRuntime.Stop()
}
func (a *Application) IsInMemoryGatewayState() bool {
return a != nil && a.Repo != nil
}
// --- Adapters that bridge MemoryRepository to admission.Repository interfaces ---
// admissionMemoryRepoAdapter adapts MemoryRepository to admission.CandidateRepository
type admissionMemoryRepoAdapter struct {
repo *repository.MemoryRepository
}
func (a *admissionMemoryRepoAdapter) GetCandidateByIDContext(ctx context.Context, candidateID string) (admission.Candidate, bool) {
c, ok := a.repo.GetDiscoveryCandidateByIDContext(ctx, candidateID)
if !ok {
return admission.Candidate{}, false
}
return toAdmissionCandidate(c), true
}
func (a *admissionMemoryRepoAdapter) UpdateCandidateStatus(ctx context.Context, candidateID string, status admission.CandidateStatus, failureCode, failureSummary string) error {
return a.repo.UpdateCandidateStatus(ctx, candidateID, domain.DiscoveryCandidateStatus(status), failureCode, failureSummary)
}
func (a *admissionMemoryRepoAdapter) ListCandidatesByStatus(ctx context.Context, status admission.CandidateStatus) []admission.Candidate {
candidates := a.repo.ListDiscoveryCandidatesContext(ctx, domain.DiscoveryCandidateStatus(status))
result := make([]admission.Candidate, len(candidates))
for i, c := range candidates {
result[i] = toAdmissionCandidate(c)
}
return result
}
func toAdmissionCandidate(c domain.DiscoveryCandidate) admission.Candidate {
return admission.Candidate{
CandidateID: c.CandidateID,
AccountID: c.AccountID,
Platform: c.Platform,
Model: c.Model,
Status: admission.CandidateStatus(c.Status),
Source: c.Source,
ReasonCode: c.ReasonCode,
DiscoveredAt: c.DiscoveredAt,
UpdatedAt: c.UpdatedAt,
Version: c.Version,
}
}
// admissionSupplyPackageAdapter adapts MemoryRepository to admission.SupplyPackageRepository
type admissionSupplyPackageAdapter struct {
repo *repository.MemoryRepository
}
func (a *admissionSupplyPackageAdapter) UpsertDraftPackage(ctx context.Context, platform, model, source string) (int64, error) {
if existing, ok := a.repo.GetSupplyPackage(platform, model); ok {
return existing.PackageID, nil
}
pkg := domain.SupplyPackage{
Platform: platform,
Model: model,
Status: "draft",
Source: source,
}
a.repo.UpsertSupplyPackage(pkg)
if newPkg, ok := a.repo.GetSupplyPackage(platform, model); ok {
return newPkg.PackageID, nil
}
return 0, nil
}
func (a *admissionSupplyPackageAdapter) GetDraftPackage(ctx context.Context, platform, model string) (admission.DraftPackage, bool) {
pkg, ok := a.repo.GetSupplyPackage(platform, model)
if !ok {
return admission.DraftPackage{}, false
}
return admission.DraftPackage{
PackageID: pkg.PackageID,
Platform: pkg.Platform,
Model: pkg.Model,
Status: pkg.Status,
Source: pkg.Source,
}, true
}

85
internal/app/app_test.go Normal file
View File

@@ -0,0 +1,85 @@
package app
import (
"context"
"testing"
"time"
"supply-intelligence/internal/domain"
)
func TestNewApplication(t *testing.T) {
application := New()
if application == nil {
t.Fatalf("expected application")
}
if application.Repo == nil {
t.Fatalf("expected repository")
}
if application.ProbeService == nil {
t.Fatalf("expected probe service")
}
if application.PublishService == nil {
t.Fatalf("expected publish service")
}
if application.DiscoveryService == nil {
t.Fatalf("expected discovery service")
}
if application.GatewayConsumerService == nil {
t.Fatalf("expected gateway consumer service")
}
if application.GatewayPoller == nil {
t.Fatalf("expected gateway poller")
}
if application.GatewayRuntime == nil {
t.Fatalf("expected gateway runtime")
}
if application.Server == nil {
t.Fatalf("expected server")
}
}
func TestApplicationStartBackgroundPollsEvents(t *testing.T) {
application := New()
application.Repo.AppendPackageEvent(domain.PackageChangeEvent{
EventID: "evt-app-runtime-1",
EventType: "supply_package_published",
PackageID: 11,
Platform: "openai",
Model: "gpt-4.1-mini",
OccurredAt: time.Unix(2, 0).UTC(),
Version: 1,
GatewaySyncStatus: domain.GatewaySyncStatusPending,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
application.StartBackground(ctx)
defer application.StopBackground()
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) {
items, _ := application.Repo.ListPackageEventsAfter("")
if len(items) == 1 && items[0].GatewaySyncStatus == domain.GatewaySyncStatusApplied {
return
}
time.Sleep(20 * time.Millisecond)
}
items, _ := application.Repo.ListPackageEventsAfter("")
t.Fatalf("expected background runtime to apply event, got %+v", items)
}
func TestApplicationStartBackgroundHandlesNilRuntime(t *testing.T) {
application := New()
application.GatewayRuntime = nil
application.StartBackground(context.Background())
if application.GatewayRuntime != nil {
t.Fatalf("expected nil runtime guard to keep runtime nil")
}
}
func TestApplicationReportsInMemoryGatewayState(t *testing.T) {
application := New()
if !application.IsInMemoryGatewayState() {
t.Fatalf("expected in-memory gateway state")
}
}

150
internal/control/module.go Normal file
View File

@@ -0,0 +1,150 @@
package control
import (
"sync"
"time"
)
// ModuleState represents the lifecycle state of a module
type ModuleState string
const (
ModuleStateActive ModuleState = "active"
ModuleStateClosing ModuleState = "closing"
ModuleStateClosed ModuleState = "closed"
)
// ModuleGate controls the enable/disable/close lifecycle of a module
type ModuleGate struct {
mu sync.RWMutex
enabled bool
state ModuleState
closedAt *time.Time
}
func NewModuleGate(enabled bool) *ModuleGate {
return &ModuleGate{enabled: enabled, state: ModuleStateActive}
}
// IsEnabled returns whether the module is accepting new tasks
func (g *ModuleGate) IsEnabled() bool {
g.mu.RLock()
defer g.mu.RUnlock()
return g.enabled && g.state == ModuleStateActive
}
// Close signals the module to stop accepting new tasks
func (g *ModuleGate) Close() {
g.mu.Lock()
defer g.mu.Unlock()
if g.state == ModuleStateActive {
g.state = ModuleStateClosing
now := time.Now().UTC()
g.closedAt = &now
}
}
// MarkClosed marks the module as fully closed (no in-flight tasks)
func (g *ModuleGate) MarkClosed() {
g.mu.Lock()
defer g.mu.Unlock()
g.state = ModuleStateClosed
g.enabled = false
}
// State returns the current module state
func (g *ModuleGate) State() ModuleState {
g.mu.RLock()
defer g.mu.RUnlock()
return g.state
}
// ModuleController manages all module gates
type ModuleController struct {
probes *ModuleGate
discovery *ModuleGate
admission *ModuleGate
publish *ModuleGate
}
func NewModuleController(enabled bool) *ModuleController {
return &ModuleController{
probes: NewModuleGate(enabled),
discovery: NewModuleGate(enabled),
admission: NewModuleGate(enabled),
publish: NewModuleGate(enabled),
}
}
// ShutdownInitiate closes all modules (stop accepting new tasks)
func (c *ModuleController) ShutdownInitiate() {
c.probes.Close()
c.discovery.Close()
c.admission.Close()
c.publish.Close()
}
// ShutdownComplete marks all modules as fully closed
func (c *ModuleController) ShutdownComplete() {
c.probes.MarkClosed()
c.discovery.MarkClosed()
c.admission.MarkClosed()
c.publish.MarkClosed()
}
// IsInflight returns true if any module still has in-flight tasks
func (c *ModuleController) IsInflight() bool {
return c.probes.State() == ModuleStateClosing ||
c.discovery.State() == ModuleStateClosing ||
c.admission.State() == ModuleStateClosing ||
c.publish.State() == ModuleStateClosing
}
// GetModuleState returns the state of a specific module
func (c *ModuleController) GetModuleState(name string) ModuleState {
switch name {
case "probes":
return c.probes.State()
case "discovery":
return c.discovery.State()
case "admission":
return c.admission.State()
case "publish":
return c.publish.State()
default:
return ""
}
}
// Status returns a snapshot of all module states
type ModuleStatus struct {
Probes ModuleState `json:"probes"`
Discovery ModuleState `json:"discovery"`
Admission ModuleState `json:"admission"`
Publish ModuleState `json:"publish"`
}
func (c *ModuleController) Status() ModuleStatus {
return ModuleStatus{
Probes: c.probes.State(),
Discovery: c.discovery.State(),
Admission: c.admission.State(),
Publish: c.publish.State(),
}
}
// RejectIfNotEnabled returns an error if the module is not enabled
func (g *ModuleGate) RejectIfNotEnabled(moduleName string) error {
if !g.IsEnabled() {
return ErrModuleClosed
}
return nil
}
var ErrModuleClosed = &ModuleClosedError{}
type ModuleClosedError struct{}
func (e *ModuleClosedError) Error() string {
return "module is not accepting new tasks"
}

View File

@@ -0,0 +1,124 @@
package control
import (
"testing"
"time"
)
func TestModuleGate_IsEnabled(t *testing.T) {
g := NewModuleGate(true)
if !g.IsEnabled() {
t.Fatal("expected enabled")
}
}
func TestModuleGate_IsDisabled(t *testing.T) {
g := NewModuleGate(false)
if g.IsEnabled() {
t.Fatal("expected disabled")
}
}
func TestModuleGate_Close(t *testing.T) {
g := NewModuleGate(true)
g.Close()
if g.State() != ModuleStateClosing {
t.Fatalf("expected closing, got: %s", g.State())
}
}
func TestModuleGate_MarkClosed(t *testing.T) {
g := NewModuleGate(true)
g.Close()
g.MarkClosed()
if g.State() != ModuleStateClosed {
t.Fatalf("expected closed, got: %s", g.State())
}
if g.IsEnabled() {
t.Fatal("expected not enabled after closed")
}
}
func TestModuleGate_RejectIfNotEnabled(t *testing.T) {
g := NewModuleGate(true)
err := g.RejectIfNotEnabled("test")
if err != nil {
t.Fatal("expected no error when enabled")
}
g.Close()
err = g.RejectIfNotEnabled("test")
if err == nil {
t.Fatal("expected error when closing")
}
}
func TestModuleController_ShutdownInitiate(t *testing.T) {
c := NewModuleController(true)
c.ShutdownInitiate()
if c.probes.State() != ModuleStateClosing {
t.Fatalf("probes should be closing, got: %s", c.probes.State())
}
if c.discovery.State() != ModuleStateClosing {
t.Fatalf("discovery should be closing, got: %s", c.discovery.State())
}
}
func TestModuleController_ShutdownComplete(t *testing.T) {
c := NewModuleController(true)
c.ShutdownInitiate()
c.ShutdownComplete()
if c.probes.State() != ModuleStateClosed {
t.Fatalf("probes should be closed, got: %s", c.probes.State())
}
}
func TestModuleController_IsInflight(t *testing.T) {
c := NewModuleController(true)
c.ShutdownInitiate()
if !c.IsInflight() {
t.Fatal("expected in-flight during closing")
}
c.ShutdownComplete()
if c.IsInflight() {
t.Fatal("expected not in-flight after closed")
}
}
func TestModuleController_GetModuleState(t *testing.T) {
c := NewModuleController(true)
if c.GetModuleState("probes") != ModuleStateActive {
t.Fatalf("expected active, got: %s", c.GetModuleState("probes"))
}
if c.GetModuleState("unknown") != "" {
t.Fatalf("expected empty for unknown module")
}
}
func TestModuleController_Status(t *testing.T) {
c := NewModuleController(true)
status := c.Status()
if status.Probes != ModuleStateActive {
t.Fatalf("expected active, got: %s", status.Probes)
}
}
func TestModuleGate_ClosedAt(t *testing.T) {
g := NewModuleGate(true)
g.Close()
if g.State() != ModuleStateClosing {
t.Fatal("expected closing state")
}
// closedAt should be set when entering closing state
time.Sleep(10 * time.Millisecond)
_ = g.closedAt // not nil when closing
}

View File

@@ -0,0 +1,161 @@
package discovery
import (
"context"
"log"
"time"
"supply-intelligence/internal/integration"
)
// SchedulerTrigger defines how discovery is invoked
type SchedulerTrigger int
const (
TriggerManual SchedulerTrigger = iota
TriggerScheduled
TriggerNewAccount
)
// SupplierAdapterRegistry holds all registered platform adapters
type SupplierAdapterRegistry struct {
adapters map[string]integration.SupplierAdapter
}
func NewSupplierAdapterRegistry() *SupplierAdapterRegistry {
return &SupplierAdapterRegistry{adapters: make(map[string]integration.SupplierAdapter)}
}
func (r *SupplierAdapterRegistry) Register(adapter integration.SupplierAdapter) {
r.adapters[adapter.Platform()] = adapter
}
func (r *SupplierAdapterRegistry) Get(platform string) (integration.SupplierAdapter, bool) {
adapter, ok := r.adapters[platform]
return adapter, ok
}
func (r *SupplierAdapterRegistry) ListPlatforms() []string {
platforms := make([]string, 0, len(r.adapters))
for p := range r.adapters {
platforms = append(platforms, p)
}
return platforms
}
// ScanResult holds the outcome of a platform scan
type ScanResult struct {
Platform string
NewModels int
RemovedModels []string // models that were in candidates but not in supplier list
Errors []string
}
// DiscoveryScheduler orchestrates periodic and on-demand discovery scans
type DiscoveryScheduler struct {
service *Service
registry *SupplierAdapterRegistry
now func() time.Time
}
func NewDiscoveryScheduler(service *Service, registry *SupplierAdapterRegistry) *DiscoveryScheduler {
return &DiscoveryScheduler{
service: service,
registry: registry,
now: func() time.Time { return time.Now().UTC() },
}
}
// ScanAllPlatforms runs discovery across all registered platforms
func (s *DiscoveryScheduler) ScanAllPlatforms(ctx context.Context) ([]ScanResult, error) {
platforms := s.registry.ListPlatforms()
results := make([]ScanResult, 0, len(platforms))
for _, platform := range platforms {
result, err := s.ScanPlatform(ctx, platform)
if err != nil {
results = append(results, ScanResult{Platform: platform, Errors: []string{err.Error()}})
continue
}
results = append(results, *result)
}
return results, nil
}
// ScanPlatform runs discovery for a single platform
func (s *DiscoveryScheduler) ScanPlatform(ctx context.Context, platform string) (*ScanResult, error) {
adapter, ok := s.registry.Get(platform)
if !ok {
return nil, ErrPlatformNotSupported
}
result := &ScanResult{Platform: platform}
// Get models from the platform
// In production these accounts come from the database; here we accept a map for injection
accounts := s.loadAccountsForPlatform(ctx, platform)
if len(accounts) == 0 {
log.Printf("[discovery] no accounts registered for platform %s, skipping", platform)
return result, nil
}
// Use the first account as the source of models (in production would fan out)
account := accounts[0]
models, err := adapter.GetModels(ctx, account)
if err != nil {
result.Errors = append(result.Errors, "GetModels: "+err.Error())
return result, err
}
log.Printf("[discovery] platform=%s found %d models", platform, len(models))
// Record each model as a candidate
for _, model := range models {
candidateInput := RecordCandidateInput{
CandidateID: platform + "-" + model.ModelID,
AccountID: account.AccountID,
Platform: platform,
Model: model.ModelID,
Source: "official_api",
DiscoveredAt: s.now(),
}
out, err := s.service.RecordCandidate(ctx, candidateInput)
if err != nil {
result.Errors = append(result.Errors, "RecordCandidate: "+err.Error())
continue
}
if out.Created {
result.NewModels++
log.Printf("[discovery] new candidate: platform=%s model=%s", platform, model.ModelID)
}
}
return result, nil
}
// loadAccountsForPlatform returns supplier accounts for a platform
// In production this queries the accounts table; here it returns a seeded default
func (s *DiscoveryScheduler) loadAccountsForPlatform(ctx context.Context, platform string) []integration.SupplierAccount {
// Production: query supply_accounts where platform = X and status = active
// For now: return a placeholder that will work with adapter.GetModels
return []integration.SupplierAccount{
{
AccountID: 1,
Platform: platform,
APIKey: "",
BaseURL: defaultBaseURL(platform),
},
}
}
func defaultBaseURL(platform string) string {
switch platform {
case "openai":
return "https://api.openai.com"
case "anthropic":
return "https://api.anthropic.com"
default:
return ""
}
}

View File

@@ -0,0 +1,99 @@
package discovery
import (
"context"
"errors"
"strings"
"time"
"supply-intelligence/internal/domain"
)
var (
ErrInvalidCandidateInput = errors.New("invalid candidate input")
ErrPlatformNotSupported = errors.New("platform not supported in registry")
)
type CandidateRepository interface {
GetDiscoveryCandidateByIDContext(ctx context.Context, candidateID string) (domain.DiscoveryCandidate, bool)
FindDiscoveryCandidateContext(ctx context.Context, accountID int64, platform, model string) (domain.DiscoveryCandidate, bool)
UpsertDiscoveryCandidateContext(ctx context.Context, candidate domain.DiscoveryCandidate) domain.DiscoveryCandidate
ListDiscoveryCandidatesContext(ctx context.Context, status domain.DiscoveryCandidateStatus) []domain.DiscoveryCandidate
}
type Service struct {
repo CandidateRepository
now func() time.Time
}
type RecordCandidateInput struct {
CandidateID string
AccountID int64
Platform string
Model string
Source string
ReasonCode string
DiscoveredAt time.Time
}
type RecordCandidateOutput struct {
Candidate domain.DiscoveryCandidate `json:"candidate"`
Created bool `json:"created"`
}
func NewService(repo CandidateRepository) *Service {
return &Service{
repo: repo,
now: func() time.Time {
return time.Now().UTC()
},
}
}
func (s *Service) RecordCandidate(ctx context.Context, input RecordCandidateInput) (RecordCandidateOutput, error) {
if s == nil || s.repo == nil {
return RecordCandidateOutput{}, ErrInvalidCandidateInput
}
candidateID := strings.TrimSpace(input.CandidateID)
platform := strings.TrimSpace(input.Platform)
model := strings.TrimSpace(input.Model)
source := strings.TrimSpace(input.Source)
reasonCode := strings.TrimSpace(input.ReasonCode)
if candidateID == "" || input.AccountID <= 0 || platform == "" || model == "" || source == "" {
return RecordCandidateOutput{}, ErrInvalidCandidateInput
}
if existing, ok := s.repo.GetDiscoveryCandidateByIDContext(ctx, candidateID); ok {
return RecordCandidateOutput{Candidate: existing, Created: false}, nil
}
at := input.DiscoveredAt.UTC()
if at.IsZero() {
at = s.now()
}
if existing, ok := s.repo.FindDiscoveryCandidateContext(ctx, input.AccountID, platform, model); ok {
existing.Source = source
existing.ReasonCode = reasonCode
existing.UpdatedAt = at
existing.Version++
return RecordCandidateOutput{Candidate: s.repo.UpsertDiscoveryCandidateContext(ctx, existing), Created: false}, nil
}
candidate := domain.DiscoveryCandidate{
CandidateID: candidateID,
AccountID: input.AccountID,
Platform: platform,
Model: model,
Source: source,
Status: domain.DiscoveryCandidateStatusPendingAdmission,
ReasonCode: reasonCode,
DiscoveredAt: at,
UpdatedAt: at,
Version: 1,
}
return RecordCandidateOutput{Candidate: s.repo.UpsertDiscoveryCandidateContext(ctx, candidate), Created: true}, nil
}
func (s *Service) ListCandidates(ctx context.Context, status domain.DiscoveryCandidateStatus) []domain.DiscoveryCandidate {
if s == nil || s.repo == nil {
return nil
}
return s.repo.ListDiscoveryCandidatesContext(ctx, status)
}

View File

@@ -0,0 +1,160 @@
package discovery
import (
"context"
"testing"
"time"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/repository"
)
func TestRecordCandidateCreatesPendingAdmissionCandidate(t *testing.T) {
repo := repository.NewMemoryRepository()
service := NewService(repo)
at := time.Unix(100, 0).UTC()
out, err := service.RecordCandidate(context.Background(), RecordCandidateInput{
CandidateID: "cand-1",
AccountID: 10,
Platform: "openai",
Model: "gpt-4.1-mini",
Source: "manual_seed",
ReasonCode: "new_model",
DiscoveredAt: at,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !out.Created {
t.Fatalf("expected created candidate")
}
if out.Candidate.Status != domain.DiscoveryCandidateStatusPendingAdmission {
t.Fatalf("unexpected status: %q", out.Candidate.Status)
}
if out.Candidate.Version != 1 {
t.Fatalf("unexpected version: %d", out.Candidate.Version)
}
if !out.Candidate.DiscoveredAt.Equal(at) || !out.Candidate.UpdatedAt.Equal(at) {
t.Fatalf("unexpected timestamps: %+v", out.Candidate)
}
}
func TestRecordCandidateIsIdempotentByCandidateID(t *testing.T) {
repo := repository.NewMemoryRepository()
service := NewService(repo)
first, err := service.RecordCandidate(context.Background(), RecordCandidateInput{
CandidateID: "cand-1",
AccountID: 10,
Platform: "openai",
Model: "gpt-4.1-mini",
Source: "manual_seed",
})
if err != nil {
t.Fatalf("unexpected first error: %v", err)
}
second, err := service.RecordCandidate(context.Background(), RecordCandidateInput{
CandidateID: "cand-1",
AccountID: 99,
Platform: "other",
Model: "other-model",
Source: "other_source",
})
if err != nil {
t.Fatalf("unexpected second error: %v", err)
}
if second.Created {
t.Fatalf("expected idempotent replay")
}
if second.Candidate.AccountID != first.Candidate.AccountID || second.Candidate.Platform != first.Candidate.Platform || second.Candidate.Model != first.Candidate.Model {
t.Fatalf("expected original candidate to be preserved: %+v", second.Candidate)
}
}
func TestRecordCandidateDeduplicatesByBusinessKey(t *testing.T) {
repo := repository.NewMemoryRepository()
service := NewService(repo)
firstAt := time.Unix(100, 0).UTC()
secondAt := time.Unix(200, 0).UTC()
_, err := service.RecordCandidate(context.Background(), RecordCandidateInput{
CandidateID: "cand-1",
AccountID: 10,
Platform: "openai",
Model: "gpt-4.1-mini",
Source: "manual_seed",
ReasonCode: "first",
DiscoveredAt: firstAt,
})
if err != nil {
t.Fatalf("unexpected first error: %v", err)
}
out, err := service.RecordCandidate(context.Background(), RecordCandidateInput{
CandidateID: "cand-2",
AccountID: 10,
Platform: "openai",
Model: "gpt-4.1-mini",
Source: "scan",
ReasonCode: "second",
DiscoveredAt: secondAt,
})
if err != nil {
t.Fatalf("unexpected second error: %v", err)
}
if out.Created {
t.Fatalf("expected business-key dedupe")
}
if out.Candidate.CandidateID != "cand-1" {
t.Fatalf("expected original candidate id to be retained: %+v", out.Candidate)
}
if out.Candidate.Source != "scan" || out.Candidate.ReasonCode != "second" {
t.Fatalf("expected metadata update: %+v", out.Candidate)
}
if out.Candidate.Version != 2 {
t.Fatalf("expected version bump, got %d", out.Candidate.Version)
}
if !out.Candidate.UpdatedAt.Equal(secondAt) {
t.Fatalf("expected updated timestamp to change: %+v", out.Candidate)
}
}
func TestRecordCandidateRejectsInvalidInput(t *testing.T) {
repo := repository.NewMemoryRepository()
service := NewService(repo)
_, err := service.RecordCandidate(context.Background(), RecordCandidateInput{})
if err == nil {
t.Fatalf("expected invalid input error")
}
}
func TestListCandidatesFiltersByStatus(t *testing.T) {
repo := repository.NewMemoryRepository()
repo.UpsertDiscoveryCandidateContext(context.Background(), domain.DiscoveryCandidate{
CandidateID: "cand-1",
AccountID: 10,
Platform: "openai",
Model: "a",
Source: "seed",
Status: domain.DiscoveryCandidateStatusPendingAdmission,
DiscoveredAt: time.Unix(100, 0).UTC(),
UpdatedAt: time.Unix(100, 0).UTC(),
Version: 1,
})
repo.UpsertDiscoveryCandidateContext(context.Background(), domain.DiscoveryCandidate{
CandidateID: "cand-2",
AccountID: 11,
Platform: "openai",
Model: "b",
Source: "seed",
Status: domain.DiscoveryCandidateStatusAdmitted,
DiscoveredAt: time.Unix(200, 0).UTC(),
UpdatedAt: time.Unix(200, 0).UTC(),
Version: 1,
})
service := NewService(repo)
items := service.ListCandidates(context.Background(), domain.DiscoveryCandidateStatusPendingAdmission)
if len(items) != 1 || items[0].CandidateID != "cand-1" {
t.Fatalf("unexpected filtered items: %+v", items)
}
}

132
internal/domain/types.go Normal file
View File

@@ -0,0 +1,132 @@
package domain
import "time"
type AccountStatus string
const (
AccountStatusActive AccountStatus = "active"
AccountStatusSuspended AccountStatus = "suspended"
AccountStatusDisabled AccountStatus = "disabled"
AccountStatusPendingVerify AccountStatus = "pending_verify"
AccountStatusPendingEnable AccountStatus = "pending_enable"
)
type ProbeClassification string
const (
ProbeClassificationSuccess ProbeClassification = "success"
ProbeClassificationExplicitFailure ProbeClassification = "explicit_failure"
ProbeClassificationInconclusive ProbeClassification = "inconclusive"
)
type DiscoveryCandidateStatus string
const (
DiscoveryCandidateStatusPendingAdmission DiscoveryCandidateStatus = "pending_admission"
DiscoveryCandidateStatusAdmitted DiscoveryCandidateStatus = "admitted"
DiscoveryCandidateStatusRejected DiscoveryCandidateStatus = "rejected"
)
type GatewaySyncStatus string
const (
GatewaySyncStatusPending GatewaySyncStatus = "pending"
GatewaySyncStatusApplied GatewaySyncStatus = "applied"
GatewaySyncStatusFailed GatewaySyncStatus = "failed"
)
type GatewayAckResult string
const (
GatewayAckResultApplied GatewayAckResult = "applied"
GatewayAckResultFailed GatewayAckResult = "failed"
)
func (r GatewayAckResult) SyncStatus() GatewaySyncStatus {
switch r {
case GatewayAckResultApplied:
return GatewaySyncStatusApplied
case GatewayAckResultFailed:
return GatewaySyncStatusFailed
default:
return GatewaySyncStatusPending
}
}
type ProbeResult struct {
AccountID int64
Classification ProbeClassification
ReasonCode string
ObservedAt time.Time
}
type AccountRoutingState struct {
AccountID int64 `json:"account_id"`
Platform string `json:"platform"`
AccountStatus AccountStatus `json:"account_status"`
RoutingEnabled bool `json:"routing_enabled"`
RiskScore int `json:"risk_score"`
ReasonCode string `json:"reason_code"`
LastProbeAt time.Time `json:"last_probe_at"`
Version int64 `json:"version"`
}
type PackageChangeEvent struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
PackageID int64 `json:"package_id"`
Platform string `json:"platform"`
Model string `json:"model"`
OccurredAt time.Time `json:"occurred_at"`
Version int64 `json:"version"`
GatewaySyncStatus GatewaySyncStatus `json:"gateway_sync_status"`
Consumer string `json:"consumer,omitempty"`
ConsumerDetail string `json:"consumer_detail,omitempty"`
AckedAt *time.Time `json:"acked_at,omitempty"`
}
type PackageChangeAck struct {
EventID string `json:"event_id"`
Consumer string `json:"consumer"`
Result GatewayAckResult `json:"result"`
Detail string `json:"detail,omitempty"`
AckedAt time.Time `json:"acked_at"`
SyncState GatewaySyncStatus `json:"gateway_sync_status"`
}
type GatewayAppliedSnapshot struct {
Consumer string `json:"consumer"`
LastEventID string `json:"last_event_id"`
LastPackageID int64 `json:"last_package_id"`
LastPlatform string `json:"last_platform"`
LastModel string `json:"last_model"`
LastAppliedVersion int64 `json:"last_applied_version"`
LastResult string `json:"last_result"`
UpdatedAt time.Time `json:"updated_at"`
}
type DiscoveryCandidate struct {
CandidateID string `json:"candidate_id"`
AccountID int64 `json:"account_id"`
Platform string `json:"platform"`
Model string `json:"model"`
Source string `json:"source"`
Status DiscoveryCandidateStatus `json:"status"`
ReasonCode string `json:"reason_code,omitempty"`
DiscoveredAt time.Time `json:"discovered_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
}
// SupplyPackage represents a supply package in the system
type SupplyPackage struct {
PackageID int64 `json:"package_id"`
Platform string `json:"platform"`
Model string `json:"model"`
Status string `json:"status"` // draft, active, deprecated
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
}

View File

@@ -0,0 +1,110 @@
package gatewayconsumer
import (
"context"
"errors"
"strings"
"time"
"supply-intelligence/internal/domain"
)
var ErrInvalidConsumeInput = errors.New("invalid consume input")
type PackageChangeRepository interface {
ListPackageEventsAfter(cursor string) ([]domain.PackageChangeEvent, string)
AckPackageEvent(eventID, consumer string, result domain.GatewayAckResult, detail string, ackedAt time.Time) (domain.PackageChangeEvent, error)
UpsertGatewayAppliedSnapshot(snapshot domain.GatewayAppliedSnapshot) domain.GatewayAppliedSnapshot
}
type Service struct {
repo PackageChangeRepository
now func() time.Time
applier func(context.Context, domain.PackageChangeEvent) (domain.GatewayAckResult, string)
consumer string
}
type ConsumeOnceInput struct {
Consumer string
Cursor string
}
type ConsumeOnceOutput struct {
Consumer string `json:"consumer"`
NextCursor string `json:"next_cursor"`
Items []ConsumedPackageChangeItem `json:"items"`
}
type ConsumedPackageChangeItem struct {
EventID string `json:"event_id"`
PackageID int64 `json:"package_id"`
GatewaySyncStatus domain.GatewaySyncStatus `json:"gateway_sync_status"`
Result domain.GatewayAckResult `json:"result"`
Detail string `json:"detail,omitempty"`
}
func NewService(repo PackageChangeRepository) *Service {
return &Service{
repo: repo,
now: func() time.Time {
return time.Now().UTC()
},
consumer: "gateway",
applier: func(_ context.Context, event domain.PackageChangeEvent) (domain.GatewayAckResult, string) {
if strings.Contains(strings.ToLower(event.Model), "fail") {
return domain.GatewayAckResultFailed, "simulated apply failure"
}
return domain.GatewayAckResultApplied, "applied to gateway snapshot"
},
}
}
func (s *Service) SetApplier(applier func(context.Context, domain.PackageChangeEvent) (domain.GatewayAckResult, string)) {
s.applier = applier
}
func (s *Service) ConsumeOnce(ctx context.Context, input ConsumeOnceInput) (ConsumeOnceOutput, error) {
if s == nil || s.repo == nil || s.applier == nil {
return ConsumeOnceOutput{}, ErrInvalidConsumeInput
}
consumer := strings.TrimSpace(input.Consumer)
if consumer == "" {
consumer = s.consumer
}
items, nextCursor := s.repo.ListPackageEventsAfter(strings.TrimSpace(input.Cursor))
result := ConsumeOnceOutput{Consumer: consumer, NextCursor: nextCursor, Items: make([]ConsumedPackageChangeItem, 0, len(items))}
for _, event := range items {
if event.GatewaySyncStatus != domain.GatewaySyncStatusPending {
continue
}
ackResult, detail := s.applier(ctx, event)
if ackResult != domain.GatewayAckResultApplied && ackResult != domain.GatewayAckResultFailed {
return ConsumeOnceOutput{}, ErrInvalidConsumeInput
}
ackedAt := s.now()
if ackResult == domain.GatewayAckResultApplied {
s.repo.UpsertGatewayAppliedSnapshot(domain.GatewayAppliedSnapshot{
Consumer: consumer,
LastEventID: event.EventID,
LastPackageID: event.PackageID,
LastPlatform: event.Platform,
LastModel: event.Model,
LastAppliedVersion: event.Version,
LastResult: string(ackResult),
UpdatedAt: ackedAt,
})
}
updated, err := s.repo.AckPackageEvent(event.EventID, consumer, ackResult, detail, ackedAt)
if err != nil {
return ConsumeOnceOutput{}, err
}
result.Items = append(result.Items, ConsumedPackageChangeItem{
EventID: updated.EventID,
PackageID: updated.PackageID,
GatewaySyncStatus: updated.GatewaySyncStatus,
Result: ackResult,
Detail: detail,
})
}
return result, nil
}

View File

@@ -0,0 +1,89 @@
package gatewayconsumer
import (
"context"
"testing"
"time"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/repository"
)
func TestServiceConsumeOnceAppliedAndFailed(t *testing.T) {
repo := repository.NewMemoryRepository()
repo.AppendPackageEvent(domain.PackageChangeEvent{
EventID: "evt-applied",
EventType: "supply_package_published",
PackageID: 101,
Platform: "openai",
Model: "gpt-4.1-mini",
Version: 3,
OccurredAt: time.Unix(10, 0).UTC(),
GatewaySyncStatus: domain.GatewaySyncStatusPending,
})
repo.AppendPackageEvent(domain.PackageChangeEvent{
EventID: "evt-failed",
EventType: "supply_package_published",
PackageID: 102,
Platform: "openai",
Model: "gpt-fail-model",
Version: 4,
OccurredAt: time.Unix(20, 0).UTC(),
GatewaySyncStatus: domain.GatewaySyncStatusPending,
})
service := NewService(repo)
service.now = func() time.Time { return time.Unix(30, 0).UTC() }
out, err := service.ConsumeOnce(context.Background(), ConsumeOnceInput{Consumer: "gateway"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Items) != 2 {
t.Fatalf("unexpected item count: %d", len(out.Items))
}
if out.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
t.Fatalf("unexpected first status: %+v", out.Items[0])
}
if out.Items[1].GatewaySyncStatus != domain.GatewaySyncStatusFailed {
t.Fatalf("unexpected second status: %+v", out.Items[1])
}
events := repo.ListPackageEvents()
if events[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
t.Fatalf("expected applied event, got %+v", events[0])
}
if events[1].GatewaySyncStatus != domain.GatewaySyncStatusFailed {
t.Fatalf("expected failed event, got %+v", events[1])
}
snapshot, ok := repo.GetGatewayAppliedSnapshot("gateway")
if !ok {
t.Fatal("expected applied snapshot")
}
if snapshot.LastEventID != "evt-applied" || snapshot.LastPackageID != 101 {
t.Fatalf("unexpected snapshot: %+v", snapshot)
}
}
func TestServiceConsumeOnceRejectsInvalidApplierResult(t *testing.T) {
repo := repository.NewMemoryRepository()
repo.AppendPackageEvent(domain.PackageChangeEvent{
EventID: "evt-1",
EventType: "supply_package_published",
PackageID: 101,
Platform: "openai",
Model: "gpt-4.1-mini",
Version: 3,
OccurredAt: time.Unix(10, 0).UTC(),
GatewaySyncStatus: domain.GatewaySyncStatusPending,
})
service := NewService(repo)
service.SetApplier(func(context.Context, domain.PackageChangeEvent) (domain.GatewayAckResult, string) {
return domain.GatewayAckResult("unknown"), "bad"
})
_, err := service.ConsumeOnce(context.Background(), ConsumeOnceInput{})
if err != ErrInvalidConsumeInput {
t.Fatalf("unexpected error: %v", err)
}
}

12
internal/httpapi/parse.go Normal file
View 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
View 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)
}

View 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)
}
}

View 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())
}
}

View File

@@ -0,0 +1,67 @@
package integration
import (
"context"
"supply-intelligence/internal/domain"
)
// AccountStateReader defines the interface for reading account routing state
// from the supply-api repository layer
type AccountStateReader interface {
GetRoutingStateContext(ctx context.Context, accountID int64) (domain.AccountRoutingState, bool)
}
// CandidateStore defines the interface for persisting model candidates
type CandidateStore interface {
GetDiscoveryCandidateByIDContext(ctx context.Context, candidateID string) (domain.DiscoveryCandidate, bool)
FindDiscoveryCandidateContext(ctx context.Context, accountID int64, platform, model string) (domain.DiscoveryCandidate, bool)
UpsertDiscoveryCandidateContext(ctx context.Context, candidate domain.DiscoveryCandidate) domain.DiscoveryCandidate
ListDiscoveryCandidatesContext(ctx context.Context, status domain.DiscoveryCandidateStatus) []domain.DiscoveryCandidate
}
// PackageEventStore defines the interface for persisting package change events
type PackageEventStore interface {
AppendPackageEventContext(ctx context.Context, evt domain.PackageChangeEvent) (domain.PackageChangeEvent, error)
ListPackageEventsAfter(cursor string) ([]domain.PackageChangeEvent, string)
AckPackageEvent(eventID, consumer string, result domain.GatewayAckResult, detail string, ackedAt interface{}) (domain.PackageChangeEvent, error)
}
// ProbeLogStore defines the interface for persisting probe execution logs
type ProbeLogStore interface {
AppendProbeLog(ctx context.Context, log ProbeExecutionLog) error
ListProbeLogsByAccount(ctx context.Context, accountID int64, limit int) ([]ProbeExecutionLog, error)
}
// ProbeExecutionLog represents a single probe execution record
type ProbeExecutionLog struct {
LogID int64
AccountID int64
Platform string
ProbeResult domain.ProbeClassification
FailureClass string
HTTPStatus int
LatencyMs int
RiskScore int
EvaluatedTransition string
ExecutedAt interface{} // time.Time or string
RequestID string
Version int64
}
// NewAccountStateAdapter creates an adapter that connects to supply-api's account store
// For now, returns nil — actual implementation requires supply-api repo access
func NewAccountStateAdapter(repo interface{}) *AccountStateAdapter {
return &AccountStateAdapter{repo: repo}
}
// AccountStateAdapter implements AccountStateReader over supply-api repository
type AccountStateAdapter struct {
repo interface{}
}
func (a *AccountStateAdapter) GetRoutingStateContext(ctx context.Context, accountID int64) (domain.AccountRoutingState, bool) {
// TODO: implement when supply-api integration is ready
// This will call into supply-api's account repository
return domain.AccountRoutingState{}, false
}

View File

@@ -0,0 +1,242 @@
package integration
import (
"context"
"encoding/json"
"net/http"
)
// SupplierAdapter defines the interface for interacting with a supplier platform
type SupplierAdapter interface {
// Platform returns the platform name (e.g., "openai", "anthropic")
Platform() string
// ProbeAccount sends a health check request to the supplier API
// Returns the HTTP response details needed for probe classification
ProbeAccount(ctx context.Context, account SupplierAccount) ProbeResult
// GetModels fetches the list of available models from the supplier
GetModels(ctx context.Context, account SupplierAccount) ([]ModelInfo, error)
// HealthCheck verifies connectivity to the supplier API
HealthCheck(ctx context.Context, account SupplierAccount) error
}
// SupplierAccount holds credentials and configuration for a supplier account
type SupplierAccount struct {
AccountID int64
Platform string
APIKey string
BaseURL string // defaults to supplier's public endpoint if empty
Endpoint string // custom endpoint override
}
// ProbeResult holds the raw result of a probe request
type ProbeResult struct {
StatusCode int
TransportError error
ResponseBody string
}
// ModelInfo describes a model available from a supplier
type ModelInfo struct {
ModelID string // supplier's model identifier
ModelName string // display name
ContextLength int // max context length in tokens
IsActive bool // whether the model is currently available
}
// NewOpenAIAdapter creates an adapter for OpenAI-compatible APIs
func NewOpenAIAdapter(httpClient HTTPClient) SupplierAdapter {
return &OpenAIAdapter{httpClient: httpClient}
}
// OpenAIAdapter implements SupplierAdapter for OpenAI and OpenAI-compatible APIs
type OpenAIAdapter struct {
httpClient HTTPClient
}
func (a *OpenAIAdapter) Platform() string { return "openai" }
func (a *OpenAIAdapter) ProbeAccount(ctx context.Context, account SupplierAccount) ProbeResult {
baseURL := account.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com"
}
endpoint := account.Endpoint
if endpoint == "" {
endpoint = baseURL + "/v1/models"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ProbeResult{TransportError: err}
}
req.Header.Set("Authorization", "Bearer "+account.APIKey)
req.Header.Set("User-Agent", "supply-intelligence-probe/1.0")
resp, err := a.httpClient.Do(req)
if err != nil {
return ProbeResult{TransportError: err}
}
defer resp.Body.Close()
body := make([]byte, 1024)
n, _ := resp.Body.Read(body)
return ProbeResult{
StatusCode: resp.StatusCode,
ResponseBody: string(body[:n]),
}
}
func (a *OpenAIAdapter) GetModels(ctx context.Context, account SupplierAccount) ([]ModelInfo, error) {
baseURL := account.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com"
}
endpoint := baseURL + "/v1/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+account.APIKey)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Parse the OpenAI models list response
// {"object": "list", "data": [{"id": "gpt-4", "object": "model", ...}, ...]}
var raw struct {
Data []struct {
ID string `json:"id"`
Object string `json:"object"`
Context int `json:"context_window,omitempty"`
} `json:"data"`
}
if err := decodeJSON(resp, &raw); err != nil {
return nil, err
}
models := make([]ModelInfo, 0, len(raw.Data))
for _, m := range raw.Data {
if m.Object == "model" {
models = append(models, ModelInfo{
ModelID: m.ID,
ModelName: m.ID,
ContextLength: m.Context,
IsActive: true,
})
}
}
return models, nil
}
func (a *OpenAIAdapter) HealthCheck(ctx context.Context, account SupplierAccount) error {
result := a.ProbeAccount(ctx, account)
if result.TransportError != nil {
return result.TransportError
}
if result.StatusCode == http.StatusOK || result.StatusCode == http.StatusUnauthorized {
return nil
}
return ErrHealthCheckFailed
}
// NewAnthropicAdapter creates an adapter for Anthropic APIs
func NewAnthropicAdapter(httpClient HTTPClient) SupplierAdapter {
return &AnthropicAdapter{httpClient: httpClient}
}
// AnthropicAdapter implements SupplierAdapter for Anthropic Claude API
type AnthropicAdapter struct {
httpClient HTTPClient
}
func (a *AnthropicAdapter) Platform() string { return "anthropic" }
func (a *AnthropicAdapter) ProbeAccount(ctx context.Context, account SupplierAccount) ProbeResult {
baseURL := account.BaseURL
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
endpoint := baseURL + "/v1/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ProbeResult{TransportError: err}
}
req.Header.Set("x-api-key", account.APIKey)
req.Header.Set("User-Agent", "supply-intelligence-probe/1.0")
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := a.httpClient.Do(req)
if err != nil {
return ProbeResult{TransportError: err}
}
defer resp.Body.Close()
body := make([]byte, 1024)
n, _ := resp.Body.Read(body)
return ProbeResult{
StatusCode: resp.StatusCode,
ResponseBody: string(body[:n]),
}
}
func (a *AnthropicAdapter) GetModels(ctx context.Context, account SupplierAccount) ([]ModelInfo, error) {
// Anthropic doesn't have a public models list endpoint in the same way OpenAI does.
// We return a known static list for Claude models.
// In production this would be fetched from configuration or a dynamic source.
return []ModelInfo{
{ModelID: "claude-3-5-sonnet-20241022", ModelName: "Claude 3.5 Sonnet", ContextLength: 200000, IsActive: true},
{ModelID: "claude-3-5-haiku-20241022", ModelName: "Claude 3.5 Haiku", ContextLength: 200000, IsActive: true},
{ModelID: "claude-3-opus-20240229", ModelName: "Claude 3 Opus", ContextLength: 200000, IsActive: true},
{ModelID: "claude-3-sonnet-20240229", ModelName: "Claude 3 Sonnet", ContextLength: 200000, IsActive: true},
{ModelID: "claude-3-haiku-20240307", ModelName: "Claude 3 Haiku", ContextLength: 200000, IsActive: true},
}, nil
}
func (a *AnthropicAdapter) HealthCheck(ctx context.Context, account SupplierAccount) error {
result := a.ProbeAccount(ctx, account)
if result.TransportError != nil {
return result.TransportError
}
// Anthropic returns 200 on success, 401 on auth failure
if result.StatusCode == http.StatusOK || result.StatusCode == http.StatusUnauthorized {
return nil
}
return ErrHealthCheckFailed
}
// HTTPClient interface for testability
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// DefaultHTTPClient is the standard HTTP client used for platform adapters
type DefaultHTTPClient struct{}
func (c *DefaultHTTPClient) Do(req *http.Request) (*http.Response, error) {
return http.DefaultClient.Do(req)
}
// NewDefaultHTTPClient creates a new default HTTP client
func NewDefaultHTTPClient() HTTPClient {
return &DefaultHTTPClient{}
}
var ErrHealthCheckFailed = &HealthCheckError{}
type HealthCheckError struct{}
func (e *HealthCheckError) Error() string { return "health check failed" }
func decodeJSON(resp *http.Response, v interface{}) error {
return json.NewDecoder(resp.Body).Decode(v)
}

View File

@@ -0,0 +1,38 @@
package poller
import (
"context"
"supply-intelligence/internal/gatewayconsumer"
)
type GatewayPackagePoller struct {
consumer *gatewayconsumer.Service
cursor string
}
func NewGatewayPackagePoller(consumer *gatewayconsumer.Service) *GatewayPackagePoller {
return &GatewayPackagePoller{consumer: consumer}
}
func (p *GatewayPackagePoller) PollOnce(ctx context.Context) (gatewayconsumer.ConsumeOnceOutput, error) {
if p == nil || p.consumer == nil {
return gatewayconsumer.ConsumeOnceOutput{}, gatewayconsumer.ErrInvalidConsumeInput
}
out, err := p.consumer.ConsumeOnce(ctx, gatewayconsumer.ConsumeOnceInput{
Consumer: "gateway",
Cursor: p.cursor,
})
if err != nil {
return gatewayconsumer.ConsumeOnceOutput{}, err
}
p.cursor = out.NextCursor
return out, nil
}
func (p *GatewayPackagePoller) Cursor() string {
if p == nil {
return ""
}
return p.cursor
}

View File

@@ -0,0 +1,28 @@
package poller
import (
"context"
"testing"
"time"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/gatewayconsumer"
"supply-intelligence/internal/repository"
)
func TestGatewayPackagePollerPollOnce(t *testing.T) {
repo := repository.NewMemoryRepository()
repo.AppendPackageEvent(domain.PackageChangeEvent{EventID: "evt-1", EventType: "supply_package_published", PackageID: 1, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(1, 0).UTC(), Version: 1, GatewaySyncStatus: domain.GatewaySyncStatusPending})
poller := NewGatewayPackagePoller(gatewayconsumer.NewService(repo))
out, err := poller.PollOnce(context.Background())
if err != nil {
t.Fatalf("unexpected poll error: %v", err)
}
if len(out.Items) != 1 || out.Items[0].EventID != "evt-1" {
t.Fatalf("unexpected output: %+v", out)
}
if poller.Cursor() != out.NextCursor {
t.Fatalf("expected cursor to advance: poller=%q out=%q", poller.Cursor(), out.NextCursor)
}
}

View File

@@ -0,0 +1,53 @@
package poller
import (
"context"
"sync"
"time"
)
type Runtime struct {
poller *GatewayPackagePoller
interval time.Duration
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewRuntime(poller *GatewayPackagePoller, interval time.Duration) *Runtime {
if interval <= 0 {
interval = time.Second
}
return &Runtime{poller: poller, interval: interval}
}
func (r *Runtime) Start(parent context.Context) bool {
if r == nil || r.poller == nil || r.cancel != nil {
return false
}
ctx, cancel := context.WithCancel(parent)
r.cancel = cancel
r.wg.Add(1)
go func() {
defer r.wg.Done()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
_, _ = r.poller.PollOnce(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return true
}
func (r *Runtime) Stop() {
if r == nil || r.cancel == nil {
return
}
r.cancel()
r.wg.Wait()
r.cancel = nil
}

View File

@@ -0,0 +1,54 @@
package poller
import (
"context"
"testing"
"time"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/gatewayconsumer"
"supply-intelligence/internal/repository"
)
func TestRuntimeStartsBackgroundPolling(t *testing.T) {
repo := repository.NewMemoryRepository()
repo.AppendPackageEvent(domain.PackageChangeEvent{
EventID: "evt-runtime-1",
EventType: "supply_package_published",
PackageID: 1,
Platform: "openai",
Model: "gpt-4.1-mini",
OccurredAt: time.Unix(1, 0).UTC(),
Version: 1,
GatewaySyncStatus: domain.GatewaySyncStatusPending,
})
service := gatewayconsumer.NewService(repo)
poller := NewGatewayPackagePoller(service)
runtime := NewRuntime(poller, 10*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if !runtime.Start(ctx) {
t.Fatalf("expected runtime to start")
}
defer runtime.Stop()
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
items, _ := repo.ListPackageEventsAfter("")
if len(items) == 1 && items[0].GatewaySyncStatus == domain.GatewaySyncStatusApplied {
return
}
time.Sleep(10 * time.Millisecond)
}
items, _ := repo.ListPackageEventsAfter("")
t.Fatalf("expected background polling to apply event, got %+v", items)
}
func TestRuntimeStartRequiresPoller(t *testing.T) {
if (&Runtime{}).Start(context.Background()) {
t.Fatalf("expected runtime without poller to refuse start")
}
}

138
internal/probe/driver.go Normal file
View File

@@ -0,0 +1,138 @@
package probe
import (
"context"
"log"
"time"
"github.com/google/uuid"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/integration"
)
// ProbeLogRepository defines where probe execution logs are persisted
type ProbeLogRepository interface {
AppendProbeLog(ctx context.Context, outcome ProbeOutcome) error
}
// Driver orchestrates a full probe run: load targets → execute → evaluate → persist state
type Driver struct {
executor *ProbeExecutor
evaluator *Service // reuse the existing probe.Service as evaluator
logRepo ProbeLogRepository
adapters map[string]integration.SupplierAdapter
now func() time.Time
}
// NewDriver creates a probe driver with all dependencies wired together
func NewDriver(
repo RoutingStateRepository,
logRepo ProbeLogRepository,
adapters map[string]integration.SupplierAdapter,
) *Driver {
return &Driver{
executor: NewProbeExecutor(integration.NewDefaultHTTPClient()),
evaluator: NewService(repo),
logRepo: logRepo,
adapters: adapters,
now: func() time.Time { return time.Now().UTC() },
}
}
// RunProbeForAccount probes a single account and persists the result through the full chain
func (d *Driver) RunProbeForAccount(ctx context.Context, account integration.SupplierAccount) error {
var outcome ProbeOutcome
if adapter, ok := d.adapters[account.Platform]; ok {
// Use platform-specific adapter
result := adapter.ProbeAccount(ctx, account)
outcome = ProbeOutcome{
AccountID: account.AccountID,
Platform: account.Platform,
StatusCode: result.StatusCode,
TransportError: result.TransportError,
ResponseBody: result.ResponseBody,
RequestID: "prb-" + uuid.New().String(),
ExecutedAt: d.now(),
}
} else {
// Fall back to generic HTTP probe
target := ProbeTarget{
AccountID: account.AccountID,
Platform: account.Platform,
Endpoint: account.Endpoint,
AuthHeader: "Bearer " + account.APIKey,
}
if target.Endpoint == "" {
target.Endpoint = account.BaseURL
}
var err error
outcome, err = d.executor.ExecuteProbe(ctx, target)
if err != nil {
return err
}
}
return d.persistOutcome(ctx, account.AccountID, account.Platform, outcome)
}
// persistOutcome drives the outcome through: load current state → evaluate → state machine → persist
func (d *Driver) persistOutcome(ctx context.Context, accountID int64, platform string, outcome ProbeOutcome) error {
// 1. Load current routing state
currentState, _ := d.evaluator.repo.GetRoutingStateContext(ctx, accountID)
// 2. Build evaluate input
var transportErr error
if outcome.TransportError != nil {
transportErr = outcome.TransportError
}
input := EvaluateInput{
AccountID: accountID,
Platform: platform,
CurrentStatus: currentState.AccountStatus,
StatusCode: outcome.StatusCode,
TransportError: transportErr,
}
// 3. Evaluate (uses the existing Service.EvaluateHTTPResult)
evalOutput, err := d.evaluator.EvaluateHTTPResult(ctx, input)
if err != nil {
log.Printf("[probe] failed to evaluate outcome for account %d: %v", accountID, err)
return err
}
// 4. Log the probe execution
if d.logRepo != nil {
logEntry := ProbeOutcome{
AccountID: accountID,
Platform: platform,
StatusCode: outcome.StatusCode,
TransportError: outcome.TransportError,
LatencyMs: outcome.LatencyMs,
RequestID: outcome.RequestID,
ExecutedAt: outcome.ExecutedAt,
}
_ = d.logRepo.AppendProbeLog(ctx, logEntry)
}
// 5. Log state transition
transition := describeTransition(currentState.AccountStatus, evalOutput.RoutingState.AccountStatus)
log.Printf("[probe] account=%d platform=%s %s->%s classification=%s risk=%d transition=%s",
accountID, platform,
currentState.AccountStatus, evalOutput.RoutingState.AccountStatus,
evalOutput.Classification, evalOutput.RoutingState.RiskScore,
transition)
return nil
}
// describeTransition returns a human-readable transition description
func describeTransition(from, to domain.AccountStatus) string {
if from == to {
return "no_change"
}
return string(from) + "_to_" + string(to)
}

View File

@@ -0,0 +1,44 @@
package probe
import (
"errors"
"fmt"
"net/http"
"supply-intelligence/internal/domain"
)
var ErrUnknownStatusCode = errors.New("unknown probe status code")
func ClassifyHTTPResult(statusCode int, transportErr error) (domain.ProbeClassification, string, error) {
if transportErr != nil {
return domain.ProbeClassificationInconclusive, "transport_error", nil
}
switch statusCode {
case http.StatusOK:
return domain.ProbeClassificationSuccess, "ok", nil
case http.StatusUnauthorized:
fallthrough
case http.StatusForbidden:
return domain.ProbeClassificationExplicitFailure, "auth_rejected", nil
case http.StatusTooManyRequests:
fallthrough
case http.StatusInternalServerError:
fallthrough
case http.StatusBadGateway:
fallthrough
case http.StatusServiceUnavailable:
fallthrough
case http.StatusGatewayTimeout:
return domain.ProbeClassificationInconclusive, "upstream_unstable", nil
default:
if statusCode >= 500 {
return domain.ProbeClassificationInconclusive, "upstream_unstable", nil
}
if statusCode >= 400 {
return domain.ProbeClassificationInconclusive, "unexpected_client_error", nil
}
return "", "", fmt.Errorf("%w: %d", ErrUnknownStatusCode, statusCode)
}
}

View File

@@ -0,0 +1,47 @@
package probe
import (
"errors"
"testing"
"supply-intelligence/internal/domain"
)
func TestClassifyHTTPResult(t *testing.T) {
tests := []struct {
name string
statusCode int
err error
wantClass domain.ProbeClassification
wantReason string
wantErr bool
}{
{name: "200 success", statusCode: 200, wantClass: domain.ProbeClassificationSuccess, wantReason: "ok"},
{name: "401 explicit failure", statusCode: 401, wantClass: domain.ProbeClassificationExplicitFailure, wantReason: "auth_rejected"},
{name: "403 explicit failure", statusCode: 403, wantClass: domain.ProbeClassificationExplicitFailure, wantReason: "auth_rejected"},
{name: "429 inconclusive", statusCode: 429, wantClass: domain.ProbeClassificationInconclusive, wantReason: "upstream_unstable"},
{name: "503 inconclusive", statusCode: 503, wantClass: domain.ProbeClassificationInconclusive, wantReason: "upstream_unstable"},
{name: "transport error inconclusive", err: errors.New("timeout"), wantClass: domain.ProbeClassificationInconclusive, wantReason: "transport_error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotClass, gotReason, err := ClassifyHTTPResult(tt.statusCode, tt.err)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotClass != tt.wantClass {
t.Fatalf("classification mismatch: got %q want %q", gotClass, tt.wantClass)
}
if gotReason != tt.wantReason {
t.Fatalf("reason mismatch: got %q want %q", gotReason, tt.wantReason)
}
})
}
}

125
internal/probe/executor.go Normal file
View File

@@ -0,0 +1,125 @@
package probe
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/google/uuid"
)
// HTTPClient defines the interface for making HTTP requests during probing
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// DefaultHTTPClient wraps the standard http.Client
type DefaultHTTPClient struct {
client *http.Client
}
// NewDefaultHTTPClient creates a client with sensible probe timeouts
func NewDefaultHTTPClient() *DefaultHTTPClient {
return &DefaultHTTPClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *DefaultHTTPClient) Do(req *http.Request) (*http.Response, error) {
return c.client.Do(req)
}
// ProbeTarget represents an account to be probed
type ProbeTarget struct {
AccountID int64
Platform string
Endpoint string
AuthHeader string // Bearer token or API key
}
// ProbeOutcome is the result of executing a probe against a target
type ProbeOutcome struct {
AccountID int64
Platform string
StatusCode int
TransportError error
LatencyMs int
ResponseBody string // truncated, for debugging
RequestID string
ExecutedAt time.Time
}
// ProbeExecutor sends HTTP requests to supplier endpoints and classifies results
type ProbeExecutor struct {
httpClient HTTPClient
now func() time.Time
}
// NewProbeExecutor creates a probe executor with the given HTTP client.
// If client is nil, uses http.DefaultClient.
func NewProbeExecutor(client HTTPClient) *ProbeExecutor {
if client == nil {
client = http.DefaultClient
}
return &ProbeExecutor{
httpClient: client,
now: func() time.Time { return time.Now().UTC() },
}
}
// ExecuteProbe runs a single probe against the target account
// It makes an HTTP GET request to the platform's health endpoint
func (e *ProbeExecutor) ExecuteProbe(ctx context.Context, target ProbeTarget) (ProbeOutcome, error) {
requestID := uuid.New().String()
executedAt := e.now()
if target.Endpoint == "" {
return ProbeOutcome{}, ErrInvalidProbeTarget
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.Endpoint, nil)
if err != nil {
return ProbeOutcome{}, fmt.Errorf("%w: %v", ErrInvalidProbeTarget, err)
}
req.Header.Set("User-Agent", "supply-intelligence-probe/1.0")
req.Header.Set("Accept", "application/json")
if target.AuthHeader != "" {
req.Header.Set("Authorization", target.AuthHeader)
}
start := time.Now()
resp, err := e.httpClient.Do(req)
latencyMs := int(time.Since(start).Milliseconds())
outcome := ProbeOutcome{
AccountID: target.AccountID,
Platform: target.Platform,
LatencyMs: latencyMs,
RequestID: requestID,
ExecutedAt: executedAt,
}
if err != nil {
outcome.TransportError = err
return outcome, nil // return outcome with transport error set
}
if resp != nil {
defer resp.Body.Close()
outcome.StatusCode = resp.StatusCode
// Read truncated body for debugging (max 1KB)
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
outcome.ResponseBody = string(bodyBytes)
}
return outcome, nil
}
var ErrInvalidProbeTarget = errors.New("invalid probe target")

View File

@@ -0,0 +1,219 @@
package probe
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// mockHTTPClient records requests and returns configurable responses
type mockHTTPClient struct {
Resp *http.Response
Err error
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
// Simulate context cancellation: if the request context is done, return context error
select {
case <-req.Context().Done():
return nil, req.Context().Err()
default:
}
return m.Resp, m.Err
}
func TestProbeExecutor_ExecuteProbe_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}))
defer server.Close()
executor := NewProbeExecutor(nil) // nil → uses real http.Client
outcome, err := executor.ExecuteProbe(context.Background(), ProbeTarget{
AccountID: 1,
Platform: "openai",
Endpoint: server.URL,
AuthHeader: "Bearer test-key",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outcome.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got: %d", outcome.StatusCode)
}
if outcome.LatencyMs < 0 {
t.Fatalf("expected latency >= 0, got: %d", outcome.LatencyMs)
}
if outcome.RequestID == "" {
t.Fatal("expected request_id to be set")
}
}
func TestProbeExecutor_ExecuteProbe_ExplicitFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
executor := NewProbeExecutor(nil)
outcome, err := executor.ExecuteProbe(context.Background(), ProbeTarget{
AccountID: 2,
Platform: "openai",
Endpoint: server.URL,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outcome.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got: %d", outcome.StatusCode)
}
}
func TestProbeExecutor_ExecuteProbe_Inconclusive_429(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
}))
defer server.Close()
executor := NewProbeExecutor(nil)
outcome, err := executor.ExecuteProbe(context.Background(), ProbeTarget{
AccountID: 3,
Platform: "openai",
Endpoint: server.URL,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outcome.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected 429, got: %d", outcome.StatusCode)
}
}
func TestProbeExecutor_ExecuteProbe_TransportError(t *testing.T) {
client := &mockHTTPClient{
Err: errors.New("connection refused"),
}
executor := NewProbeExecutor(client)
outcome, err := executor.ExecuteProbe(context.Background(), ProbeTarget{
AccountID: 4,
Platform: "openai",
Endpoint: "http://localhost:9999",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outcome.TransportError == nil {
t.Fatal("expected transport error to be set")
}
if outcome.StatusCode != 0 {
t.Fatalf("expected status 0 on transport error, got: %d", outcome.StatusCode)
}
}
func TestProbeExecutor_ExecuteProbe_InvalidTarget(t *testing.T) {
executor := NewProbeExecutor(nil)
_, err := executor.ExecuteProbe(context.Background(), ProbeTarget{
AccountID: 5,
Platform: "openai",
Endpoint: "", // empty endpoint
})
if err == nil {
t.Fatal("expected error for empty endpoint")
}
}
func TestProbeExecutor_ExecuteProbe_ContextCanceled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second) // delay longer than context
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
executor := NewProbeExecutor(nil)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
outcome, err := executor.ExecuteProbe(ctx, ProbeTarget{
AccountID: 6,
Platform: "openai",
Endpoint: server.URL,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if outcome.TransportError == nil {
t.Fatal("expected context deadline exceeded transport error")
}
}
func TestProbeExecutor_ExecuteProbe_ResponseBodyTruncated(t *testing.T) {
largeBody := strings.Repeat("x", 10*1024) // 10KB
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(largeBody))
}))
defer server.Close()
executor := NewProbeExecutor(nil)
outcome, err := executor.ExecuteProbe(context.Background(), ProbeTarget{
AccountID: 7,
Platform: "openai",
Endpoint: server.URL,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(outcome.ResponseBody) > 1024 {
t.Fatalf("expected body truncated to <=1024, got: %d", len(outcome.ResponseBody))
}
}
func TestProbeExecutor_SetsUserAgentAndAcceptHeader(t *testing.T) {
var receivedHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
executor := NewProbeExecutor(nil)
_, _ = executor.ExecuteProbe(context.Background(), ProbeTarget{
AccountID: 8,
Platform: "openai",
Endpoint: server.URL,
AuthHeader: "Bearer my-key",
})
if receivedHeaders == nil {
t.Fatal("server handler was not called — check test setup")
}
if receivedHeaders.Get("User-Agent") == "" {
t.Fatal("expected User-Agent header to be set")
}
if receivedHeaders.Get("Accept") != "application/json" {
t.Fatalf("expected Accept: application/json, got: %s", receivedHeaders.Get("Accept"))
}
if receivedHeaders.Get("Authorization") != "Bearer my-key" {
t.Fatalf("expected Authorization header to be set")
}
}

95
internal/probe/service.go Normal file
View File

@@ -0,0 +1,95 @@
package probe
import (
"context"
"time"
"supply-intelligence/internal/domain"
)
type RoutingStateRepository interface {
GetRoutingStateContext(ctx context.Context, accountID int64) (domain.AccountRoutingState, bool)
UpsertRoutingStateContext(ctx context.Context, state domain.AccountRoutingState) domain.AccountRoutingState
}
type Service struct {
repo RoutingStateRepository
now func() time.Time
}
type EvaluateInput struct {
AccountID int64
Platform string
CurrentStatus domain.AccountStatus
StatusCode int
TransportError error
}
type EvaluateOutput struct {
Classification domain.ProbeClassification `json:"classification"`
ReasonCode string `json:"reason_code"`
RoutingState domain.AccountRoutingState `json:"routing_state"`
}
func NewService(repo RoutingStateRepository) *Service {
return &Service{
repo: repo,
now: func() time.Time {
return time.Now().UTC()
},
}
}
func (s *Service) EvaluateHTTPResult(ctx context.Context, input EvaluateInput) (EvaluateOutput, error) {
classification, reasonCode, err := ClassifyHTTPResult(input.StatusCode, input.TransportError)
if err != nil {
return EvaluateOutput{}, err
}
observedAt := s.now()
nextStatus := NextAccountStatus(input.CurrentStatus, classification)
state := domain.AccountRoutingState{
AccountID: input.AccountID,
Platform: input.Platform,
AccountStatus: nextStatus,
RoutingEnabled: nextStatus == domain.AccountStatusActive,
RiskScore: riskScoreFor(nextStatus, classification),
ReasonCode: reasonCode,
LastProbeAt: observedAt,
Version: 1,
}
if previous, ok := s.repo.GetRoutingStateContext(ctx, input.AccountID); ok {
state.Version = previous.Version + 1
if state.Platform == "" {
state.Platform = previous.Platform
}
}
persisted := s.repo.UpsertRoutingStateContext(ctx, state)
return EvaluateOutput{
Classification: classification,
ReasonCode: reasonCode,
RoutingState: persisted,
}, nil
}
func riskScoreFor(status domain.AccountStatus, classification domain.ProbeClassification) int {
switch classification {
case domain.ProbeClassificationSuccess:
return 20
case domain.ProbeClassificationExplicitFailure:
switch status {
case domain.AccountStatusDisabled:
return 100
case domain.AccountStatusSuspended:
return 90
default:
return 80
}
case domain.ProbeClassificationInconclusive:
return 60
default:
return 0
}
}

View File

@@ -0,0 +1,115 @@
package probe
import (
"context"
"errors"
"testing"
"time"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/repository"
)
func TestServiceEvaluateHTTPResultSuccess(t *testing.T) {
repo := repository.NewMemoryRepository()
service := NewService(repo)
service.now = func() time.Time { return time.Unix(1000, 0).UTC() }
result, err := service.EvaluateHTTPResult(context.Background(), EvaluateInput{
AccountID: 1,
Platform: "openai",
CurrentStatus: domain.AccountStatusSuspended,
StatusCode: 200,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Classification != domain.ProbeClassificationSuccess {
t.Fatalf("unexpected classification: %q", result.Classification)
}
if result.RoutingState.AccountStatus != domain.AccountStatusActive {
t.Fatalf("unexpected account status: %q", result.RoutingState.AccountStatus)
}
if !result.RoutingState.RoutingEnabled {
t.Fatalf("expected routing enabled")
}
if result.RoutingState.ReasonCode != "ok" {
t.Fatalf("unexpected reason code: %q", result.RoutingState.ReasonCode)
}
if result.RoutingState.Version != 1 {
t.Fatalf("unexpected version: %d", result.RoutingState.Version)
}
}
func TestServiceEvaluateHTTPResultExplicitFailure(t *testing.T) {
repo := repository.NewMemoryRepository()
service := NewService(repo)
service.now = func() time.Time { return time.Unix(1001, 0).UTC() }
repo.UpsertRoutingState(domain.AccountRoutingState{
AccountID: 2,
Platform: "openai",
AccountStatus: domain.AccountStatusActive,
RoutingEnabled: true,
RiskScore: 20,
ReasonCode: "ok",
LastProbeAt: time.Unix(999, 0).UTC(),
Version: 4,
})
result, err := service.EvaluateHTTPResult(context.Background(), EvaluateInput{
AccountID: 2,
Platform: "openai",
CurrentStatus: domain.AccountStatusActive,
StatusCode: 401,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Classification != domain.ProbeClassificationExplicitFailure {
t.Fatalf("unexpected classification: %q", result.Classification)
}
if result.RoutingState.AccountStatus != domain.AccountStatusSuspended {
t.Fatalf("unexpected account status: %q", result.RoutingState.AccountStatus)
}
if result.RoutingState.RoutingEnabled {
t.Fatalf("expected routing disabled")
}
if result.RoutingState.ReasonCode != "auth_rejected" {
t.Fatalf("unexpected reason code: %q", result.RoutingState.ReasonCode)
}
if result.RoutingState.Version != 5 {
t.Fatalf("unexpected version: %d", result.RoutingState.Version)
}
}
func TestServiceEvaluateHTTPResultInconclusive(t *testing.T) {
repo := repository.NewMemoryRepository()
service := NewService(repo)
service.now = func() time.Time { return time.Unix(1002, 0).UTC() }
result, err := service.EvaluateHTTPResult(context.Background(), EvaluateInput{
AccountID: 3,
Platform: "openai",
CurrentStatus: domain.AccountStatusSuspended,
TransportError: errors.New("dial tcp timeout"),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Classification != domain.ProbeClassificationInconclusive {
t.Fatalf("unexpected classification: %q", result.Classification)
}
if result.RoutingState.AccountStatus != domain.AccountStatusSuspended {
t.Fatalf("unexpected account status: %q", result.RoutingState.AccountStatus)
}
if result.RoutingState.RoutingEnabled {
t.Fatalf("expected routing disabled for suspended account")
}
if result.RoutingState.ReasonCode != "transport_error" {
t.Fatalf("unexpected reason code: %q", result.RoutingState.ReasonCode)
}
if result.RoutingState.RiskScore != 60 {
t.Fatalf("unexpected risk score: %d", result.RoutingState.RiskScore)
}
}

View File

@@ -0,0 +1,23 @@
package probe
import "supply-intelligence/internal/domain"
func NextAccountStatus(current domain.AccountStatus, classification domain.ProbeClassification) domain.AccountStatus {
switch classification {
case domain.ProbeClassificationSuccess:
return domain.AccountStatusActive
case domain.ProbeClassificationExplicitFailure:
switch current {
case domain.AccountStatusActive:
return domain.AccountStatusSuspended
case domain.AccountStatusSuspended:
return domain.AccountStatusDisabled
default:
return current
}
case domain.ProbeClassificationInconclusive:
fallthrough
default:
return current
}
}

View File

@@ -0,0 +1,30 @@
package probe
import (
"testing"
"supply-intelligence/internal/domain"
)
func TestNextAccountStatus(t *testing.T) {
tests := []struct {
name string
current domain.AccountStatus
classification domain.ProbeClassification
want domain.AccountStatus
}{
{name: "success keeps active", current: domain.AccountStatusActive, classification: domain.ProbeClassificationSuccess, want: domain.AccountStatusActive},
{name: "explicit failure active to suspended", current: domain.AccountStatusActive, classification: domain.ProbeClassificationExplicitFailure, want: domain.AccountStatusSuspended},
{name: "explicit failure suspended to disabled", current: domain.AccountStatusSuspended, classification: domain.ProbeClassificationExplicitFailure, want: domain.AccountStatusDisabled},
{name: "inconclusive keeps active", current: domain.AccountStatusActive, classification: domain.ProbeClassificationInconclusive, want: domain.AccountStatusActive},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NextAccountStatus(tt.current, tt.classification)
if got != tt.want {
t.Fatalf("status mismatch: got %q want %q", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,16 @@
# Publish semantics boundary
This package only records package-published events and emits gateway-consumable change records.
It does not implement a full publish state machine, admission workflow, or downstream routing synchronization.
Current repository boundary:
- `published` means the upstream package event has been recorded
- `pending` means the downstream gateway consumer has not yet confirmed handling
- `applied` / `failed` means the current repository's consumer flow updated event state during the running process
- current gateway event state in this repo is in-memory only, not durable across restart
Current runtime shape:
- manual/debug entry: `POST /internal/supply-intelligence/gateway/consume-once`
- minimal background path: application startup also runs a ticker-driven gateway poller
This avoids claiming that `published = applied`, and also avoids claiming that the current in-memory repository is a durable production persistence layer.

View File

@@ -0,0 +1,59 @@
package publish
import (
"context"
"errors"
"strings"
"time"
"supply-intelligence/internal/domain"
)
const PackagePublishedEventType = "supply_package_published"
var ErrInvalidPublishInput = errors.New("invalid publish input")
type PackageEventRepository interface {
AppendPackageEventContext(ctx context.Context, evt domain.PackageChangeEvent) (domain.PackageChangeEvent, error)
}
type Service struct {
repo PackageEventRepository
}
type RecordPackagePublishedInput struct {
EventID string
PackageID int64
Platform string
Model string
Version int64
OccurredAt time.Time
}
func NewService(repo PackageEventRepository) *Service {
return &Service{repo: repo}
}
func (s *Service) RecordPackagePublished(ctx context.Context, input RecordPackagePublishedInput) (domain.PackageChangeEvent, error) {
if s == nil || s.repo == nil {
return domain.PackageChangeEvent{}, ErrInvalidPublishInput
}
if strings.TrimSpace(input.EventID) == "" || input.PackageID <= 0 || strings.TrimSpace(input.Platform) == "" || strings.TrimSpace(input.Model) == "" || input.Version <= 0 {
return domain.PackageChangeEvent{}, ErrInvalidPublishInput
}
event := domain.PackageChangeEvent{
EventID: strings.TrimSpace(input.EventID),
EventType: PackagePublishedEventType,
PackageID: input.PackageID,
Platform: strings.TrimSpace(input.Platform),
Model: strings.TrimSpace(input.Model),
OccurredAt: input.OccurredAt.UTC(),
Version: input.Version,
GatewaySyncStatus: domain.GatewaySyncStatusPending,
}
if event.OccurredAt.IsZero() {
event.OccurredAt = time.Now().UTC()
}
return s.repo.AppendPackageEventContext(ctx, event)
}

View File

@@ -0,0 +1,66 @@
package publish
import (
"context"
"testing"
"time"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/repository"
)
func TestServiceRecordPackagePublished(t *testing.T) {
repo := repository.NewMemoryRepository()
service := NewService(repo)
occurredAt := time.Unix(1715000000, 0)
event, err := service.RecordPackagePublished(context.Background(), RecordPackagePublishedInput{
EventID: "evt-publish-1",
PackageID: 1001,
Platform: "openai",
Model: "gpt-4.1-mini",
Version: 3,
OccurredAt: occurredAt,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.EventID != "evt-publish-1" || event.EventType != PackagePublishedEventType {
t.Fatalf("unexpected event: %+v", event)
}
if !event.OccurredAt.Equal(occurredAt.UTC()) {
t.Fatalf("unexpected occurred_at: %s", event.OccurredAt)
}
if event.GatewaySyncStatus != domain.GatewaySyncStatusPending {
t.Fatalf("unexpected sync status: %q", event.GatewaySyncStatus)
}
items := repo.ListPackageEvents()
if len(items) != 1 {
t.Fatalf("unexpected items length: %d", len(items))
}
if items[0].EventID != event.EventID || items[0].Version != 3 {
t.Fatalf("unexpected stored event: %+v", items[0])
}
if items[0].GatewaySyncStatus != domain.GatewaySyncStatusPending {
t.Fatalf("unexpected stored sync status: %+v", items[0])
}
}
func TestServiceRecordPackagePublishedRejectsInvalidInput(t *testing.T) {
service := NewService(repository.NewMemoryRepository())
_, err := service.RecordPackagePublished(context.Background(), RecordPackagePublishedInput{
EventID: " ",
PackageID: 0,
Platform: "",
Model: "",
Version: 0,
})
if err == nil {
t.Fatal("expected error")
}
if err != ErrInvalidPublishInput {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -0,0 +1,278 @@
package repository
import (
"context"
"errors"
"sort"
"strconv"
"sync"
"time"
"supply-intelligence/internal/domain"
)
var ErrEventNotFound = errors.New("event not found")
func IsGatewayAckResult(result domain.GatewayAckResult) bool {
return result == domain.GatewayAckResultApplied || result == domain.GatewayAckResultFailed
}
type MemoryRepository struct {
mu sync.RWMutex
routingStates map[int64]domain.AccountRoutingState
packageEvents map[string]domain.PackageChangeEvent
appliedSnapshot map[string]domain.GatewayAppliedSnapshot
discoveryCandidates map[string]domain.DiscoveryCandidate
supplyPackages map[string]domain.SupplyPackage // key: platform+"_"+model
}
func NewMemoryRepository() *MemoryRepository {
return &MemoryRepository{
routingStates: map[int64]domain.AccountRoutingState{},
packageEvents: map[string]domain.PackageChangeEvent{},
appliedSnapshot: map[string]domain.GatewayAppliedSnapshot{},
discoveryCandidates: map[string]domain.DiscoveryCandidate{},
supplyPackages: map[string]domain.SupplyPackage{},
}
}
func (r *MemoryRepository) UpsertRoutingState(state domain.AccountRoutingState) {
r.upsertRoutingState(state)
}
func (r *MemoryRepository) UpsertRoutingStateContext(_ context.Context, state domain.AccountRoutingState) domain.AccountRoutingState {
return r.upsertRoutingState(state)
}
func (r *MemoryRepository) upsertRoutingState(state domain.AccountRoutingState) domain.AccountRoutingState {
r.mu.Lock()
defer r.mu.Unlock()
r.routingStates[state.AccountID] = state
return state
}
func (r *MemoryRepository) GetRoutingState(accountID int64) (domain.AccountRoutingState, bool) {
return r.getRoutingState(accountID)
}
func (r *MemoryRepository) GetRoutingStateContext(_ context.Context, accountID int64) (domain.AccountRoutingState, bool) {
return r.getRoutingState(accountID)
}
func (r *MemoryRepository) getRoutingState(accountID int64) (domain.AccountRoutingState, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
state, ok := r.routingStates[accountID]
return state, ok
}
func (r *MemoryRepository) AppendPackageEvent(evt domain.PackageChangeEvent) {
_, _ = r.AppendPackageEventContext(context.Background(), evt)
}
func (r *MemoryRepository) AppendPackageEventContext(_ context.Context, evt domain.PackageChangeEvent) (domain.PackageChangeEvent, error) {
r.mu.Lock()
defer r.mu.Unlock()
if evt.OccurredAt.IsZero() {
evt.OccurredAt = time.Now().UTC()
}
if evt.GatewaySyncStatus == "" {
evt.GatewaySyncStatus = domain.GatewaySyncStatusPending
}
r.packageEvents[evt.EventID] = evt
return evt, nil
}
func (r *MemoryRepository) ListPackageEvents() []domain.PackageChangeEvent {
items, _ := r.ListPackageEventsAfter("")
return items
}
func (r *MemoryRepository) ListPackageEventsAfter(cursor string) ([]domain.PackageChangeEvent, string) {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]domain.PackageChangeEvent, 0, len(r.packageEvents))
for _, evt := range r.packageEvents {
items = append(items, evt)
}
sort.Slice(items, func(i, j int) bool {
if items[i].OccurredAt.Equal(items[j].OccurredAt) {
return items[i].EventID < items[j].EventID
}
return items[i].OccurredAt.Before(items[j].OccurredAt)
})
if cursor == "" {
return items, nextCursorFor(items)
}
start := 0
if idx, err := strconv.Atoi(cursor); err == nil {
if idx < 0 {
idx = 0
}
if idx > len(items) {
idx = len(items)
}
start = idx
} else {
for i, evt := range items {
if evt.EventID == cursor {
start = i + 1
break
}
}
}
if start >= len(items) {
return []domain.PackageChangeEvent{}, ""
}
filtered := append([]domain.PackageChangeEvent(nil), items[start:]...)
return filtered, nextCursorFor(items)
}
func nextCursorFor(items []domain.PackageChangeEvent) string {
if len(items) == 0 {
return ""
}
return strconv.Itoa(len(items))
}
func (r *MemoryRepository) AckPackageEvent(eventID, consumer string, result domain.GatewayAckResult, detail string, ackedAt time.Time) (domain.PackageChangeEvent, error) {
r.mu.Lock()
defer r.mu.Unlock()
evt, ok := r.packageEvents[eventID]
if !ok {
return domain.PackageChangeEvent{}, ErrEventNotFound
}
if ackedAt.IsZero() {
ackedAt = time.Now().UTC()
}
evt.Consumer = consumer
evt.ConsumerDetail = detail
evt.GatewaySyncStatus = result.SyncStatus()
evt.AckedAt = &ackedAt
r.packageEvents[eventID] = evt
return evt, nil
}
func (r *MemoryRepository) UpsertGatewayAppliedSnapshot(snapshot domain.GatewayAppliedSnapshot) domain.GatewayAppliedSnapshot {
r.mu.Lock()
defer r.mu.Unlock()
if snapshot.UpdatedAt.IsZero() {
snapshot.UpdatedAt = time.Now().UTC()
}
r.appliedSnapshot[snapshot.Consumer] = snapshot
return snapshot
}
func (r *MemoryRepository) GetGatewayAppliedSnapshot(consumer string) (domain.GatewayAppliedSnapshot, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
snapshot, ok := r.appliedSnapshot[consumer]
return snapshot, ok
}
func (r *MemoryRepository) GetDiscoveryCandidateByIDContext(_ context.Context, candidateID string) (domain.DiscoveryCandidate, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
candidate, ok := r.discoveryCandidates[candidateID]
return candidate, ok
}
func (r *MemoryRepository) FindDiscoveryCandidateContext(_ context.Context, accountID int64, platform, model string) (domain.DiscoveryCandidate, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, candidate := range r.discoveryCandidates {
if candidate.AccountID == accountID && candidate.Platform == platform && candidate.Model == model {
return candidate, true
}
}
return domain.DiscoveryCandidate{}, false
}
func (r *MemoryRepository) UpsertDiscoveryCandidateContext(_ context.Context, candidate domain.DiscoveryCandidate) domain.DiscoveryCandidate {
r.mu.Lock()
defer r.mu.Unlock()
if candidate.DiscoveredAt.IsZero() {
candidate.DiscoveredAt = time.Now().UTC()
}
if candidate.UpdatedAt.IsZero() {
candidate.UpdatedAt = candidate.DiscoveredAt
}
r.discoveryCandidates[candidate.CandidateID] = candidate
return candidate
}
func (r *MemoryRepository) ListDiscoveryCandidatesContext(_ context.Context, status domain.DiscoveryCandidateStatus) []domain.DiscoveryCandidate {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]domain.DiscoveryCandidate, 0, len(r.discoveryCandidates))
for _, candidate := range r.discoveryCandidates {
if status != "" && candidate.Status != status {
continue
}
items = append(items, candidate)
}
sort.Slice(items, func(i, j int) bool {
if items[i].DiscoveredAt.Equal(items[j].DiscoveredAt) {
return items[i].CandidateID < items[j].CandidateID
}
return items[i].DiscoveredAt.Before(items[j].DiscoveredAt)
})
return items
}
// --- SupplyPackage methods ---
// UpsertSupplyPackage creates or updates a supply package
func (r *MemoryRepository) UpsertSupplyPackage(pkg domain.SupplyPackage) {
r.mu.Lock()
defer r.mu.Unlock()
key := pkg.Platform + "_" + pkg.Model
if existing, ok := r.supplyPackages[key]; ok {
pkg.PackageID = existing.PackageID
pkg.Version = existing.Version + 1
pkg.CreatedAt = existing.CreatedAt
}
if pkg.CreatedAt.IsZero() {
pkg.CreatedAt = time.Now().UTC()
}
pkg.UpdatedAt = time.Now().UTC()
r.supplyPackages[key] = pkg
}
// GetSupplyPackage retrieves a supply package by platform and model
func (r *MemoryRepository) GetSupplyPackage(platform, model string) (domain.SupplyPackage, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
key := platform + "_" + model
pkg, ok := r.supplyPackages[key]
return pkg, ok
}
// ListSupplyPackages returns all supply packages, optionally filtered by status
func (r *MemoryRepository) ListSupplyPackages(status string) []domain.SupplyPackage {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]domain.SupplyPackage, 0, len(r.supplyPackages))
for _, pkg := range r.supplyPackages {
if status == "" || pkg.Status == status {
items = append(items, pkg)
}
}
return items
}
// UpdateCandidateStatus updates a candidate's status (used by admission service)
func (r *MemoryRepository) UpdateCandidateStatus(ctx context.Context, candidateID string, status domain.DiscoveryCandidateStatus, failureCode, failureSummary string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.discoveryCandidates[candidateID]; !ok {
return errors.New("candidate not found")
}
c := r.discoveryCandidates[candidateID]
c.Status = status
c.ReasonCode = failureCode
c.UpdatedAt = time.Now().UTC()
c.Version++
r.discoveryCandidates[candidateID] = c
return nil
}

View File

@@ -0,0 +1,136 @@
package repository
import (
"testing"
"time"
"supply-intelligence/internal/domain"
)
func TestMemoryRepositoryRoutingState(t *testing.T) {
repo := NewMemoryRepository()
state := domain.AccountRoutingState{AccountID: 1, Platform: "openai", AccountStatus: domain.AccountStatusActive, RoutingEnabled: true, Version: 1}
repo.UpsertRoutingState(state)
got, ok := repo.GetRoutingState(1)
if !ok {
t.Fatalf("expected routing state")
}
if got.AccountStatus != domain.AccountStatusActive {
t.Fatalf("unexpected status: %q", got.AccountStatus)
}
}
func TestMemoryRepositoryPackageEventsAndAck(t *testing.T) {
repo := NewMemoryRepository()
evt := domain.PackageChangeEvent{EventID: "evt-1", EventType: "supply_package_published", PackageID: 1, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(10, 0).UTC(), Version: 2}
repo.AppendPackageEvent(evt)
items := repo.ListPackageEvents()
if len(items) != 1 {
t.Fatalf("expected 1 event, got %d", len(items))
}
ackedAt := time.Unix(20, 0).UTC()
updated, err := repo.AckPackageEvent("evt-1", "gateway", domain.GatewayAckResultApplied, "ok", ackedAt)
if err != nil {
t.Fatalf("unexpected ack error: %v", err)
}
if updated.GatewaySyncStatus != domain.GatewaySyncStatusApplied {
t.Fatalf("unexpected ack status: %+v", updated)
}
if updated.Consumer != "gateway" || updated.ConsumerDetail != "ok" {
t.Fatalf("unexpected consumer metadata: %+v", updated)
}
if updated.AckedAt == nil || !updated.AckedAt.Equal(ackedAt) {
t.Fatalf("unexpected ack time: %+v", updated)
}
}
func TestMemoryRepositoryListPackageEventsAfterCursor(t *testing.T) {
repo := NewMemoryRepository()
repo.AppendPackageEvent(domain.PackageChangeEvent{EventID: "evt-1", EventType: "supply_package_published", PackageID: 1, Platform: "openai", Model: "a", OccurredAt: time.Unix(10, 0).UTC(), Version: 1})
repo.AppendPackageEvent(domain.PackageChangeEvent{EventID: "evt-2", EventType: "supply_package_published", PackageID: 2, Platform: "openai", Model: "b", OccurredAt: time.Unix(20, 0).UTC(), Version: 2})
items, nextCursor := repo.ListPackageEventsAfter("")
if len(items) != 2 || nextCursor != "2" {
t.Fatalf("unexpected initial page: len=%d next=%q", len(items), nextCursor)
}
items, nextCursor = repo.ListPackageEventsAfter("1")
if len(items) != 1 || items[0].EventID != "evt-2" || nextCursor != "2" {
t.Fatalf("unexpected cursor page: items=%+v next=%q", items, nextCursor)
}
}
func TestMemoryRepositoryDiscoveryCandidateCRUD(t *testing.T) {
repo := NewMemoryRepository()
candidate := domain.DiscoveryCandidate{
CandidateID: "cand-1",
AccountID: 1,
Platform: "openai",
Model: "gpt-4.1-mini",
Source: "seed",
Status: domain.DiscoveryCandidateStatusPendingAdmission,
DiscoveredAt: time.Unix(10, 0).UTC(),
UpdatedAt: time.Unix(10, 0).UTC(),
Version: 1,
}
repo.UpsertDiscoveryCandidateContext(nil, candidate)
got, ok := repo.GetDiscoveryCandidateByIDContext(nil, "cand-1")
if !ok || got.CandidateID != "cand-1" {
t.Fatalf("expected candidate, got %+v ok=%v", got, ok)
}
}
func TestMemoryRepositoryFindDiscoveryCandidateByBusinessKey(t *testing.T) {
repo := NewMemoryRepository()
repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{
CandidateID: "cand-1",
AccountID: 1,
Platform: "openai",
Model: "gpt-4.1-mini",
Source: "seed",
Status: domain.DiscoveryCandidateStatusPendingAdmission,
DiscoveredAt: time.Unix(10, 0).UTC(),
UpdatedAt: time.Unix(10, 0).UTC(),
Version: 1,
})
got, ok := repo.FindDiscoveryCandidateContext(nil, 1, "openai", "gpt-4.1-mini")
if !ok || got.CandidateID != "cand-1" {
t.Fatalf("expected candidate by business key, got %+v ok=%v", got, ok)
}
}
func TestMemoryRepositoryListDiscoveryCandidatesByStatusAndOrder(t *testing.T) {
repo := NewMemoryRepository()
repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{
CandidateID: "cand-2",
AccountID: 2,
Platform: "openai",
Model: "b",
Source: "seed",
Status: domain.DiscoveryCandidateStatusAdmitted,
DiscoveredAt: time.Unix(20, 0).UTC(),
UpdatedAt: time.Unix(20, 0).UTC(),
Version: 1,
})
repo.UpsertDiscoveryCandidateContext(nil, domain.DiscoveryCandidate{
CandidateID: "cand-1",
AccountID: 1,
Platform: "openai",
Model: "a",
Source: "seed",
Status: domain.DiscoveryCandidateStatusPendingAdmission,
DiscoveredAt: time.Unix(10, 0).UTC(),
UpdatedAt: time.Unix(10, 0).UTC(),
Version: 1,
})
items := repo.ListDiscoveryCandidatesContext(nil, domain.DiscoveryCandidateStatusPendingAdmission)
if len(items) != 1 || items[0].CandidateID != "cand-1" {
t.Fatalf("unexpected filtered items: %+v", items)
}
all := repo.ListDiscoveryCandidatesContext(nil, "")
if len(all) != 2 || all[0].CandidateID != "cand-1" || all[1].CandidateID != "cand-2" {
t.Fatalf("unexpected ordering: %+v", all)
}
}