feat: add Go client package with sync facade over async /jobs
Adds client/ -- a public Go package providing a synchronous facade over
foreman's async POST /jobs API (Level 1 integration per ADR-0011).
Two delivery modes:
- Webhook receiver (preferred): ephemeral HTTP server on random port,
pushes results immediately, verifies HMAC when configured
- Polling fallback: polls GET /jobs/{id} at configurable interval
Also includes Tags() and Embed() helpers, bearer auth support, and
comprehensive integration tests against the real foreman HTTP handlers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/foreman/client"
|
||||
"gitea.stevedudenhoeffer.com/steve/foreman/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/foreman/internal/ollama"
|
||||
"gitea.stevedudenhoeffer.com/steve/foreman/internal/server"
|
||||
"gitea.stevedudenhoeffer.com/steve/foreman/internal/store"
|
||||
"gitea.stevedudenhoeffer.com/steve/foreman/internal/webhook"
|
||||
"gitea.stevedudenhoeffer.com/steve/foreman/internal/worker"
|
||||
)
|
||||
|
||||
// newTestForeman creates a fully wired foreman server (store, worker, dispatcher)
|
||||
// backed by a temp SQLite database and stub Ollama client. Returns the httptest
|
||||
// server URL and a cleanup function.
|
||||
//
|
||||
// Why: the client tests exercise the real HTTP API to catch integration bugs.
|
||||
// What: wires store, inventory, notifier, dispatcher, worker, and server into an
|
||||
// httptest.Server.
|
||||
// Test: used as a helper in all client_test.go test functions.
|
||||
func newTestForeman(t *testing.T, ollamaClient ollama.Client, webhookSecret string) string {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
inv := ollama.NewModelInventory(ollamaClient, logger)
|
||||
if err := inv.Refresh(context.Background()); err != nil {
|
||||
t.Fatalf("inv.Refresh: %v", err)
|
||||
}
|
||||
|
||||
notifier := worker.NewNotifier()
|
||||
dispatcher := webhook.NewDispatcher(webhookSecret, logger)
|
||||
w := worker.New(st, ollamaClient, inv, notifier, dispatcher, logger)
|
||||
|
||||
cfg := config.Config{
|
||||
OllamaURL: "http://localhost:11434",
|
||||
MaxAttempts: 3,
|
||||
JobTTL: 24 * time.Hour,
|
||||
WebhookSecret: webhookSecret,
|
||||
}
|
||||
|
||||
srv := server.New(cfg, st, ollamaClient, inv, notifier, w, dispatcher, logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
go w.Run(ctx)
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return ts.URL
|
||||
}
|
||||
|
||||
func TestSubmit_HappyPath_Polling(t *testing.T) {
|
||||
ollamaStub := &stubOllamaClient{
|
||||
tags: &ollama.TagsResponse{
|
||||
Models: []ollama.ModelInfo{{Name: "qwen3:30b"}},
|
||||
},
|
||||
ps: &ollama.PsResponse{},
|
||||
chatFunc: func(ctx context.Context, req ollama.ChatRequest, stream bool) (*ollama.ChatResponse, <-chan ollama.ChatResponse, error) {
|
||||
return &ollama.ChatResponse{
|
||||
Model: req.Model,
|
||||
Done: true,
|
||||
Message: &ollama.Message{Role: "assistant", Content: "Hello from foreman!"},
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
baseURL := newTestForeman(t, ollamaStub, "")
|
||||
c := client.New(baseURL, client.WithPollingMode(), client.WithPollInterval(100*time.Millisecond))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := c.Submit(ctx, client.SubmitRequest{
|
||||
Model: "qwen3:30b",
|
||||
Messages: []json.RawMessage{json.RawMessage(`{"role":"user","content":"hi"}`)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
|
||||
if result.State != "done" {
|
||||
t.Errorf("state = %q, want %q", result.State, "done")
|
||||
}
|
||||
if result.JobID == "" {
|
||||
t.Error("job_id should not be empty")
|
||||
}
|
||||
if result.Model != "qwen3:30b" {
|
||||
t.Errorf("model = %q, want %q", result.Model, "qwen3:30b")
|
||||
}
|
||||
if len(result.Result) == 0 {
|
||||
t.Error("result should not be empty on done")
|
||||
}
|
||||
if len(result.Artifacts) == 0 {
|
||||
t.Error("artifacts should include the completion")
|
||||
}
|
||||
|
||||
// Verify the result contains the expected chat response.
|
||||
var chatResp ollama.ChatResponse
|
||||
if err := json.Unmarshal(result.Result, &chatResp); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if chatResp.Message == nil || chatResp.Message.Content != "Hello from foreman!" {
|
||||
t.Errorf("chat content = %v, want 'Hello from foreman!'", chatResp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_HappyPath_Webhook(t *testing.T) {
|
||||
ollamaStub := &stubOllamaClient{
|
||||
tags: &ollama.TagsResponse{
|
||||
Models: []ollama.ModelInfo{{Name: "qwen3:30b"}},
|
||||
},
|
||||
ps: &ollama.PsResponse{},
|
||||
chatFunc: func(ctx context.Context, req ollama.ChatRequest, stream bool) (*ollama.ChatResponse, <-chan ollama.ChatResponse, error) {
|
||||
return &ollama.ChatResponse{
|
||||
Model: req.Model,
|
||||
Done: true,
|
||||
Message: &ollama.Message{Role: "assistant", Content: "webhook response"},
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
baseURL := newTestForeman(t, ollamaStub, "")
|
||||
c := client.New(baseURL) // Webhook mode (default)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := c.Submit(ctx, client.SubmitRequest{
|
||||
Model: "qwen3:30b",
|
||||
Messages: []json.RawMessage{json.RawMessage(`{"role":"user","content":"hi"}`)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
|
||||
if result.State != "done" {
|
||||
t.Errorf("state = %q, want %q", result.State, "done")
|
||||
}
|
||||
if result.JobID == "" {
|
||||
t.Error("job_id should not be empty")
|
||||
}
|
||||
|
||||
// Verify the result.
|
||||
var chatResp ollama.ChatResponse
|
||||
if err := json.Unmarshal(result.Result, &chatResp); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if chatResp.Message == nil || chatResp.Message.Content != "webhook response" {
|
||||
t.Errorf("chat content = %v, want 'webhook response'", chatResp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_FailedJob(t *testing.T) {
|
||||
ollamaStub := &stubOllamaClient{
|
||||
tags: &ollama.TagsResponse{
|
||||
Models: []ollama.ModelInfo{{Name: "qwen3:30b"}},
|
||||
},
|
||||
ps: &ollama.PsResponse{},
|
||||
chatFunc: func(ctx context.Context, req ollama.ChatRequest, stream bool) (*ollama.ChatResponse, <-chan ollama.ChatResponse, error) {
|
||||
return nil, nil, &ollama.HTTPError{StatusCode: 500, Body: "internal error"}
|
||||
},
|
||||
}
|
||||
|
||||
baseURL := newTestForeman(t, ollamaStub, "")
|
||||
c := client.New(baseURL, client.WithPollingMode(), client.WithPollInterval(100*time.Millisecond))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := c.Submit(ctx, client.SubmitRequest{
|
||||
Model: "qwen3:30b",
|
||||
Messages: []json.RawMessage{json.RawMessage(`{"role":"user","content":"hi"}`)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
|
||||
if result.State != "failed" {
|
||||
t.Errorf("state = %q, want %q", result.State, "failed")
|
||||
}
|
||||
if result.Error == "" {
|
||||
t.Error("error should not be empty on failed job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_ContextTimeout(t *testing.T) {
|
||||
ollamaStub := &stubOllamaClient{
|
||||
tags: &ollama.TagsResponse{
|
||||
Models: []ollama.ModelInfo{{Name: "qwen3:30b"}},
|
||||
},
|
||||
ps: &ollama.PsResponse{},
|
||||
chatFunc: func(ctx context.Context, req ollama.ChatRequest, stream bool) (*ollama.ChatResponse, <-chan ollama.ChatResponse, error) {
|
||||
// Block until context cancelled.
|
||||
<-ctx.Done()
|
||||
return nil, nil, ctx.Err()
|
||||
},
|
||||
}
|
||||
|
||||
baseURL := newTestForeman(t, ollamaStub, "")
|
||||
c := client.New(baseURL, client.WithPollingMode(), client.WithPollInterval(50*time.Millisecond))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.Submit(ctx, client.SubmitRequest{
|
||||
Model: "qwen3:30b",
|
||||
Messages: []json.RawMessage{json.RawMessage(`{"role":"user","content":"hi"}`)},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on context timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_AuthToken(t *testing.T) {
|
||||
ollamaStub := &stubOllamaClient{
|
||||
tags: &ollama.TagsResponse{
|
||||
Models: []ollama.ModelInfo{{Name: "qwen3:30b"}},
|
||||
},
|
||||
ps: &ollama.PsResponse{},
|
||||
chatFunc: func(ctx context.Context, req ollama.ChatRequest, stream bool) (*ollama.ChatResponse, <-chan ollama.ChatResponse, error) {
|
||||
return &ollama.ChatResponse{
|
||||
Model: req.Model,
|
||||
Done: true,
|
||||
Message: &ollama.Message{Role: "assistant", Content: "ok"},
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
// Create a foreman with auth required.
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
inv := ollama.NewModelInventory(ollamaStub, logger)
|
||||
inv.Refresh(context.Background())
|
||||
|
||||
notifier := worker.NewNotifier()
|
||||
dispatcher := webhook.NewDispatcher("", logger)
|
||||
w := worker.New(st, ollamaStub, inv, notifier, dispatcher, logger)
|
||||
|
||||
cfg := config.Config{
|
||||
OllamaURL: "http://localhost:11434",
|
||||
Token: "my-secret-token",
|
||||
MaxAttempts: 3,
|
||||
JobTTL: 24 * time.Hour,
|
||||
}
|
||||
|
||||
srv := server.New(cfg, st, ollamaStub, inv, notifier, w, dispatcher, logger)
|
||||
|
||||
wCtx, wCancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(wCancel)
|
||||
go w.Run(wCtx)
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Without token: should fail.
|
||||
c := client.New(ts.URL, client.WithPollingMode(), client.WithPollInterval(100*time.Millisecond))
|
||||
ctx := context.Background()
|
||||
_, err = c.Submit(ctx, client.SubmitRequest{
|
||||
Model: "qwen3:30b",
|
||||
Messages: []json.RawMessage{json.RawMessage(`{"role":"user","content":"hi"}`)},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no token provided")
|
||||
}
|
||||
|
||||
// With correct token: should succeed.
|
||||
c = client.New(ts.URL, client.WithToken("my-secret-token"), client.WithPollingMode(), client.WithPollInterval(100*time.Millisecond))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
result, err := c.Submit(ctx, client.SubmitRequest{
|
||||
Model: "qwen3:30b",
|
||||
Messages: []json.RawMessage{json.RawMessage(`{"role":"user","content":"hi"}`)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Submit with correct token: %v", err)
|
||||
}
|
||||
if result.State != "done" {
|
||||
t.Errorf("state = %q, want %q", result.State, "done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
ollamaStub := &stubOllamaClient{
|
||||
tags: &ollama.TagsResponse{
|
||||
Models: []ollama.ModelInfo{
|
||||
{Name: "qwen3:30b", Size: 19000000000},
|
||||
{Name: "nomic-embed-text", Size: 300000000},
|
||||
},
|
||||
},
|
||||
ps: &ollama.PsResponse{},
|
||||
}
|
||||
|
||||
baseURL := newTestForeman(t, ollamaStub, "")
|
||||
c := client.New(baseURL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
models, err := c.Tags(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Tags: %v", err)
|
||||
}
|
||||
|
||||
if len(models) != 2 {
|
||||
t.Fatalf("got %d models, want 2", len(models))
|
||||
}
|
||||
if models[0].Name != "qwen3:30b" {
|
||||
t.Errorf("first model = %q, want %q", models[0].Name, "qwen3:30b")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbed(t *testing.T) {
|
||||
embedResp := ollama.EmbedResponse{
|
||||
Model: "nomic-embed-text",
|
||||
Embeddings: [][]float64{{0.1, 0.2, 0.3}},
|
||||
}
|
||||
respBytes, _ := json.Marshal(embedResp)
|
||||
|
||||
ollamaStub := &stubOllamaClient{
|
||||
tags: &ollama.TagsResponse{},
|
||||
ps: &ollama.PsResponse{},
|
||||
rawEmbedFunc: func(ctx context.Context, body []byte) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewReader(respBytes)),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
baseURL := newTestForeman(t, ollamaStub, "")
|
||||
c := client.New(baseURL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.Embed(ctx, client.EmbedRequest{
|
||||
Model: "nomic-embed-text",
|
||||
Input: json.RawMessage(`"test input"`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Embed: %v", err)
|
||||
}
|
||||
|
||||
if resp.Model != "nomic-embed-text" {
|
||||
t.Errorf("model = %q, want %q", resp.Model, "nomic-embed-text")
|
||||
}
|
||||
if len(resp.Embeddings) != 1 || len(resp.Embeddings[0]) != 3 {
|
||||
t.Errorf("embeddings shape wrong: %v", resp.Embeddings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_MissingModel(t *testing.T) {
|
||||
c := client.New("http://localhost:9999")
|
||||
_, err := c.Submit(context.Background(), client.SubmitRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_WebhookWithHMAC(t *testing.T) {
|
||||
secret := "test-hmac-secret"
|
||||
|
||||
ollamaStub := &stubOllamaClient{
|
||||
tags: &ollama.TagsResponse{
|
||||
Models: []ollama.ModelInfo{{Name: "qwen3:30b"}},
|
||||
},
|
||||
ps: &ollama.PsResponse{},
|
||||
chatFunc: func(ctx context.Context, req ollama.ChatRequest, stream bool) (*ollama.ChatResponse, <-chan ollama.ChatResponse, error) {
|
||||
return &ollama.ChatResponse{
|
||||
Model: req.Model,
|
||||
Done: true,
|
||||
Message: &ollama.Message{Role: "assistant", Content: "hmac verified"},
|
||||
}, nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
baseURL := newTestForeman(t, ollamaStub, secret)
|
||||
c := client.New(baseURL, client.WithWebhookSecret(secret))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := c.Submit(ctx, client.SubmitRequest{
|
||||
Model: "qwen3:30b",
|
||||
Messages: []json.RawMessage{json.RawMessage(`{"role":"user","content":"hi"}`)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Submit with HMAC: %v", err)
|
||||
}
|
||||
|
||||
if result.State != "done" {
|
||||
t.Errorf("state = %q, want %q", result.State, "done")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stub Ollama client for integration tests ---
|
||||
|
||||
type stubOllamaClient struct {
|
||||
tags *ollama.TagsResponse
|
||||
tagsErr error
|
||||
ps *ollama.PsResponse
|
||||
psErr error
|
||||
|
||||
chatFunc func(ctx context.Context, req ollama.ChatRequest, stream bool) (*ollama.ChatResponse, <-chan ollama.ChatResponse, error)
|
||||
rawEmbedFunc func(ctx context.Context, body []byte) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s *stubOllamaClient) Chat(ctx context.Context, req ollama.ChatRequest, stream bool) (*ollama.ChatResponse, <-chan ollama.ChatResponse, error) {
|
||||
if s.chatFunc != nil {
|
||||
return s.chatFunc(ctx, req, stream)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stubOllamaClient.Chat not implemented")
|
||||
}
|
||||
|
||||
func (s *stubOllamaClient) Embed(ctx context.Context, req ollama.EmbedRequest) (*ollama.EmbedResponse, error) {
|
||||
return nil, fmt.Errorf("stubOllamaClient.Embed not implemented")
|
||||
}
|
||||
|
||||
func (s *stubOllamaClient) Tags(ctx context.Context) (*ollama.TagsResponse, error) {
|
||||
if s.tagsErr != nil {
|
||||
return nil, s.tagsErr
|
||||
}
|
||||
return s.tags, nil
|
||||
}
|
||||
|
||||
func (s *stubOllamaClient) Ps(ctx context.Context) (*ollama.PsResponse, error) {
|
||||
if s.psErr != nil {
|
||||
return nil, s.psErr
|
||||
}
|
||||
return s.ps, nil
|
||||
}
|
||||
|
||||
func (s *stubOllamaClient) RawChat(ctx context.Context, body []byte) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("stubOllamaClient.RawChat not implemented")
|
||||
}
|
||||
|
||||
func (s *stubOllamaClient) RawEmbed(ctx context.Context, body []byte) (*http.Response, error) {
|
||||
if s.rawEmbedFunc != nil {
|
||||
return s.rawEmbedFunc(ctx, body)
|
||||
}
|
||||
return nil, fmt.Errorf("stubOllamaClient.RawEmbed not implemented")
|
||||
}
|
||||
Reference in New Issue
Block a user