package events import ( "net/http" "strings" "testing" "time" ) var testNow = time.Date(2026, 8, 28, 22, 0, 0, 0, time.UTC) func TestNormalize(t *testing.T) { cases := []struct { name string source string headers map[string]string body string want Event wantErr string }{ { name: "gitea pr approved", source: SourceGitea, headers: map[string]string{ giteaDeliveryHeader: "d-1234", }, body: `{"action":"approved","number":5, "pull_request":{"number":5,"title":"Add events receiver","merged":false}, "repository":{"full_name":"ukrrs/MOPAC"}, "sender":{"login":"charles"}}`, want: Event{ Source: SourceGitea, Kind: "pr_approved", Action: ActionPipelineStep, Actor: "charles", SubjectID: "gitea:pr:ukrrs/MOPAC#5", Subject: "Add events receiver", Repo: "ukrrs/MOPAC", ProviderID: "d-1234", }, }, { name: "gitea pr closed and merged maps to pr_merged", source: SourceGitea, body: `{"action":"closed","number":7, "pull_request":{"number":7,"title":"Fix loop","merged":true}, "repository":{"full_name":"ukrrs/MOPAC"},"sender":{"login":"alice"}}`, want: Event{ Source: SourceGitea, Kind: "pr_merged", Action: ActionPipelineStep, Actor: "alice", SubjectID: "gitea:pr:ukrrs/MOPAC#7", Subject: "Fix loop", Repo: "ukrrs/MOPAC", }, }, { name: "gitea pr opened is stored but ignored", source: SourceGitea, body: `{"action":"opened","number":9, "pull_request":{"number":9,"title":"New"},"sender":{"login":"bob"}}`, want: Event{ Source: SourceGitea, Kind: "pr_opened", Action: ActionIgnore, Actor: "bob", SubjectID: "gitea:pr:9", Subject: "New", }, }, { name: "gitea push event", source: SourceGitea, headers: map[string]string{ "X-Gitea-Event": "push", giteaDeliveryHeader: "d-push", }, body: `{"ref":"refs/heads/main","sender":{"login":"ci"}}`, want: Event{ Source: SourceGitea, Kind: "push", Action: ActionIgnore, Actor: "ci", ProviderID: "d-push", }, }, { name: "redmine webhook plugin shape", source: SourceRedmine, body: `{"event_name":"issue_updated", "payload":{"issue":{"id":42,"subject":"Ship phase 2b","author":{"name":"charles"}}, "user":{"login":"charles"}}}`, want: Event{ Source: SourceRedmine, Kind: "issue_updated", Action: ActionDispatchTurn, Actor: "charles", SubjectID: "redmine:issue:42", Subject: "Ship phase 2b", }, }, { name: "redmine flat shape defaults kind", source: SourceRedmine, body: `{"issue":{"id":43,"subject":"Flat plugin","author":{"login":"dana"}}}`, want: Event{ Source: SourceRedmine, Kind: "issue_updated", Action: ActionDispatchTurn, Actor: "dana", SubjectID: "redmine:issue:43", Subject: "Flat plugin", }, }, { name: "redmine journal event dispatches", source: SourceRedmine, body: `{"event_name":"journal_created","payload":{"issue":{"id":8,"subject":"Note"}}}`, want: Event{ Source: SourceRedmine, Kind: "journal_created", Action: ActionDispatchTurn, SubjectID: "redmine:issue:8", Subject: "Note", Actor: "unknown", }, }, { name: "discourse post created", source: SourceDiscourse, headers: map[string]string{ "X-Discourse-Event": "post_created", discourseEventIDHeader: "42", }, body: `{"post":{"id":99,"topic_id":7,"username":"charles","topic_title":"Phase 2b plan"}}`, want: Event{ Source: SourceDiscourse, Kind: "post_created", Action: ActionRespondTurn, Actor: "charles", SubjectID: "discourse:topic:7", Subject: "Phase 2b plan", ProviderID: "42", }, }, { name: "discourse without headers reads payload", source: SourceDiscourse, body: `{"event_type":"post_edited","post":{"topic_id":3,"username":"eve"},"id":"77"}`, want: Event{ Source: SourceDiscourse, Kind: "post_edited", Action: ActionRespondTurn, Actor: "eve", SubjectID: "discourse:topic:3", ProviderID: "77", }, }, { name: "discourse user event ignored", source: SourceDiscourse, headers: map[string]string{"X-Discourse-Event": "user_created"}, body: `{"user":{"username":"newbie"}}`, want: Event{ Source: SourceDiscourse, Kind: "user_created", Action: ActionIgnore, Actor: "newbie", }, }, { name: "not json", source: SourceGitea, body: `not json`, wantErr: "payload is not a JSON object", }, { name: "json array not object", source: SourceRedmine, body: `[1,2,3]`, wantErr: "payload is not a JSON object", }, { name: "unknown source", source: "slack", body: `{}`, wantErr: "unknown event source", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { header := http.Header{} for k, v := range tc.headers { header.Set(k, v) } ev, err := Normalize(tc.source, header, []byte(tc.body), testNow) if tc.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("error = %v, want containing %q", err, tc.wantErr) } return } if err != nil { t.Fatalf("Normalize: %v", err) } got := Event{ Source: ev.Source, Kind: ev.Kind, Action: ev.Action, Actor: ev.Actor, SubjectID: ev.SubjectID, Subject: ev.Subject, Repo: ev.Repo, ProviderID: ev.ProviderID, } if tc.want.ProviderID == "" { got.ProviderID = "" // digest fallback is asserted separately } if got != tc.want { t.Errorf("normalized event:\n got %+v\n want %+v", got, tc.want) } if ev.ReceivedAt != testNow { t.Errorf("ReceivedAt = %v, want %v", ev.ReceivedAt, testNow) } if len(ev.PayloadDigest) != 64 { t.Errorf("PayloadDigest = %q, want sha256 hex", ev.PayloadDigest) } // No provider id anywhere -> digest fallback (dedup still works). if tc.want.ProviderID == "" && ev.ProviderID != ev.PayloadDigest { t.Errorf("ProviderID = %q, want digest fallback", ev.ProviderID) } }) } } func TestMapAction(t *testing.T) { cases := []struct { source, kind, want string }{ {SourceRedmine, "issue_updated", ActionDispatchTurn}, {SourceRedmine, "issue_note_added", ActionDispatchTurn}, {SourceRedmine, "issue_created", ActionIgnore}, {SourceRedmine, "member_added", ActionIgnore}, {SourceDiscourse, "post_created", ActionRespondTurn}, {SourceDiscourse, "post", ActionRespondTurn}, {SourceDiscourse, "topic_created", ActionIgnore}, {SourceGitea, "pr_approved", ActionPipelineStep}, {SourceGitea, "pr_merged", ActionPipelineStep}, {SourceGitea, "pr_synchronized", ActionIgnore}, {SourceGitea, "issue_opened", ActionIgnore}, {SourceGitea, "push", ActionIgnore}, } for _, tc := range cases { if got := MapAction(tc.source, tc.kind); got != tc.want { t.Errorf("MapAction(%s, %s) = %s, want %s", tc.source, tc.kind, got, tc.want) } } } func TestNormalizeDigestStable(t *testing.T) { body := []byte(`{"action":"approved","number":1,"pull_request":{"number":1}}`) h := http.Header{giteaDeliveryHeader: []string{"same"}} a, err := Normalize(SourceGitea, h, body, testNow) if err != nil { t.Fatal(err) } b, err := Normalize(SourceGitea, h, body, testNow.Add(time.Hour)) if err != nil { t.Fatal(err) } if a.DedupKey() != b.DedupKey() { t.Errorf("same delivery must keep one dedup key: %s vs %s", a.DedupKey(), b.DedupKey()) } if a.PayloadDigest != b.PayloadDigest { t.Errorf("digest changed for identical payload") } }