feat: scaffold project with config, store, health endpoint, CI, and Dockerfile
Phase 1 of foreman: initialize the Go module, project layout, and core infrastructure. Includes env-based configuration (FOREMAN_* namespace), SQLite-backed durable job queue with WAL mode via modernc.org/sqlite, stdlib HTTP server with /healthz and optional bearer-token auth middleware, subcommand dispatch (serve + stubs), Gitea CI workflow, multi-stage distroless Dockerfile, and comprehensive tests for all packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openTestDB creates a fresh SQLite store in a temp directory for test isolation.
|
||||
func openTestDB(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open(%q): %v", path, err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOpen_CreatesTablesAndWAL(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
// Verify WAL mode is active.
|
||||
var mode string
|
||||
if err := s.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
|
||||
t.Fatalf("query journal_mode: %v", err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Errorf("journal_mode = %q, want %q", mode, "wal")
|
||||
}
|
||||
|
||||
// Verify tables exist by querying their metadata.
|
||||
for _, table := range []string{"jobs", "artifacts"} {
|
||||
var name string
|
||||
err := s.db.QueryRow(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("table %q not found: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateJob_And_GetJob(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
webhook := "http://example.com/webhook"
|
||||
job := Job{
|
||||
ID: "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
Model: "qwen3:30b",
|
||||
Payload: json.RawMessage(`{"model":"qwen3:30b","messages":[{"role":"user","content":"hi"}]}`),
|
||||
MaxAttempts: 5,
|
||||
StateWebhookURL: &webhook,
|
||||
}
|
||||
|
||||
created, err := s.CreateJob(job)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
|
||||
if created.State != JobStateQueued {
|
||||
t.Errorf("State = %q, want %q", created.State, JobStateQueued)
|
||||
}
|
||||
if created.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt should be set")
|
||||
}
|
||||
if created.MaxAttempts != 5 {
|
||||
t.Errorf("MaxAttempts = %d, want 5", created.MaxAttempts)
|
||||
}
|
||||
|
||||
got, err := s.GetJob(created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJob: %v", err)
|
||||
}
|
||||
|
||||
if got.ID != created.ID {
|
||||
t.Errorf("ID = %q, want %q", got.ID, created.ID)
|
||||
}
|
||||
if got.Model != "qwen3:30b" {
|
||||
t.Errorf("Model = %q, want %q", got.Model, "qwen3:30b")
|
||||
}
|
||||
if got.State != JobStateQueued {
|
||||
t.Errorf("State = %q, want %q", got.State, JobStateQueued)
|
||||
}
|
||||
if got.StateWebhookURL == nil || *got.StateWebhookURL != webhook {
|
||||
t.Errorf("StateWebhookURL = %v, want %q", got.StateWebhookURL, webhook)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateJob_DefaultMaxAttempts(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
job := Job{
|
||||
ID: "01ARZ3NDEKTSV4RRFFQ69G5FA2",
|
||||
Model: "qwen3:14b",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
|
||||
created, err := s.CreateJob(job)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
|
||||
if created.MaxAttempts != 3 {
|
||||
t.Errorf("MaxAttempts = %d, want 3 (default)", created.MaxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJob_NotFound(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
_, err := s.GetJob("nonexistent")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("GetJob(nonexistent) error = %v, want sql.ErrNoRows wrapped", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobState(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
job := Job{
|
||||
ID: "01ARZ3NDEKTSV4RRFFQ69G5FA3",
|
||||
Model: "qwen3:30b",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
if _, err := s.CreateJob(job); err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
|
||||
// Transition: queued -> loading
|
||||
if err := s.UpdateJobState(job.ID, JobStateLoading, nil, nil); err != nil {
|
||||
t.Fatalf("UpdateJobState to loading: %v", err)
|
||||
}
|
||||
got, _ := s.GetJob(job.ID)
|
||||
if got.State != JobStateLoading {
|
||||
t.Errorf("State = %q, want %q", got.State, JobStateLoading)
|
||||
}
|
||||
if got.StartedAt == nil {
|
||||
t.Error("StartedAt should be set after loading")
|
||||
}
|
||||
|
||||
// Transition: loading -> working
|
||||
if err := s.UpdateJobState(job.ID, JobStateWorking, nil, nil); err != nil {
|
||||
t.Fatalf("UpdateJobState to working: %v", err)
|
||||
}
|
||||
|
||||
// Transition: working -> done with result
|
||||
result := json.RawMessage(`{"response":"hello"}`)
|
||||
if err := s.UpdateJobState(job.ID, JobStateDone, result, nil); err != nil {
|
||||
t.Fatalf("UpdateJobState to done: %v", err)
|
||||
}
|
||||
got, _ = s.GetJob(job.ID)
|
||||
if got.State != JobStateDone {
|
||||
t.Errorf("State = %q, want %q", got.State, JobStateDone)
|
||||
}
|
||||
if got.CompletedAt == nil {
|
||||
t.Error("CompletedAt should be set after done")
|
||||
}
|
||||
if string(got.Result) != `{"response":"hello"}` {
|
||||
t.Errorf("Result = %s, want %s", got.Result, `{"response":"hello"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobState_Failed(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
job := Job{
|
||||
ID: "01ARZ3NDEKTSV4RRFFQ69G5FA4",
|
||||
Model: "qwen3:30b",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}
|
||||
if _, err := s.CreateJob(job); err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
|
||||
errMsg := "target unreachable after 3 attempts"
|
||||
if err := s.UpdateJobState(job.ID, JobStateFailed, nil, &errMsg); err != nil {
|
||||
t.Fatalf("UpdateJobState to failed: %v", err)
|
||||
}
|
||||
|
||||
got, _ := s.GetJob(job.ID)
|
||||
if got.State != JobStateFailed {
|
||||
t.Errorf("State = %q, want %q", got.State, JobStateFailed)
|
||||
}
|
||||
if got.Error == nil || *got.Error != errMsg {
|
||||
t.Errorf("Error = %v, want %q", got.Error, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobState_NotFound(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
err := s.UpdateJobState("nonexistent", JobStateDone, nil, nil)
|
||||
if err == nil {
|
||||
t.Error("UpdateJobState for nonexistent job should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListJobs_All(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
for i, id := range []string{"01A", "01B", "01C"} {
|
||||
state := JobStateQueued
|
||||
job := Job{ID: id, Model: "m", Payload: json.RawMessage(`{}`)}
|
||||
if _, err := s.CreateJob(job); err != nil {
|
||||
t.Fatalf("CreateJob[%d]: %v", i, err)
|
||||
}
|
||||
if i == 1 {
|
||||
_ = s.UpdateJobState(id, JobStateDone, nil, nil)
|
||||
_ = state // suppress unused warning
|
||||
}
|
||||
}
|
||||
|
||||
all, err := s.ListJobs(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ListJobs(nil): %v", err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Errorf("len = %d, want 3", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListJobs_FilterByState(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
for _, id := range []string{"01D", "01E", "01F"} {
|
||||
job := Job{ID: id, Model: "m", Payload: json.RawMessage(`{}`)}
|
||||
if _, err := s.CreateJob(job); err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
}
|
||||
// Move one to done.
|
||||
_ = s.UpdateJobState("01E", JobStateDone, nil, nil)
|
||||
|
||||
queued := JobStateQueued
|
||||
jobs, err := s.ListJobs(&queued)
|
||||
if err != nil {
|
||||
t.Fatalf("ListJobs(queued): %v", err)
|
||||
}
|
||||
if len(jobs) != 2 {
|
||||
t.Errorf("len = %d, want 2", len(jobs))
|
||||
}
|
||||
for _, j := range jobs {
|
||||
if j.State != JobStateQueued {
|
||||
t.Errorf("unexpected state %q in filtered results", j.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateArtifact_And_GetArtifact(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
// Need a job first (foreign key).
|
||||
job := Job{ID: "01ART", Model: "m", Payload: json.RawMessage(`{}`)}
|
||||
if _, err := s.CreateJob(job); err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
|
||||
artifact := Artifact{
|
||||
JobID: "01ART",
|
||||
Name: "completion",
|
||||
ContentType: "application/json",
|
||||
Data: []byte(`{"response":"hello world"}`),
|
||||
}
|
||||
|
||||
created, err := s.CreateArtifact(artifact)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateArtifact: %v", err)
|
||||
}
|
||||
if created.ID == 0 {
|
||||
t.Error("ID should be set after insert")
|
||||
}
|
||||
if created.Size != int64(len(artifact.Data)) {
|
||||
t.Errorf("Size = %d, want %d", created.Size, len(artifact.Data))
|
||||
}
|
||||
|
||||
got, err := s.GetArtifact("01ART", "completion")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtifact: %v", err)
|
||||
}
|
||||
if string(got.Data) != `{"response":"hello world"}` {
|
||||
t.Errorf("Data = %q, want %q", got.Data, `{"response":"hello world"}`)
|
||||
}
|
||||
if got.ContentType != "application/json" {
|
||||
t.Errorf("ContentType = %q", got.ContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtifact_NotFound(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
_, err := s.GetArtifact("noexist", "noname")
|
||||
if err == nil {
|
||||
t.Error("GetArtifact should fail for nonexistent artifact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtifactsByJob(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
job := Job{ID: "01ARTS", Model: "m", Payload: json.RawMessage(`{}`)}
|
||||
if _, err := s.CreateJob(job); err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"completion", "metadata"} {
|
||||
_, err := s.CreateArtifact(Artifact{
|
||||
JobID: "01ARTS",
|
||||
Name: name,
|
||||
ContentType: "application/json",
|
||||
Data: []byte(`{}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateArtifact(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
artifacts, err := s.GetArtifactsByJob("01ARTS")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtifactsByJob: %v", err)
|
||||
}
|
||||
if len(artifacts) != 2 {
|
||||
t.Errorf("len = %d, want 2", len(artifacts))
|
||||
}
|
||||
// Should be ordered by name.
|
||||
if artifacts[0].Name != "completion" {
|
||||
t.Errorf("artifacts[0].Name = %q, want %q", artifacts[0].Name, "completion")
|
||||
}
|
||||
if artifacts[1].Name != "metadata" {
|
||||
t.Errorf("artifacts[1].Name = %q, want %q", artifacts[1].Name, "metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateArtifact_DuplicateNameFails(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
job := Job{ID: "01DUP", Model: "m", Payload: json.RawMessage(`{}`)}
|
||||
if _, err := s.CreateJob(job); err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
|
||||
a := Artifact{
|
||||
JobID: "01DUP",
|
||||
Name: "completion",
|
||||
ContentType: "application/json",
|
||||
Data: []byte(`{}`),
|
||||
}
|
||||
if _, err := s.CreateArtifact(a); err != nil {
|
||||
t.Fatalf("first CreateArtifact: %v", err)
|
||||
}
|
||||
|
||||
_, err := s.CreateArtifact(a)
|
||||
if err == nil {
|
||||
t.Error("duplicate artifact (job_id, name) should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtifactsByJob_Empty(t *testing.T) {
|
||||
s := openTestDB(t)
|
||||
|
||||
job := Job{ID: "01EMPTY", Model: "m", Payload: json.RawMessage(`{}`)}
|
||||
if _, err := s.CreateJob(job); err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
|
||||
artifacts, err := s.GetArtifactsByJob("01EMPTY")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtifactsByJob: %v", err)
|
||||
}
|
||||
if len(artifacts) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(artifacts))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user