Files
odidere/internal/job/job_test.go
T
2026-10-30 00:45:54 +00:00

346 lines
7.8 KiB
Go

package job
import (
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"code.chimeric.al/chimerical/odidere/internal/config"
"code.chimeric.al/chimerical/odidere/internal/conversation"
"code.chimeric.al/chimerical/odidere/internal/db"
"code.chimeric.al/chimerical/odidere/internal/llm"
"code.chimeric.al/chimerical/odidere/internal/tool"
)
func testDependencies(t *testing.T) *llm.Client {
t.Helper()
client, err := llm.NewClient(
llm.Config{URL: "http://localhost"}, slog.Default(),
)
if err != nil {
t.Fatalf("llm.NewClient: %v", err)
}
return client
}
func TestConfigValidate(t *testing.T) {
tests := []struct {
name string
config config.JobConfig
wantErr bool
}{
{
name: "empty config",
config: config.JobConfig{},
wantErr: true,
},
{
name: "missing name",
config: config.JobConfig{
Schedule: "0 9 * * *",
Task: "do something",
UserId: "default",
},
wantErr: true,
},
{
name: "missing task",
config: config.JobConfig{
Name: "test",
Schedule: "0 9 * * *",
UserId: "default",
},
wantErr: true,
},
{
name: "missing schedule",
config: config.JobConfig{
Name: "test",
Task: "do something",
UserId: "default",
},
wantErr: true,
},
{
name: "missing user ID",
config: config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
},
wantErr: true,
},
{
name: "negative max iterations",
config: config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
MaxIterations: -1,
UserId: "default",
},
wantErr: true,
},
{
name: "minimal valid config",
config: config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
UserId: "default",
},
wantErr: false,
},
{
name: "full valid config",
config: config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
Timeout: 10 * time.Minute,
MaxIterations: 20,
SystemMessage: "You are an assistant.",
UserId: "default",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestConfigMaxIterations(t *testing.T) {
// Zero means unlimited
cfg := config.JobConfig{Name: "test"}
if got := cfg.MaxIterations; got != 0 {
t.Errorf("default max iterations = %d, want 0", got)
}
cfg.MaxIterations = 0
if got := cfg.MaxIterations; got != 0 {
t.Errorf("explicit zero max iterations = %d, want 0", got)
}
// Positive value passes through
cfg.MaxIterations = 25
if got := cfg.MaxIterations; got != 25 {
t.Errorf("custom max iterations = %d, want 25", got)
}
}
func TestConfigTimeout(t *testing.T) {
// A zero Timeout means the job has no per-job execution limit;
// Run then relies on the callers context.
var cfg config.JobConfig
if cfg.Timeout != 0 {
t.Errorf("default timeout = %v, want 0", cfg.Timeout)
}
}
func TestNew(t *testing.T) {
client := testDependencies(t)
cfg := config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
Timeout: 10 * time.Minute,
MaxIterations: 20,
SystemMessage: "You are an assistant.",
}
cfg.Model.Model = "test-model"
cfg.UserId = "default"
j, err := New(cfg, client, slog.Default())
if err != nil {
t.Fatalf("New() error = %v", err)
}
if j.Name() != "test" {
t.Errorf("Name() = %q, want %q", j.Name(), "test")
}
if j.Schedule() != "0 9 * * *" {
t.Errorf("Schedule() = %q, want %q", j.Schedule(), "0 9 * * *")
}
if j.SystemMessage() != "You are an assistant." {
t.Errorf("SystemMessage() = %q, want %q", j.SystemMessage(), "You are an assistant.")
}
if j.Task() != "do something" {
t.Errorf("Task() = %q, want %q", j.Task(), "do something")
}
if j.Timeout().Minutes() != 10 {
t.Errorf("Timeout() = %v, want 10m", j.Timeout())
}
if j.MaxIterations() != 20 {
t.Errorf("MaxIterations() = %d, want 20", j.MaxIterations())
}
if j.Model() != "test-model" {
t.Errorf("Model() = %q, want %q", j.Model(), "test-model")
}
if j.Cron() == nil {
t.Error("Cron() = nil, want non-nil")
}
}
func TestNew_InvalidSchedule(t *testing.T) {
client := testDependencies(t)
cfg := config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "not-a-cron",
}
cfg.Model.Model = "test-model"
cfg.UserId = "default"
_, err := New(cfg, client, slog.Default())
if err == nil {
t.Fatal("New() expected error for invalid schedule")
}
}
func TestNew_InvalidTimeout(t *testing.T) {
client := testDependencies(t)
cfg := config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
Timeout: -time.Minute,
}
cfg.Model.Model = "test-model"
cfg.UserId = "default"
_, err := New(cfg, client, slog.Default())
if err == nil {
t.Fatal("New() expected error for invalid timeout")
}
}
func TestNewRequiresDependencies(t *testing.T) {
client := testDependencies(t)
cfg := config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
}
for _, test := range []struct {
name string
client *llm.Client
logger *slog.Logger
userID string
}{
{
name: "empty user ID",
client: client,
logger: slog.Default(),
},
{
name: "nil llm client",
logger: slog.Default(),
userID: "default",
},
{
name: "nil logger",
client: client,
userID: "default",
},
} {
t.Run(test.name, func(t *testing.T) {
cfg.UserId = test.userID
_, err := New(cfg, test.client, test.logger)
if err == nil {
t.Fatal("New() expected error")
}
})
}
}
// TestRun checks that Run saves the task as a user message in the
// conversation and runs inference to completion.
func TestRun(t *testing.T) {
var (
ctx = t.Context()
log = slog.New(slog.DiscardHandler)
)
inference := httptest.NewServer(http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"choices":[{
"message":{"role":"assistant","content":"finished"},
"finish_reason":"stop"
}]
}`)
}))
defer inference.Close()
database, err := db.Connect(ctx, db.Config{
Path: filepath.Join(t.TempDir(), "job.db"),
})
if err != nil {
t.Fatalf("db.Connect: %v", err)
}
defer database.Close()
if err := database.ApplySchema(ctx, log); err != nil {
t.Fatalf("ApplySchema: %v", err)
}
saved, err := db.New(database.Writer).InsertConversation(
ctx, db.InsertConversationParams{UserId: "default"},
)
if err != nil {
t.Fatalf("InsertConversation: %v", err)
}
registry, err := tool.NewRegistry(nil)
if err != nil {
t.Fatalf("tool.NewRegistry: %v", err)
}
c, err := conversation.New(
conversation.Config{Id: saved.Id}, database, log, registry,
)
if err != nil {
t.Fatalf("conversation.New: %v", err)
}
client, err := llm.NewClient(
llm.Config{Provider: "test", URL: inference.URL}, log,
)
if err != nil {
t.Fatalf("llm.NewClient: %v", err)
}
j, err := New(config.JobConfig{
Name: "daily report",
Task: "prepare the report",
Schedule: "0 9 * * *",
Model: config.ModelConfig{Model: "test-model"},
UserId: "default",
}, client, log)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := j.Run(ctx, c); err != nil {
t.Fatalf("Run: %v", err)
}
rows, err := db.New(database.Reader).
SelectMessagesConversationId(ctx, saved.Id)
if err != nil {
t.Fatalf("SelectMessagesConversationId: %v", err)
}
if len(rows) != 2 ||
rows[0].Role != db.MessageRoleUser ||
rows[1].Role != db.MessageRoleAssistant {
t.Fatalf("saved rows = %v, want user then assistant", rows)
}
}