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