feat: bootstrap supply intelligence baseline
This commit is contained in:
27
internal/admission/repository.go
Normal file
27
internal/admission/repository.go
Normal 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"`
|
||||
}
|
||||
131
internal/admission/runner.go
Normal file
131
internal/admission/runner.go
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
169
internal/admission/runner_test.go
Normal file
169
internal/admission/runner_test.go
Normal 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
|
||||
}
|
||||
166
internal/admission/service.go
Normal file
166
internal/admission/service.go
Normal 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)
|
||||
}
|
||||
201
internal/admission/service_test.go
Normal file
201
internal/admission/service_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
62
internal/admission/types.go
Normal file
62
internal/admission/types.go
Normal 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
160
internal/app/app.go
Normal 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
85
internal/app/app_test.go
Normal 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
150
internal/control/module.go
Normal 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"
|
||||
}
|
||||
124
internal/control/module_test.go
Normal file
124
internal/control/module_test.go
Normal 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
|
||||
}
|
||||
161
internal/discovery/scheduler.go
Normal file
161
internal/discovery/scheduler.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
99
internal/discovery/service.go
Normal file
99
internal/discovery/service.go
Normal 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)
|
||||
}
|
||||
160
internal/discovery/service_test.go
Normal file
160
internal/discovery/service_test.go
Normal 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
132
internal/domain/types.go
Normal 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"`
|
||||
}
|
||||
110
internal/gatewayconsumer/service.go
Normal file
110
internal/gatewayconsumer/service.go
Normal 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
|
||||
}
|
||||
89
internal/gatewayconsumer/service_test.go
Normal file
89
internal/gatewayconsumer/service_test.go
Normal 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
12
internal/httpapi/parse.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package httpapi
|
||||
|
||||
import "strconv"
|
||||
|
||||
func parseInt64(input string, target *int64) (int64, error) {
|
||||
value, err := strconv.ParseInt(input, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
*target = value
|
||||
return value, nil
|
||||
}
|
||||
415
internal/httpapi/server.go
Normal file
415
internal/httpapi/server.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"supply-intelligence/internal/admission"
|
||||
"supply-intelligence/internal/discovery"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/gatewayconsumer"
|
||||
"supply-intelligence/internal/probe"
|
||||
"supply-intelligence/internal/publish"
|
||||
"supply-intelligence/internal/repository"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
repo *repository.MemoryRepository
|
||||
probeService *probe.Service
|
||||
publishService *publish.Service
|
||||
gatewayConsumerService *gatewayconsumer.Service
|
||||
discoveryService *discovery.Service
|
||||
admissionService *admission.Service
|
||||
}
|
||||
|
||||
type packageChangesResponse struct {
|
||||
Items []domain.PackageChangeEvent `json:"items"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
type discoveryCandidatesResponse struct {
|
||||
Items []domain.DiscoveryCandidate `json:"items"`
|
||||
}
|
||||
|
||||
func NewServer(repo *repository.MemoryRepository, probeService *probe.Service, publishService *publish.Service, gatewayConsumerService *gatewayconsumer.Service, discoveryService *discovery.Service, admissionService *admission.Service) *Server {
|
||||
return &Server{repo: repo, probeService: probeService, publishService: publishService, gatewayConsumerService: gatewayConsumerService, discoveryService: discoveryService, admissionService: admissionService}
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.handleHealth)
|
||||
mux.HandleFunc("/internal/supply-intelligence/accounts/", s.handleGetRoutingState)
|
||||
mux.HandleFunc("/internal/supply-intelligence/probe/evaluate", s.handleEvaluateProbe)
|
||||
mux.HandleFunc("/internal/supply-intelligence/publish/package-event", s.handlePublishPackageEvent)
|
||||
mux.HandleFunc("/internal/supply-intelligence/discovery/candidates", s.handleDiscoveryCandidates)
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/package-changes", s.handleListPackageChanges)
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/package-changes/", s.handleAckPackageChange)
|
||||
mux.HandleFunc("/internal/supply-intelligence/gateway/consume-once", s.handleConsumeOnce)
|
||||
mux.HandleFunc("/internal/supply-intelligence/admission/run", s.handleAdmissionRun)
|
||||
mux.HandleFunc("/internal/supply-intelligence/admission/candidates", s.handleAdmissionCandidates)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetRoutingState(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
prefix := "/internal/supply-intelligence/accounts/"
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if !strings.HasSuffix(path, "/routing-state") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
accountIDPart := strings.TrimSuffix(path, "/routing-state")
|
||||
var accountID int64
|
||||
if _, err := parseInt64(accountIDPart, &accountID); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"})
|
||||
return
|
||||
}
|
||||
state, ok := s.repo.GetRoutingState(accountID)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (s *Server) handleEvaluateProbe(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.probeService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "probe_service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
CurrentStatus string `json:"current_status"`
|
||||
StatusCode int `json:"status_code"`
|
||||
TransportError string `json:"transport_error"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
if payload.AccountID <= 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"})
|
||||
return
|
||||
}
|
||||
if payload.Platform == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_platform"})
|
||||
return
|
||||
}
|
||||
if payload.CurrentStatus == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_current_status"})
|
||||
return
|
||||
}
|
||||
|
||||
var transportErr error
|
||||
if payload.TransportError != "" {
|
||||
transportErr = errors.New(payload.TransportError)
|
||||
}
|
||||
|
||||
result, err := s.probeService.EvaluateHTTPResult(context.Background(), probe.EvaluateInput{
|
||||
AccountID: payload.AccountID,
|
||||
Platform: payload.Platform,
|
||||
CurrentStatus: domainAccountStatus(payload.CurrentStatus),
|
||||
StatusCode: payload.StatusCode,
|
||||
TransportError: transportErr,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishPackageEvent(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.publishService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "publish_service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
EventID string `json:"event_id"`
|
||||
PackageID int64 `json:"package_id"`
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
Version int64 `json:"version"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
|
||||
var occurredAt time.Time
|
||||
if payload.OccurredAt != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, payload.OccurredAt)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_occurred_at"})
|
||||
return
|
||||
}
|
||||
occurredAt = parsed
|
||||
}
|
||||
|
||||
event, err := s.publishService.RecordPackagePublished(r.Context(), publish.RecordPackagePublishedInput{
|
||||
EventID: payload.EventID,
|
||||
PackageID: payload.PackageID,
|
||||
Platform: payload.Platform,
|
||||
Model: payload.Model,
|
||||
Version: payload.Version,
|
||||
OccurredAt: occurredAt,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, publish.ErrInvalidPublishInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_publish_input"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, event)
|
||||
}
|
||||
|
||||
func (s *Server) handleDiscoveryCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
s.handleCreateDiscoveryCandidate(w, r)
|
||||
case http.MethodGet:
|
||||
s.handleListDiscoveryCandidates(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateDiscoveryCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.discoveryService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "discovery_service_unavailable"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
CandidateID string `json:"candidate_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
DiscoveredAt string `json:"discovered_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
var discoveredAt time.Time
|
||||
if strings.TrimSpace(payload.DiscoveredAt) != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, payload.DiscoveredAt)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_discovered_at"})
|
||||
return
|
||||
}
|
||||
discoveredAt = parsed
|
||||
}
|
||||
out, err := s.discoveryService.RecordCandidate(r.Context(), discovery.RecordCandidateInput{
|
||||
CandidateID: payload.CandidateID,
|
||||
AccountID: payload.AccountID,
|
||||
Platform: payload.Platform,
|
||||
Model: payload.Model,
|
||||
Source: payload.Source,
|
||||
ReasonCode: payload.ReasonCode,
|
||||
DiscoveredAt: discoveredAt,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, discovery.ErrInvalidCandidateInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_candidate_input"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleListDiscoveryCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
if s.discoveryService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "discovery_service_unavailable"})
|
||||
return
|
||||
}
|
||||
status, ok := parseDiscoveryCandidateStatus(strings.TrimSpace(r.URL.Query().Get("status")))
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_status"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, discoveryCandidatesResponse{Items: s.discoveryService.ListCandidates(r.Context(), status)})
|
||||
}
|
||||
|
||||
func parseDiscoveryCandidateStatus(raw string) (domain.DiscoveryCandidateStatus, bool) {
|
||||
if raw == "" {
|
||||
return "", true
|
||||
}
|
||||
status := domain.DiscoveryCandidateStatus(raw)
|
||||
switch status {
|
||||
case domain.DiscoveryCandidateStatusPendingAdmission, domain.DiscoveryCandidateStatusAdmitted, domain.DiscoveryCandidateStatusRejected:
|
||||
return status, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListPackageChanges(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
items, nextCursor := s.repo.ListPackageEventsAfter(strings.TrimSpace(r.URL.Query().Get("cursor")))
|
||||
writeJSON(w, http.StatusOK, packageChangesResponse{Items: items, NextCursor: nextCursor})
|
||||
}
|
||||
|
||||
func (s *Server) handleAckPackageChange(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
prefix := "/internal/supply-intelligence/gateway/package-changes/"
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if !strings.HasSuffix(path, "/ack") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
eventID := strings.TrimSuffix(path, "/ack")
|
||||
var payload struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Result string `json:"result"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
ackResult := domain.GatewayAckResult(payload.Result)
|
||||
if !repository.IsGatewayAckResult(ackResult) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_result"})
|
||||
return
|
||||
}
|
||||
consumer := strings.TrimSpace(payload.Consumer)
|
||||
if consumer == "" {
|
||||
consumer = "gateway"
|
||||
}
|
||||
_, err := s.repo.AckPackageEvent(eventID, consumer, ackResult, payload.Detail, time.Now().UTC())
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrEventNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleConsumeOnce(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.gatewayConsumerService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gateway_consumer_unavailable"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Cursor string `json:"cursor"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil && err.Error() != "EOF" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
}
|
||||
out, err := s.gatewayConsumerService.ConsumeOnce(r.Context(), gatewayconsumer.ConsumeOnceInput{Consumer: payload.Consumer, Cursor: payload.Cursor})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "consume_failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// handleAdmissionRun runs admission test for a specific candidate
|
||||
func (s *Server) handleAdmissionRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.admissionService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
CandidateID string `json:"candidate_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_json"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(payload.CandidateID) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing_candidate_id"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.admissionService.RunAdmission(r.Context(), payload.CandidateID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, admission.ErrCandidateNotFound):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "candidate_not_found"})
|
||||
case errors.Is(err, admission.ErrCandidateNotRunnable):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "candidate_not_runnable"})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_run_failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleAdmissionCandidates lists candidates pending admission testing
|
||||
func (s *Server) handleAdmissionCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
|
||||
return
|
||||
}
|
||||
if s.admissionService == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "admission_service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
candidates := s.admissionService.GetRunnableCandidates(r.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": candidates})
|
||||
}
|
||||
|
||||
func domainAccountStatus(raw string) domain.AccountStatus {
|
||||
return domain.AccountStatus(raw)
|
||||
}
|
||||
149
internal/httpapi/server_integration_test.go
Normal file
149
internal/httpapi/server_integration_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"supply-intelligence/internal/app"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/probe"
|
||||
)
|
||||
|
||||
func TestApplicationServerRoutes(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/probe/evaluate", bytes.NewBufferString(`{"account_id":7,"platform":"openai","current_status":"active","status_code":401}`))
|
||||
rr := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result probe.EvaluateOutput
|
||||
if err := json.NewDecoder(rr.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if result.RoutingState.AccountID != 7 || result.RoutingState.AccountStatus != "suspended" {
|
||||
t.Fatalf("unexpected state: %+v", result.RoutingState)
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/accounts/7/routing-state", nil)
|
||||
getRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(getRR, getReq)
|
||||
if getRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected get status: %d body=%s", getRR.Code, getRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConsumeOnceListAppliedIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
publishReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(`{"event_id":"evt-integration-1","package_id":501,"platform":"openai","model":"gpt-4.1-mini","version":9,"occurred_at":"2026-05-06T20:30:00Z"}`))
|
||||
publishRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(publishRR, publishReq)
|
||||
if publishRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected publish status: %d body=%s", publishRR.Code, publishRR.Body.String())
|
||||
}
|
||||
|
||||
consumeReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
||||
consumeRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(consumeRR, consumeReq)
|
||||
if consumeRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected consume status: %d body=%s", consumeRR.Code, consumeRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(listRR, listReq)
|
||||
if listRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected list status: %d body=%s", listRR.Code, listRR.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
Items []domain.PackageChangeEvent `json:"items"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list error: %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].EventID != "evt-integration-1" {
|
||||
t.Fatalf("unexpected list items: %+v", listResp.Items)
|
||||
}
|
||||
if listResp.NextCursor != "1" {
|
||||
t.Fatalf("unexpected next cursor: %+v", listResp)
|
||||
}
|
||||
if listResp.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("unexpected sync status: %+v", listResp.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConsumeOnceListFailedIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
publishReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", bytes.NewBufferString(`{"event_id":"evt-integration-failed","package_id":502,"platform":"openai","model":"gpt-fail-model","version":10,"occurred_at":"2026-05-06T20:31:00Z"}`))
|
||||
publishRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(publishRR, publishReq)
|
||||
if publishRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected publish status: %d body=%s", publishRR.Code, publishRR.Body.String())
|
||||
}
|
||||
|
||||
consumeReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
||||
consumeRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(consumeRR, consumeReq)
|
||||
if consumeRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected consume status: %d body=%s", consumeRR.Code, consumeRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(listRR, listReq)
|
||||
if listRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected list status: %d body=%s", listRR.Code, listRR.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
Items []domain.PackageChangeEvent `json:"items"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list error: %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].EventID != "evt-integration-failed" {
|
||||
t.Fatalf("unexpected list items: %+v", listResp.Items)
|
||||
}
|
||||
if listResp.NextCursor != "1" {
|
||||
t.Fatalf("unexpected next cursor: %+v", listResp)
|
||||
}
|
||||
if listResp.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusFailed {
|
||||
t.Fatalf("unexpected sync status: %+v", listResp.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryCandidateCreateAndListIntegration(t *testing.T) {
|
||||
application := app.New()
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/discovery/candidates", bytes.NewBufferString(`{"candidate_id":"cand-int-1","account_id":701,"platform":"openai","model":"gpt-4.1-mini","source":"manual_seed","reason_code":"new_model","discovered_at":"2026-05-06T20:30:00Z"}`))
|
||||
createRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(createRR, createReq)
|
||||
if createRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected create status: %d body=%s", createRR.Code, createRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/discovery/candidates?status=pending_admission", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
application.Server.Routes().ServeHTTP(listRR, listReq)
|
||||
if listRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected list status: %d body=%s", listRR.Code, listRR.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
Items []domain.DiscoveryCandidate `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list error: %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].CandidateID != "cand-int-1" {
|
||||
t.Fatalf("unexpected discovery list items: %+v", listResp.Items)
|
||||
}
|
||||
}
|
||||
266
internal/httpapi/server_test.go
Normal file
266
internal/httpapi/server_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"supply-intelligence/internal/discovery"
|
||||
"supply-intelligence/internal/domain"
|
||||
"supply-intelligence/internal/gatewayconsumer"
|
||||
"supply-intelligence/internal/probe"
|
||||
"supply-intelligence/internal/publish"
|
||||
"supply-intelligence/internal/repository"
|
||||
)
|
||||
|
||||
func TestServerRoutingStateEndpoint(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.UpsertRoutingState(domain.AccountRoutingState{
|
||||
AccountID: 101,
|
||||
Platform: "openai",
|
||||
AccountStatus: domain.AccountStatusActive,
|
||||
RoutingEnabled: true,
|
||||
RiskScore: 10,
|
||||
ReasonCode: "ok",
|
||||
LastProbeAt: time.Unix(100, 0).UTC(),
|
||||
Version: 3,
|
||||
})
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/accounts/101/routing-state", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got domain.AccountRoutingState
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if got.AccountID != 101 || got.AccountStatus != domain.AccountStatusActive {
|
||||
t.Fatalf("unexpected payload: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerProbeEvaluateEndpointPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantStatus int
|
||||
wantClassification domain.ProbeClassification
|
||||
wantAccountStatus domain.AccountStatus
|
||||
wantReasonCode string
|
||||
wantRoutingEnabled bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
body: `{"account_id":201,"platform":"openai","current_status":"suspended","status_code":200}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantClassification: domain.ProbeClassificationSuccess,
|
||||
wantAccountStatus: domain.AccountStatusActive,
|
||||
wantReasonCode: "ok",
|
||||
wantRoutingEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "explicit_failure",
|
||||
body: `{"account_id":202,"platform":"openai","current_status":"active","status_code":401}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantClassification: domain.ProbeClassificationExplicitFailure,
|
||||
wantAccountStatus: domain.AccountStatusSuspended,
|
||||
wantReasonCode: "auth_rejected",
|
||||
wantRoutingEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "inconclusive",
|
||||
body: `{"account_id":203,"platform":"openai","current_status":"suspended","transport_error":"dial tcp timeout"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantClassification: domain.ProbeClassificationInconclusive,
|
||||
wantAccountStatus: domain.AccountStatusSuspended,
|
||||
wantReasonCode: "transport_error",
|
||||
wantRoutingEnabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/probe/evaluate", bytes.NewBufferString(tt.body))
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != tt.wantStatus {
|
||||
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got probe.EvaluateOutput
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if got.Classification != tt.wantClassification {
|
||||
t.Fatalf("unexpected classification: %q", got.Classification)
|
||||
}
|
||||
if got.RoutingState.AccountStatus != tt.wantAccountStatus {
|
||||
t.Fatalf("unexpected account status: %q", got.RoutingState.AccountStatus)
|
||||
}
|
||||
if got.RoutingState.ReasonCode != tt.wantReasonCode {
|
||||
t.Fatalf("unexpected reason code: %q", got.RoutingState.ReasonCode)
|
||||
}
|
||||
if got.RoutingState.RoutingEnabled != tt.wantRoutingEnabled {
|
||||
t.Fatalf("unexpected routing enabled: %v", got.RoutingState.RoutingEnabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPublishPackageEventEndpoint(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
body := bytes.NewBufferString(`{"event_id":"evt-1","package_id":1001,"platform":"openai","model":"gpt-4.1-mini","version":7,"occurred_at":"2026-05-06T20:30:00Z"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/publish/package-event", body)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected publish status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var event domain.PackageChangeEvent
|
||||
if err := json.NewDecoder(rr.Body).Decode(&event); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if event.EventID != "evt-1" || event.EventType != publish.PackagePublishedEventType {
|
||||
t.Fatalf("unexpected event: %+v", event)
|
||||
}
|
||||
if event.GatewaySyncStatus != domain.GatewaySyncStatusPending {
|
||||
t.Fatalf("unexpected sync status: %q", event.GatewaySyncStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPackageChangeListAndAck(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.AppendPackageEvent(domain.PackageChangeEvent{EventID: "evt-1", EventType: publish.PackagePublishedEventType, PackageID: 1001, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(5, 0).UTC(), Version: 7, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(listRR, listReq)
|
||||
if listRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected list status: %d body=%s", listRR.Code, listRR.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
Items []domain.PackageChangeEvent `json:"items"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list error: %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.NextCursor != "1" {
|
||||
t.Fatalf("unexpected list response: %+v", listResp)
|
||||
}
|
||||
|
||||
ackReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/package-changes/evt-1/ack", bytes.NewBufferString(`{"consumer":"gateway","result":"applied","detail":"ok"}`))
|
||||
ackRR := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(ackRR, ackReq)
|
||||
if ackRR.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected ack status: %d body=%s", ackRR.Code, ackRR.Body.String())
|
||||
}
|
||||
updated, _ := repo.ListPackageEventsAfter("")
|
||||
if len(updated) != 1 || updated[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied {
|
||||
t.Fatalf("unexpected ack state: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPackageChangeListWithCursor(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.AppendPackageEvent(domain.PackageChangeEvent{EventID: "evt-1", EventType: publish.PackagePublishedEventType, PackageID: 1001, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(5, 0).UTC(), Version: 7, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
||||
repo.AppendPackageEvent(domain.PackageChangeEvent{EventID: "evt-2", EventType: publish.PackagePublishedEventType, PackageID: 1002, Platform: "openai", Model: "gpt-4.1", OccurredAt: time.Unix(6, 0).UTC(), Version: 8, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/gateway/package-changes?cursor=1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Items []domain.PackageChangeEvent `json:"items"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 || resp.Items[0].EventID != "evt-2" || resp.NextCursor != "2" {
|
||||
t.Fatalf("unexpected cursor response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConsumeOnceEndpoint(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
repo.AppendPackageEvent(domain.PackageChangeEvent{EventID: "evt-apply", EventType: publish.PackagePublishedEventType, PackageID: 1001, Platform: "openai", Model: "gpt-4.1-mini", OccurredAt: time.Unix(5, 0).UTC(), Version: 7, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
||||
repo.AppendPackageEvent(domain.PackageChangeEvent{EventID: "evt-fail", EventType: publish.PackagePublishedEventType, PackageID: 1002, Platform: "openai", Model: "gpt-fail-model", OccurredAt: time.Unix(6, 0).UTC(), Version: 8, GatewaySyncStatus: domain.GatewaySyncStatusPending})
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/gateway/consume-once", bytes.NewBufferString(`{"consumer":"gateway"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected consume status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out gatewayconsumer.ConsumeOnceOutput
|
||||
if err := json.NewDecoder(rr.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(out.Items) != 2 {
|
||||
t.Fatalf("unexpected consume output length: %+v", out)
|
||||
}
|
||||
if out.Items[0].Result != domain.GatewayAckResultApplied || out.Items[0].GatewaySyncStatus != domain.GatewaySyncStatusApplied || out.Items[0].Detail == "" {
|
||||
t.Fatalf("unexpected first consume item: %+v", out.Items[0])
|
||||
}
|
||||
if out.Items[1].Result != domain.GatewayAckResultFailed || out.Items[1].GatewaySyncStatus != domain.GatewaySyncStatusFailed || out.Items[1].Detail == "" {
|
||||
t.Fatalf("unexpected second consume item: %+v", out.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDiscoveryCandidateCreateAndList(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/discovery/candidates", bytes.NewBufferString(`{"candidate_id":"cand-1","account_id":301,"platform":"openai","model":"gpt-4.1-mini","source":"manual_seed","reason_code":"new_model","discovered_at":"2026-05-06T20:30:00Z"}`))
|
||||
createRR := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(createRR, createReq)
|
||||
if createRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected create status: %d body=%s", createRR.Code, createRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/internal/supply-intelligence/discovery/candidates?status=pending_admission", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(listRR, listReq)
|
||||
if listRR.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected list status: %d body=%s", listRR.Code, listRR.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
Items []domain.DiscoveryCandidate `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(listRR.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list error: %v", err)
|
||||
}
|
||||
if len(listResp.Items) != 1 || listResp.Items[0].CandidateID != "cand-1" || listResp.Items[0].Status != domain.DiscoveryCandidateStatusPendingAdmission {
|
||||
t.Fatalf("unexpected discovery list response: %+v", listResp.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDiscoveryCandidateRejectsInvalidInput(t *testing.T) {
|
||||
repo := repository.NewMemoryRepository()
|
||||
server := NewServer(repo, probe.NewService(repo), publish.NewService(repo), gatewayconsumer.NewService(repo), discovery.NewService(repo), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/supply-intelligence/discovery/candidates", bytes.NewBufferString(`{"candidate_id":"","account_id":0}`))
|
||||
rr := httptest.NewRecorder()
|
||||
server.Routes().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
67
internal/integration/adapter.go
Normal file
67
internal/integration/adapter.go
Normal 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
|
||||
}
|
||||
242
internal/integration/platform.go
Normal file
242
internal/integration/platform.go
Normal 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)
|
||||
}
|
||||
38
internal/poller/gateway_package_poller.go
Normal file
38
internal/poller/gateway_package_poller.go
Normal 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
|
||||
}
|
||||
28
internal/poller/gateway_package_poller_test.go
Normal file
28
internal/poller/gateway_package_poller_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
53
internal/poller/runtime.go
Normal file
53
internal/poller/runtime.go
Normal 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
|
||||
}
|
||||
54
internal/poller/runtime_test.go
Normal file
54
internal/poller/runtime_test.go
Normal 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
138
internal/probe/driver.go
Normal 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)
|
||||
}
|
||||
44
internal/probe/evaluator.go
Normal file
44
internal/probe/evaluator.go
Normal 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)
|
||||
}
|
||||
}
|
||||
47
internal/probe/evaluator_test.go
Normal file
47
internal/probe/evaluator_test.go
Normal 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
125
internal/probe/executor.go
Normal 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")
|
||||
219
internal/probe/executor_test.go
Normal file
219
internal/probe/executor_test.go
Normal 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
95
internal/probe/service.go
Normal 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
|
||||
}
|
||||
}
|
||||
115
internal/probe/service_test.go
Normal file
115
internal/probe/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
internal/probe/state_machine.go
Normal file
23
internal/probe/state_machine.go
Normal 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
|
||||
}
|
||||
}
|
||||
30
internal/probe/state_machine_test.go
Normal file
30
internal/probe/state_machine_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
16
internal/publish/README.md
Normal file
16
internal/publish/README.md
Normal 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.
|
||||
59
internal/publish/service.go
Normal file
59
internal/publish/service.go
Normal 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)
|
||||
}
|
||||
66
internal/publish/service_test.go
Normal file
66
internal/publish/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
278
internal/repository/memory.go
Normal file
278
internal/repository/memory.go
Normal 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
|
||||
}
|
||||
136
internal/repository/memory_test.go
Normal file
136
internal/repository/memory_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user