Files
odidere/internal/job/job_test.go
T

346 lines
7.8 KiB
Go
Raw Normal View History

2026-06-02 12:03:03 +00:00
package job
import (
2026-10-07 00:23:08 +00:00
"fmt"
2026-06-02 12:03:03 +00:00
"log/slog"
2026-10-07 00:23:08 +00:00
"net/http"
"net/http/httptest"
"path/filepath"
2026-06-02 12:03:03 +00:00
"testing"
"time"
2026-06-19 14:31:41 +00:00
"code.chimeric.al/chimerical/odidere/internal/config"
2026-10-30 00:45:54 +00:00
"code.chimeric.al/chimerical/odidere/internal/conversation"
2026-10-07 00:23:08 +00:00
"code.chimeric.al/chimerical/odidere/internal/db"
"code.chimeric.al/chimerical/odidere/internal/llm"
"code.chimeric.al/chimerical/odidere/internal/tool"
2026-06-02 12:03:03 +00:00
)
2026-10-30 00:45:54 +00:00
func testDependencies(t *testing.T) *llm.Client {
2026-10-07 00:23:08 +00:00
t.Helper()
client, err := llm.NewClient(
llm.Config{URL: "http://localhost"}, slog.Default(),
)
if err != nil {
t.Fatalf("llm.NewClient: %v", err)
}
2026-10-30 00:45:54 +00:00
return client
2026-10-07 00:23:08 +00:00
}
2026-06-02 12:03:03 +00:00
func TestConfigValidate(t *testing.T) {
tests := []struct {
name string
2026-06-19 14:31:41 +00:00
config config.JobConfig
2026-06-02 12:03:03 +00:00
wantErr bool
}{
{
name: "empty config",
2026-06-19 14:31:41 +00:00
config: config.JobConfig{},
2026-06-02 12:03:03 +00:00
wantErr: true,
},
{
name: "missing name",
2026-06-19 14:31:41 +00:00
config: config.JobConfig{
2026-06-02 12:03:03 +00:00
Schedule: "0 9 * * *",
Task: "do something",
2026-10-07 00:23:08 +00:00
UserId: "default",
2026-06-02 12:03:03 +00:00
},
wantErr: true,
},
{
name: "missing task",
2026-06-19 14:31:41 +00:00
config: config.JobConfig{
2026-06-02 12:03:03 +00:00
Name: "test",
Schedule: "0 9 * * *",
2026-10-07 00:23:08 +00:00
UserId: "default",
2026-06-02 12:03:03 +00:00
},
wantErr: true,
},
{
name: "missing schedule",
2026-06-19 14:31:41 +00:00
config: config.JobConfig{
2026-10-07 00:23:08 +00:00
Name: "test",
Task: "do something",
UserId: "default",
},
wantErr: true,
},
{
name: "missing user ID",
config: config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
2026-06-02 12:03:03 +00:00
},
wantErr: true,
},
{
name: "negative max iterations",
2026-06-19 14:31:41 +00:00
config: config.JobConfig{
2026-06-02 12:03:03 +00:00
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
MaxIterations: -1,
2026-10-07 00:23:08 +00:00
UserId: "default",
2026-06-02 12:03:03 +00:00
},
wantErr: true,
},
{
name: "minimal valid config",
2026-06-19 14:31:41 +00:00
config: config.JobConfig{
2026-06-02 12:03:03 +00:00
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
2026-10-07 00:23:08 +00:00
UserId: "default",
2026-06-02 12:03:03 +00:00
},
wantErr: false,
},
{
name: "full valid config",
2026-06-19 14:31:41 +00:00
config: config.JobConfig{
2026-06-02 12:03:03 +00:00
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
2026-10-07 00:23:08 +00:00
Timeout: 10 * time.Minute,
2026-06-02 12:03:03 +00:00
MaxIterations: 20,
SystemMessage: "You are an assistant.",
2026-10-07 00:23:08 +00:00
UserId: "default",
2026-06-02 12:03:03 +00:00
},
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
2026-06-19 14:31:41 +00:00
cfg := config.JobConfig{Name: "test"}
2026-06-02 12:03:03 +00:00
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) {
2026-10-07 00:23:08 +00:00
// 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)
2026-06-02 12:03:03 +00:00
}
}
func TestNew(t *testing.T) {
2026-10-30 00:45:54 +00:00
client := testDependencies(t)
2026-06-19 14:31:41 +00:00
cfg := config.JobConfig{
2026-06-02 12:03:03 +00:00
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
2026-10-07 00:23:08 +00:00
Timeout: 10 * time.Minute,
2026-06-02 12:03:03 +00:00
MaxIterations: 20,
SystemMessage: "You are an assistant.",
}
2026-10-07 00:23:08 +00:00
cfg.Model.Model = "test-model"
cfg.UserId = "default"
2026-10-30 00:45:54 +00:00
j, err := New(cfg, client, slog.Default())
2026-06-02 12:03:03 +00:00
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())
}
2026-06-19 14:31:41 +00:00
if j.Model() != "test-model" {
t.Errorf("Model() = %q, want %q", j.Model(), "test-model")
}
2026-06-02 12:03:03 +00:00
if j.Cron() == nil {
t.Error("Cron() = nil, want non-nil")
}
}
func TestNew_InvalidSchedule(t *testing.T) {
2026-10-30 00:45:54 +00:00
client := testDependencies(t)
2026-06-19 14:31:41 +00:00
cfg := config.JobConfig{
2026-06-02 12:03:03 +00:00
Name: "test",
Task: "do something",
Schedule: "not-a-cron",
}
2026-10-07 00:23:08 +00:00
cfg.Model.Model = "test-model"
cfg.UserId = "default"
2026-10-30 00:45:54 +00:00
_, err := New(cfg, client, slog.Default())
2026-06-02 12:03:03 +00:00
if err == nil {
t.Fatal("New() expected error for invalid schedule")
}
}
func TestNew_InvalidTimeout(t *testing.T) {
2026-10-30 00:45:54 +00:00
client := testDependencies(t)
2026-06-19 14:31:41 +00:00
cfg := config.JobConfig{
2026-06-02 12:03:03 +00:00
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
2026-10-07 00:23:08 +00:00
Timeout: -time.Minute,
2026-06-02 12:03:03 +00:00
}
2026-10-07 00:23:08 +00:00
cfg.Model.Model = "test-model"
cfg.UserId = "default"
2026-10-30 00:45:54 +00:00
_, err := New(cfg, client, slog.Default())
2026-06-02 12:03:03 +00:00
if err == nil {
t.Fatal("New() expected error for invalid timeout")
}
}
2026-10-07 00:23:08 +00:00
func TestNewRequiresDependencies(t *testing.T) {
2026-10-30 00:45:54 +00:00
client := testDependencies(t)
2026-10-07 00:23:08 +00:00
cfg := config.JobConfig{
Name: "test",
Task: "do something",
Schedule: "0 9 * * *",
}
for _, test := range []struct {
2026-10-30 00:45:54 +00:00
name string
client *llm.Client
logger *slog.Logger
userID string
2026-10-07 00:23:08 +00:00
}{
{
2026-10-30 00:45:54 +00:00
name: "empty user ID",
client: client,
logger: slog.Default(),
2026-10-07 00:23:08 +00:00
},
{
2026-10-30 00:45:54 +00:00
name: "nil llm client",
logger: slog.Default(),
userID: "default",
2026-10-07 00:23:08 +00:00
},
{
2026-10-30 00:45:54 +00:00
name: "nil logger",
client: client,
userID: "default",
2026-10-07 00:23:08 +00:00
},
} {
t.Run(test.name, func(t *testing.T) {
cfg.UserId = test.userID
2026-10-30 00:45:54 +00:00
_, err := New(cfg, test.client, test.logger)
2026-10-07 00:23:08 +00:00
if err == nil {
t.Fatal("New() expected error")
}
})
}
}
2026-10-30 00:45:54 +00:00
// 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)
)
2026-10-07 00:23:08 +00:00
inference := httptest.NewServer(http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
) {
w.Header().Set("Content-Type", "application/json")
2026-10-30 00:45:54 +00:00
fmt.Fprint(w, `{
"choices":[{
"message":{"role":"assistant","content":"finished"},
"finish_reason":"stop"
}]
}`)
2026-10-07 00:23:08 +00:00
}))
defer inference.Close()
2026-10-30 00:45:54 +00:00
database, err := db.Connect(ctx, db.Config{
Path: filepath.Join(t.TempDir(), "job.db"),
})
2026-10-07 00:23:08 +00:00
if err != nil {
t.Fatalf("db.Connect: %v", err)
}
defer database.Close()
2026-10-30 00:45:54 +00:00
if err := database.ApplySchema(ctx, log); err != nil {
2026-10-07 00:23:08 +00:00
t.Fatalf("ApplySchema: %v", err)
}
2026-10-30 00:45:54 +00:00
saved, err := db.New(database.Writer).InsertConversation(
ctx, db.InsertConversationParams{UserId: "default"},
)
2026-10-07 00:23:08 +00:00
if err != nil {
2026-10-30 00:45:54 +00:00
t.Fatalf("InsertConversation: %v", err)
2026-10-07 00:23:08 +00:00
}
2026-10-30 00:45:54 +00:00
registry, err := tool.NewRegistry(nil)
2026-10-07 00:23:08 +00:00
if err != nil {
t.Fatalf("tool.NewRegistry: %v", err)
}
2026-10-30 00:45:54 +00:00
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,
)
2026-10-07 00:23:08 +00:00
if err != nil {
t.Fatalf("llm.NewClient: %v", err)
}
2026-10-30 00:45:54 +00:00
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)
2026-10-07 00:23:08 +00:00
if err != nil {
t.Fatalf("New: %v", err)
}
2026-10-30 00:45:54 +00:00
if err := j.Run(ctx, c); err != nil {
t.Fatalf("Run: %v", err)
2026-10-07 00:23:08 +00:00
}
2026-10-30 00:45:54 +00:00
rows, err := db.New(database.Reader).
SelectMessagesConversationId(ctx, saved.Id)
2026-10-07 00:23:08 +00:00
if err != nil {
t.Fatalf("SelectMessagesConversationId: %v", err)
}
2026-10-30 00:45:54 +00:00
if len(rows) != 2 ||
rows[0].Role != db.MessageRoleUser ||
rows[1].Role != db.MessageRoleAssistant {
t.Fatalf("saved rows = %v, want user then assistant", rows)
2026-10-07 00:23:08 +00:00
}
}