agent-orchestrator/backend/internal/notification/renderer_test.go

134 lines
4.6 KiB
Go

package notification
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
type fakeReader struct {
rec domain.SessionRecord
pr domain.PRFacts
}
func (f fakeReader) GetSession(context.Context, domain.SessionID) (domain.SessionRecord, bool, error) {
return f.rec, true, nil
}
func (f fakeReader) PRFactsForSession(context.Context, domain.SessionID) (domain.PRFacts, error) {
return f.pr, nil
}
func TestSemanticTypeMapping(t *testing.T) {
cases := map[string]string{
"approved-and-green": "merge.ready",
"agent-stuck": "session.stuck",
"agent-needs-input": "session.needs_input",
"agent-exited": "session.exited",
"pr-closed": "pr.closed",
"pr-merged": "pr.merged",
"ci-failed": "ci.failing",
"review-comments": "review.changes_requested",
"merge-conflicts": "merge.conflicts",
}
for key, want := range cases {
if got := SemanticTypeForReaction(key); got != want {
t.Fatalf("SemanticTypeForReaction(%q) = %q, want %q", key, got, want)
}
}
}
func TestRendererPayloadIncludesSessionProjectIssueAndBranch(t *testing.T) {
r := NewRenderer(fakeReader{rec: renderRecord()})
row, err := r.Render(context.Background(), ports.Event{
Type: "reaction.agent-needs-input", Priority: ports.PriorityUrgent,
ProjectID: "ao", SessionID: "ao-7", Message: "needs input",
Reaction: &ports.ReactionEvent{Key: "agent-needs-input", Action: "notify"},
OccurredAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
var p Payload
if err := json.Unmarshal(row.Payload, &p); err != nil {
t.Fatal(err)
}
if p.SchemaVersion != 3 || p.SemanticType != "session.needs_input" {
t.Fatalf("payload header = %+v", p)
}
if p.Subject.Session == nil || p.Subject.Session.ID != "ao-7" || p.Subject.Session.ProjectID != "ao" {
t.Fatalf("session subject missing: %+v", p.Subject.Session)
}
if p.Subject.Issue == nil || p.Subject.Issue.ID != "AO-12" || p.Subject.Branch != "feat/example" {
t.Fatalf("issue/branch missing: %+v", p.Subject)
}
}
func TestRendererPRPayloadIncludesFacts(t *testing.T) {
r := NewRenderer(fakeReader{rec: renderRecord(), pr: domain.PRFacts{
Exists: true, URL: "https://github.com/org/repo/pull/12", Number: 12,
CI: domain.CIFailing, Review: domain.ReviewChangesRequest, Mergeability: domain.MergeConflicting,
}})
row, err := r.Render(context.Background(), ports.Event{
Type: "reaction.review-comments", Priority: ports.PriorityAction,
ProjectID: "ao", SessionID: "ao-7", Message: "review",
Reaction: &ports.ReactionEvent{Key: "review-comments", Action: "notify"},
})
if err != nil {
t.Fatal(err)
}
var p Payload
if err := json.Unmarshal(row.Payload, &p); err != nil {
t.Fatal(err)
}
if p.Subject.PR == nil || p.Subject.PR.URL != "https://github.com/org/repo/pull/12" || p.Subject.PR.Number != 12 {
t.Fatalf("pr subject missing: %+v", p.Subject.PR)
}
if p.CI == nil || p.CI.Status != "failing" {
t.Fatalf("ci missing: %+v", p.CI)
}
if p.Review == nil || p.Review.Decision != "changes_requested" {
t.Fatalf("review missing: %+v", p.Review)
}
if p.Merge == nil || p.Merge.Conflicts == nil || *p.Merge.Conflicts != true || p.Merge.Ready == nil || *p.Merge.Ready != false {
t.Fatalf("merge missing: %+v", p.Merge)
}
}
func TestRendererEscalationPayloadIncludesDetails(t *testing.T) {
r := NewRenderer(fakeReader{rec: renderRecord()})
row, err := r.Render(context.Background(), ports.Event{
Type: "reaction.escalated", Priority: ports.PriorityUrgent,
ProjectID: "ao", SessionID: "ao-7", Message: "escalated",
Reaction: &ports.ReactionEvent{Key: "ci-failed", Action: "escalated"},
Escalation: &ports.EscalationEvent{Attempts: 3, Cause: "max_retries", DurationMs: 42},
})
if err != nil {
t.Fatal(err)
}
var p Payload
if err := json.Unmarshal(row.Payload, &p); err != nil {
t.Fatal(err)
}
if p.Reaction == nil || p.Reaction.Key != "ci-failed" || p.Reaction.Action != "escalated" {
t.Fatalf("reaction missing: %+v", p.Reaction)
}
if p.Escalation == nil || p.Escalation.Attempts != 3 || p.Escalation.Cause != "max_retries" || p.Escalation.DurationMs != 42 {
t.Fatalf("escalation missing: %+v", p.Escalation)
}
}
func renderRecord() domain.SessionRecord {
return domain.SessionRecord{
ID: "ao-7",
ProjectID: "ao",
IssueID: "AO-12",
Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionNeedsInput}},
Metadata: domain.SessionMetadata{Branch: "feat/example"},
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
}