package gitoevents import ( "strings" "testing" "github.com/nomadcode/nomadcode-core/internal/protosocket" ) // branchUpdatedEnvelope builds a contract-shaped broadcast envelope fixture. func branchUpdatedEnvelope() protosocket.Envelope { return protosocket.Envelope{ ProtocolVersion: protosocket.ProtocolVersion, ID: "event-id", Type: "event", Channel: EventChannel, Action: BranchUpdatedAction, Payload: map[string]any{ "id": "event-id", "type": "branch.updated", "provider": "forgejo", "delivery_id": "delivery-id", "repo_id": "nomadcode", "branch": "develop", "before": "old-sha", "after": "new-sha", "changed_files": []any{ map[string]any{ "path": "agent-roadmap/phase/example/milestones/example.md", "change_type": "modified", }, map[string]any{ "path": "README.md", "change_type": "modified", }, }, "observed_at": "2026-06-13T00:00:00Z", "created_at": "2026-06-13T00:00:00Z", }, } } func TestBuildSubscribeEnvelopeMatchesContract(t *testing.T) { env := BuildSubscribeEnvelope("msg-1", "nomadcode", "develop") if env.ProtocolVersion != protosocket.ProtocolVersion { t.Fatalf("protocol_version: got %q", env.ProtocolVersion) } if env.ID != "msg-1" { t.Fatalf("id: got %q", env.ID) } if env.Type != "request" { t.Fatalf("type: got %q, want request", env.Type) } if env.Channel != EventChannel { t.Fatalf("channel: got %q, want %q", env.Channel, EventChannel) } if env.Action != SubscribeAction { t.Fatalf("action: got %q, want %q", env.Action, SubscribeAction) } if got := env.Payload["repo_id"]; got != "nomadcode" { t.Fatalf("payload.repo_id: got %v", got) } if got := env.Payload["branch"]; got != "develop" { t.Fatalf("payload.branch: got %v", got) } events, ok := env.Payload["events"].([]any) if !ok || len(events) != 1 || events[0] != BranchUpdatedAction { t.Fatalf("payload.events: got %v", env.Payload["events"]) } } func TestBuildSubscribeEnvelopeDefaultsBranch(t *testing.T) { env := BuildSubscribeEnvelope("msg-1", "nomadcode", "") if got := env.Payload["branch"]; got != DefaultBranch { t.Fatalf("payload.branch default: got %v, want %q", got, DefaultBranch) } } func TestDecodeBranchUpdatedEnvelope(t *testing.T) { ev, err := DecodeBranchUpdatedEnvelope(branchUpdatedEnvelope()) if err != nil { t.Fatalf("decode: unexpected error: %v", err) } if ev.RepoID != "nomadcode" || ev.Branch != "develop" { t.Fatalf("repo/branch: got %q/%q", ev.RepoID, ev.Branch) } if ev.Before != "old-sha" || ev.After != "new-sha" { t.Fatalf("before/after: got %q/%q", ev.Before, ev.After) } if ev.DeliveryID != "delivery-id" || ev.Provider != "forgejo" { t.Fatalf("delivery/provider: got %q/%q", ev.DeliveryID, ev.Provider) } if len(ev.ChangedFiles) != 2 { t.Fatalf("changed_files: got %d, want 2", len(ev.ChangedFiles)) } if ev.ChangedFiles[0].Path != "agent-roadmap/phase/example/milestones/example.md" { t.Fatalf("changed_files[0].path: got %q", ev.ChangedFiles[0].Path) } } func TestDecodeBranchUpdatedEnvelopeAcceptsConsumerNeutralBasePayload(t *testing.T) { env := protosocket.Envelope{ ProtocolVersion: protosocket.ProtocolVersion, ID: "event-id", Type: "event", Channel: EventChannel, Action: BranchUpdatedAction, Payload: map[string]any{ "id": "event-id", "type": BranchUpdatedAction, "repo_id": "nomadcode", "branch": "develop", "before": "old-sha", "after": "new-sha", "changed_files": []any{ map[string]any{ "path": "agent-roadmap/phase/example/milestones/example.md", "change_type": "modified", }, }, }, } ev, err := DecodeBranchUpdatedEnvelope(env) if err != nil { t.Fatalf("decode consumer-neutral payload: unexpected error: %v", err) } if ev.RepoID != "nomadcode" || ev.Branch != "develop" || ev.After != "new-sha" { t.Fatalf("decoded event: got %+v", ev) } if len(ev.MilestoneChangedFiles()) != 1 { t.Fatalf("milestone wakeup hint: got %+v", ev.MilestoneChangedFiles()) } } func TestDecodeBranchUpdatedEnvelopeIgnoresOptionalConsumerExtensions(t *testing.T) { env := branchUpdatedEnvelope() env.Payload["metadata"] = map[string]any{ "nomadcode": map[string]any{"ignored": true}, } env.Payload["custom"] = map[string]any{ "consumer": "nomadcode", } ev, err := DecodeBranchUpdatedEnvelope(env) if err != nil { t.Fatalf("decode with optional extensions: unexpected error: %v", err) } if ev.RepoID != "nomadcode" || ev.Branch != "develop" { t.Fatalf("decoded event: got %+v", ev) } } func TestDecodeBranchUpdatedEnvelopeRejectsWrongAction(t *testing.T) { env := branchUpdatedEnvelope() env.Action = "task.status.changed" if _, err := DecodeBranchUpdatedEnvelope(env); err == nil { t.Fatal("expected error for wrong action") } } func TestDecodeBranchUpdatedEnvelopeRejectsWrongChannel(t *testing.T) { env := branchUpdatedEnvelope() env.Channel = "task" if _, err := DecodeBranchUpdatedEnvelope(env); err == nil { t.Fatal("expected error for wrong channel") } } func TestDecodeBranchUpdatedEnvelopeRejectsMalformedChangedFiles(t *testing.T) { env := branchUpdatedEnvelope() env.Payload["changed_files"] = "not-a-list" if _, err := DecodeBranchUpdatedEnvelope(env); err == nil { t.Fatal("expected error for malformed changed_files") } } func TestDecodeBranchUpdatedEnvelopeRejectsMissingRepoID(t *testing.T) { env := branchUpdatedEnvelope() delete(env.Payload, "repo_id") if _, err := DecodeBranchUpdatedEnvelope(env); err == nil { t.Fatal("expected error for missing repo_id") } } func TestDecodeBranchUpdatedPayloadAcceptsBasePayloadWithoutNomadCodeFields(t *testing.T) { // Gito base JSON shape: minimal fields without NomadCode-specific extensions. payload := map[string]any{ "id": "event-123", "type": "branch.updated", "provider": "forgejo", "repo_id": "nomadcode", "branch": "develop", "before": "old-sha", "after": "new-sha", "changed_files": []any{ map[string]any{ "path": "agent-roadmap/phase/example/milestones/example.md", "change_type": "modified", }, map[string]any{ "path": "README.md", "change_type": "modified", }, }, "observed_at": "2026-06-13T00:00:00Z", "created_at": "2026-06-13T00:00:00Z", } ev, err := DecodeBranchUpdatedPayload(payload) if err != nil { t.Fatalf("decode base payload: unexpected error: %v", err) } if ev.RepoID != "nomadcode" || ev.Branch != "develop" || ev.After != "new-sha" { t.Fatalf("decoded event: got %+v", ev) } if ev.ID != "event-123" || ev.Provider != "forgejo" { t.Fatalf("id/provider: got %+v", ev) } if len(ev.ChangedFiles) != 2 { t.Fatalf("changed_files: got %d, want 2", len(ev.ChangedFiles)) } if ev.DeliveryID != "" { t.Fatalf("delivery_id should be empty for minimal payload, got %q", ev.DeliveryID) } } func TestDecodeBranchUpdatedPayloadRejectsWrongType(t *testing.T) { payload := map[string]any{ "id": "event-456", "type": "task.status.changed", "repo_id": "nomadcode", "branch": "develop", "after": "new-sha", } _, err := DecodeBranchUpdatedPayload(payload) if err == nil { t.Fatal("expected error for wrong type") } if !strings.Contains(err.Error(), "type") { t.Fatalf("error should mention type validation: %v", err) } } func TestDecodeBranchUpdatedPayloadRejectsMalformedChangedFiles(t *testing.T) { payload := map[string]any{ "id": "event-789", "type": "branch.updated", "repo_id": "nomadcode", "branch": "develop", "after": "new-sha", "changed_files": "not-a-list", } _, err := DecodeBranchUpdatedPayload(payload) if err == nil { t.Fatal("expected error for malformed changed_files") } if !strings.Contains(err.Error(), "changed_files") { t.Fatalf("error should mention changed_files: %v", err) } } func TestDecodeBranchUpdatedPayloadRejectsMissingRepoID(t *testing.T) { payload := map[string]any{ "type": "branch.updated", "branch": "develop", "after": "new-sha", } _, err := DecodeBranchUpdatedPayload(payload) if err == nil { t.Fatal("expected error for missing repo_id") } } func TestDecodeBranchUpdatedPayloadRejectsMissingBranch(t *testing.T) { payload := map[string]any{ "type": "branch.updated", "repo_id": "nomadcode", "after": "new-sha", } _, err := DecodeBranchUpdatedPayload(payload) if err == nil { t.Fatal("expected error for missing branch") } } func TestDecodeBranchUpdatedPayloadEmptyChangedFiles(t *testing.T) { payload := map[string]any{ "id": "event-empty", "type": "branch.updated", "repo_id": "nomadcode", "branch": "develop", "after": "new-sha", "changed_files": []any{}, } ev, err := DecodeBranchUpdatedPayload(payload) if err != nil { t.Fatalf("decode empty changed_files: unexpected error: %v", err) } if ev.ChangedFiles == nil { t.Fatalf("changed_files should be empty slice, got nil") } if len(ev.ChangedFiles) != 0 { t.Fatalf("changed_files: got %d, want 0", len(ev.ChangedFiles)) } } func TestDecodeBranchUpdatedPayloadNilPayload(t *testing.T) { _, err := DecodeBranchUpdatedPayload(nil) if err == nil { t.Fatal("expected error for nil payload") } } func TestDecodeBranchUpdatedPayloadAllowsNilChangedFiles(t *testing.T) { payload := map[string]any{ "id": "event-nil-cf", "type": "branch.updated", "repo_id": "nomadcode", "branch": "develop", "after": "new-sha", "provider": "forgejo", } ev, err := DecodeBranchUpdatedPayload(payload) if err != nil { t.Fatalf("decode with nil changed_files: unexpected error: %v", err) } if ev.ChangedFiles != nil { t.Fatalf("changed_files should be nil, got %+v", ev.ChangedFiles) } } func TestDecodeBranchUpdatedPayloadHandlesMalformedChangedFileItem(t *testing.T) { payload := map[string]any{ "id": "event-bad-item", "type": "branch.updated", "repo_id": "nomadcode", "branch": "develop", "after": "new-sha", "changed_files": []any{ "not-an-object", }, } _, err := DecodeBranchUpdatedPayload(payload) if err == nil { t.Fatal("expected error for malformed changed_files item") } if !strings.Contains(err.Error(), "changed_files") { t.Fatalf("error should mention changed_files: %v", err) } } func TestDecodeBranchUpdatedPayloadWithoutTypeField(t *testing.T) { // type field is optional; when absent it should be accepted. payload := map[string]any{ "id": "event-no-type", "repo_id": "nomadcode", "branch": "develop", "after": "new-sha", "provider": "forgejo", } ev, err := DecodeBranchUpdatedPayload(payload) if err != nil { t.Fatalf("decode without type: unexpected error: %v", err) } if ev.Type != "" { t.Fatalf("type should be empty when absent, got %q", ev.Type) } if ev.RepoID != "nomadcode" || ev.Branch != "develop" { t.Fatalf("repo/branch: got %+v", ev) } } func TestIsTarget(t *testing.T) { ev := BranchUpdatedEvent{RepoID: "nomadcode", Branch: "develop"} if !ev.IsTarget("nomadcode", "develop") { t.Fatal("expected match for same repo/branch") } if ev.IsTarget("other-repo", "develop") { t.Fatal("expected no match for different repo") } if ev.IsTarget("nomadcode", "main") { t.Fatal("expected no match for different branch") } if !ev.IsTarget("nomadcode", "") { t.Fatal("expected empty branch to default to develop and match") } } func TestMilestoneChangedFiles(t *testing.T) { ev := BranchUpdatedEvent{ ChangedFiles: []ChangedFile{ {Path: "agent-roadmap/phase/example/milestones/example.md", ChangeType: "modified"}, {Path: "./agent-roadmap/phase/p/milestones/m.md", ChangeType: "added"}, {Path: "README.md", ChangeType: "modified"}, {Path: "agent-roadmap/phase/example/PHASE.md", ChangeType: "modified"}, {Path: "agent-roadmap/phase/example/milestones/notes.txt", ChangeType: "modified"}, }, } got := ev.MilestoneChangedFiles() if len(got) != 2 { t.Fatalf("milestone changed files: got %d, want 2 (%+v)", len(got), got) } if got[0].Path != "agent-roadmap/phase/example/milestones/example.md" { t.Fatalf("milestone[0]: got %q", got[0].Path) } if got[1].Path != "./agent-roadmap/phase/p/milestones/m.md" { t.Fatalf("milestone[1]: got %q", got[1].Path) } }