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) } }