summaryrefslogtreecommitdiff
path: root/internal/storage/events_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/storage/events_test.go')
-rw-r--r--internal/storage/events_test.go456
1 files changed, 456 insertions, 0 deletions
diff --git a/internal/storage/events_test.go b/internal/storage/events_test.go
new file mode 100644
index 0000000..c5d3c37
--- /dev/null
+++ b/internal/storage/events_test.go
@@ -0,0 +1,456 @@
+package storage
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// makeTaskForEvent creates a minimal task row so events can satisfy the FK.
+func makeTaskForEvent(t *testing.T, db *DB, id string) {
+ t.Helper()
+ now := time.Now().UTC().Truncate(time.Second)
+ tk := &task.Task{
+ ID: id,
+ Name: id,
+ Agent: task.AgentConfig{Type: "claude", Instructions: "noop"},
+ Priority: task.PriorityNormal,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatalf("creating task: %v", err)
+ }
+}
+
+func TestCreateEvent_AssignsSeqAndDefaults(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ e := &event.Event{
+ TaskID: "task-1",
+ Kind: event.KindStateChange,
+ Actor: event.ActorSystem,
+ }
+ if err := db.CreateEvent(e); err != nil {
+ t.Fatalf("CreateEvent: %v", err)
+ }
+ if e.ID == "" {
+ t.Error("expected ID populated")
+ }
+ if e.Seq != 1 {
+ t.Errorf("expected seq=1, got %d", e.Seq)
+ }
+ if e.Timestamp.IsZero() {
+ t.Error("expected Timestamp populated")
+ }
+ if string(e.Payload) != "{}" {
+ t.Errorf("expected default payload {}, got %s", e.Payload)
+ }
+}
+
+func TestCreateEvent_SeqIsMonotonicPerTask(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-A")
+ makeTaskForEvent(t, db, "task-B")
+
+ // 3 events on task-A, 2 on task-B, interleaved.
+ for _, taskID := range []string{"task-A", "task-A", "task-B", "task-A", "task-B"} {
+ e := &event.Event{
+ TaskID: taskID,
+ Kind: event.KindAgentMessage,
+ Actor: event.ActorAgent,
+ }
+ if err := db.CreateEvent(e); err != nil {
+ t.Fatalf("CreateEvent: %v", err)
+ }
+ }
+
+ a, err := db.ListEvents("task-A", 0)
+ if err != nil {
+ t.Fatalf("ListEvents A: %v", err)
+ }
+ if len(a) != 3 {
+ t.Fatalf("expected 3 events on task-A, got %d", len(a))
+ }
+ for i, e := range a {
+ want := int64(i + 1)
+ if e.Seq != want {
+ t.Errorf("task-A event %d: seq=%d want %d", i, e.Seq, want)
+ }
+ }
+
+ b, err := db.ListEvents("task-B", 0)
+ if err != nil {
+ t.Fatalf("ListEvents B: %v", err)
+ }
+ if len(b) != 2 {
+ t.Fatalf("expected 2 events on task-B, got %d", len(b))
+ }
+ for i, e := range b {
+ want := int64(i + 1)
+ if e.Seq != want {
+ t.Errorf("task-B event %d: seq=%d want %d", i, e.Seq, want)
+ }
+ }
+}
+
+func TestListEvents_SinceSeq(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ for i := 0; i < 5; i++ {
+ e := &event.Event{
+ TaskID: "task-1",
+ Kind: event.KindAgentMessage,
+ Actor: event.ActorAgent,
+ }
+ if err := db.CreateEvent(e); err != nil {
+ t.Fatalf("CreateEvent: %v", err)
+ }
+ }
+
+ got, err := db.ListEvents("task-1", 3)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("expected 2 events with seq>3, got %d", len(got))
+ }
+ if got[0].Seq != 4 || got[1].Seq != 5 {
+ t.Errorf("expected seqs 4,5; got %d,%d", got[0].Seq, got[1].Seq)
+ }
+}
+
+func TestListEvents_PreservesPayloadAndKind(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ payload := json.RawMessage(`{"question":"which env?","options":["dev","prod"]}`)
+ written := &event.Event{
+ TaskID: "task-1",
+ Kind: event.KindClarificationRequest,
+ Actor: event.ActorAgent,
+ Payload: payload,
+ }
+ if err := db.CreateEvent(written); err != nil {
+ t.Fatalf("CreateEvent: %v", err)
+ }
+
+ got, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(got))
+ }
+ e := got[0]
+ if e.Kind != event.KindClarificationRequest {
+ t.Errorf("kind: got %v want %v", e.Kind, event.KindClarificationRequest)
+ }
+ if e.Actor != event.ActorAgent {
+ t.Errorf("actor: got %v want %v", e.Actor, event.ActorAgent)
+ }
+ if string(e.Payload) != string(payload) {
+ t.Errorf("payload: got %s want %s", e.Payload, payload)
+ }
+}
+
+func TestUpdateTaskState_WritesStateChangeEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1") // starts PENDING
+
+ if err := db.UpdateTaskState("task-1", task.StateQueued); err != nil {
+ t.Fatalf("UpdateTaskState: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ e := events[0]
+ if e.Kind != event.KindStateChange {
+ t.Errorf("kind: got %v want state_change", e.Kind)
+ }
+ if e.Actor != event.ActorSystem {
+ t.Errorf("actor: got %v want system", e.Actor)
+ }
+ var payload struct {
+ From task.State `json:"from"`
+ To task.State `json:"to"`
+ }
+ if err := json.Unmarshal(e.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if payload.From != task.StatePending || payload.To != task.StateQueued {
+ t.Errorf("payload: got %s→%s want PENDING→QUEUED", payload.From, payload.To)
+ }
+}
+
+func TestUpdateTaskStateBy_AttributesActor(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ if err := db.UpdateTaskStateBy("task-1", task.StateCancelled, event.ActorUser); err != nil {
+ t.Fatalf("UpdateTaskStateBy: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ if events[0].Actor != event.ActorUser {
+ t.Errorf("actor: got %v want user", events[0].Actor)
+ }
+}
+
+func TestUpdateTaskState_InvalidTransition_WritesNoEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1") // PENDING
+
+ // PENDING → COMPLETED is not a valid transition.
+ if err := db.UpdateTaskState("task-1", task.StateCompleted); err == nil {
+ t.Fatal("expected invalid transition error, got nil")
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 0 {
+ t.Fatalf("expected no events after rejected transition, got %d", len(events))
+ }
+}
+
+func TestRejectTask_WritesUserStateChangeEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+ // Drive PENDING → QUEUED → RUNNING → READY so reject is valid.
+ for _, st := range []task.State{task.StateQueued, task.StateRunning, task.StateReady} {
+ if err := db.UpdateTaskState("task-1", st); err != nil {
+ t.Fatalf("transition to %s: %v", st, err)
+ }
+ }
+
+ if err := db.RejectTask("task-1", "needs work"); err != nil {
+ t.Fatalf("RejectTask: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ last := events[len(events)-1]
+ if last.Kind != event.KindStateChange || last.Actor != event.ActorUser {
+ t.Errorf("reject event: got kind=%v actor=%v want state_change/user", last.Kind, last.Actor)
+ }
+ var payload struct {
+ From task.State `json:"from"`
+ To task.State `json:"to"`
+ }
+ if err := json.Unmarshal(last.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if payload.From != task.StateReady || payload.To != task.StatePending {
+ t.Errorf("payload: got %s→%s want READY→PENDING", payload.From, payload.To)
+ }
+}
+
+func TestUpdateTaskQuestion_EmitsClarificationRequest(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ q := `{"text":"which env?","options":["dev","prod"]}`
+ if err := db.UpdateTaskQuestion("task-1", q); err != nil {
+ t.Fatalf("UpdateTaskQuestion: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ e := events[0]
+ if e.Kind != event.KindClarificationRequest || e.Actor != event.ActorAgent {
+ t.Errorf("got kind=%v actor=%v want clarification_request/agent", e.Kind, e.Actor)
+ }
+ if string(e.Payload) != q {
+ t.Errorf("payload: got %s want %s", e.Payload, q)
+ }
+}
+
+func TestUpdateTaskQuestion_ClearEmitsNothing(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ if err := db.UpdateTaskQuestion("task-1", ""); err != nil {
+ t.Fatalf("UpdateTaskQuestion clear: %v", err)
+ }
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 0 {
+ t.Fatalf("clearing question should emit no event, got %d", len(events))
+ }
+}
+
+func TestUpdateTaskSummary_EmitsSummaryEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ if err := db.UpdateTaskSummary("task-1", "Did the thing."); err != nil {
+ t.Fatalf("UpdateTaskSummary: %v", err)
+ }
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ e := events[0]
+ if e.Kind != event.KindSummary || e.Actor != event.ActorAgent {
+ t.Errorf("got kind=%v actor=%v want summary/agent", e.Kind, e.Actor)
+ }
+ var payload struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(e.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if payload.Text != "Did the thing." {
+ t.Errorf("payload text: got %q want %q", payload.Text, "Did the thing.")
+ }
+}
+
+func TestAppendTaskInteraction_EmitsClarificationAnswer(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "task-1")
+
+ interaction := task.Interaction{
+ QuestionText: "which env?",
+ Options: []string{"dev", "prod"},
+ Answer: "prod",
+ }
+ if err := db.AppendTaskInteraction("task-1", interaction); err != nil {
+ t.Fatalf("AppendTaskInteraction: %v", err)
+ }
+
+ events, err := db.ListEvents("task-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(events))
+ }
+ e := events[0]
+ if e.Kind != event.KindClarificationAnswer || e.Actor != event.ActorUser {
+ t.Errorf("got kind=%v actor=%v want clarification_answer/user", e.Kind, e.Actor)
+ }
+ var payload task.Interaction
+ if err := json.Unmarshal(e.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if payload.Answer != "prod" || payload.QuestionText != "which env?" {
+ t.Errorf("payload: got %+v", payload)
+ }
+}
+
+func TestCreateTask_Subtask_EmitsSubtaskSpawnedOnParent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "parent-1")
+
+ now := time.Now().UTC().Truncate(time.Second)
+ child := &task.Task{
+ ID: "child-1",
+ Name: "Do the subthing",
+ ParentTaskID: "parent-1",
+ Agent: task.AgentConfig{Type: "claude", Instructions: "noop"},
+ Priority: task.PriorityNormal,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.CreateTask(child); err != nil {
+ t.Fatalf("CreateTask child: %v", err)
+ }
+
+ // Parent should have the subtask_spawned event.
+ parentEvents, err := db.ListEvents("parent-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents parent: %v", err)
+ }
+ if len(parentEvents) != 1 {
+ t.Fatalf("expected 1 event on parent, got %d", len(parentEvents))
+ }
+ e := parentEvents[0]
+ if e.Kind != event.KindSubtaskSpawned || e.Actor != event.ActorAgent {
+ t.Errorf("got kind=%v actor=%v want subtask_spawned/agent", e.Kind, e.Actor)
+ }
+ var payload struct {
+ SubtaskID string `json:"subtask_id"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(e.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if payload.SubtaskID != "child-1" || payload.Name != "Do the subthing" {
+ t.Errorf("payload: got %+v", payload)
+ }
+
+ // Child should have no events of its own from creation.
+ childEvents, err := db.ListEvents("child-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents child: %v", err)
+ }
+ if len(childEvents) != 0 {
+ t.Errorf("expected no events on child, got %d", len(childEvents))
+ }
+}
+
+func TestCreateTask_TopLevel_EmitsNoEvent(t *testing.T) {
+ db := testDB(t)
+ makeTaskForEvent(t, db, "top-1") // no parent
+
+ events, err := db.ListEvents("top-1", 0)
+ if err != nil {
+ t.Fatalf("ListEvents: %v", err)
+ }
+ if len(events) != 0 {
+ t.Errorf("top-level task creation should emit no event, got %d", len(events))
+ }
+}
+
+func TestCreateEvent_RequiresFields(t *testing.T) {
+ db := testDB(t)
+
+ cases := []struct {
+ name string
+ e *event.Event
+ }{
+ {"nil", nil},
+ {"empty taskID", &event.Event{Kind: event.KindAgentMessage, Actor: event.ActorAgent}},
+ {"empty kind", &event.Event{TaskID: "t", Actor: event.ActorAgent}},
+ {"empty actor", &event.Event{TaskID: "t", Kind: event.KindAgentMessage}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := db.CreateEvent(tc.e); err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ })
+ }
+}