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