Files
kandev/apps/backend/internal/automation/github_pr_merged_subscriber_test.go
T

916 lines
27 KiB
Go

package automation
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/github"
)
// fakeTaskOriginLookup is a configurable stub for TaskOriginLookup.
type fakeTaskOriginLookup struct {
results map[string]fakeOriginResult
}
type fakeOriginResult struct {
workspaceID string
isAutomationRun bool
ok bool
}
func (f *fakeTaskOriginLookup) TaskWorkspaceAndAutomationOrigin(_ context.Context, taskID string) (string, bool, bool) {
if r, ok := f.results[taskID]; ok {
return r.workspaceID, r.isAutomationRun, r.ok
}
return "", false, false
}
func newPRMergedTestService(t *testing.T) *Service {
t.Helper()
store := setupTestStore(t)
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatal(err)
}
eb := bus.NewMemoryEventBus(log)
return NewService(store, eb, log)
}
func newPRMergedSubscriber(t *testing.T, svc *Service, lookup TaskOriginLookup) *GitHubPRMergedSubscriber {
t.Helper()
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatal(err)
}
if lookup != nil {
svc.SetTaskOriginLookup(lookup)
}
sub := NewGitHubPRMergedSubscriber(svc, svc.eventBus, log)
sub.Start(context.Background())
t.Cleanup(sub.Stop)
return sub
}
func publishPRUpdated(t *testing.T, svc *Service, pr *github.TaskPR) {
t.Helper()
if err := svc.eventBus.Publish(context.Background(), events.GitHubTaskPRUpdated, bus.NewEvent(
events.GitHubTaskPRUpdated, "test", pr,
)); err != nil {
t.Fatalf("Publish: %v", err)
}
}
func mergedPR() *github.TaskPR {
ts := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)
return &github.TaskPR{
TaskID: "t_abc123",
Owner: "acme",
Repo: "api",
PRNumber: 7,
PRURL: exampleGitHubPRURL,
BaseBranch: "main",
State: "merged",
MergedAt: &ts,
}
}
// TestGitHubPRMergedSubscriberFiresHappyPath checks the full happy path:
// creates an automation with an all-repos trigger, publishes a merged PR event,
// and verifies the AutomationTriggered event fires with the expected data.
func TestGitHubPRMergedSubscriberFiresHappyPath(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", isAutomationRun: false, ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
trig := addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{
AllRepos: true,
})
pr := mergedPR()
publishPRUpdated(t, svc, pr)
select {
case evt := <-fired:
if evt.AutomationID != a.ID || evt.TriggerID != trig.ID {
t.Fatalf("unexpected evt ids: %+v", evt)
}
if evt.TriggerType != TriggerTypeGitHubPRMerged {
t.Fatalf("wrong TriggerType: %s", evt.TriggerType)
}
want := "pr_merged:t_abc123:acme/api#7"
if evt.DedupKey != want {
t.Fatalf("dedup key = %q, want %q", evt.DedupKey, want)
}
var data map[string]interface{}
if err := json.Unmarshal(evt.TriggerData, &data); err != nil {
t.Fatalf("unmarshal trigger data: %v", err)
}
checks := map[string]string{
"task_id": "t_abc123",
automationRepoKey: "acme/api",
"pr_url": exampleGitHubPRURL,
automationBaseBranchKey: defaultBranchMain,
"merged_at": "2026-03-08T12:00:00Z",
}
for k, wantV := range checks {
if got, ok := data[k]; !ok || got != wantV {
t.Errorf("data[%q] = %v, want %q", k, got, wantV)
}
}
if num, ok := data["pr_number"].(float64); !ok || int(num) != 7 {
t.Errorf("data[pr_number] = %v, want 7", data["pr_number"])
}
// Security contract: exactly 6 keys, no prose fields.
// Spec lines 1320-1326: "no more and no fewer" and "what fails if someone widens the map."
if len(data) != 6 {
t.Errorf("trigger data has %d keys, want exactly 6: %v", len(data), data)
}
for _, forbidden := range []string{"pr_title", "body", "author_login", "head_branch"} {
if _, exists := data[forbidden]; exists {
t.Errorf("trigger data must not contain %q", forbidden)
}
}
default:
t.Fatal("expected AutomationTriggered event")
}
}
// TestGitHubPRMergedSubscriberGates verifies that each per-event gate blocks
// firing when its condition is violated.
func TestGitHubPRMergedSubscriberGates(t *testing.T) {
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", isAutomationRun: false, ok: true},
},
}
tests := []struct {
name string
mutate func(*github.TaskPR)
}{
{"empty task id", func(p *github.TaskPR) { p.TaskID = "" }},
{"state open", func(p *github.TaskPR) { p.State = "open" }},
{"state closed", func(p *github.TaskPR) { p.State = "closed" }},
{"empty owner", func(p *github.TaskPR) { p.Owner = "" }},
{"empty repo", func(p *github.TaskPR) { p.Repo = "" }},
{"zero pr_number", func(p *github.TaskPR) { p.PRNumber = 0 }},
{"negative pr_number", func(p *github.TaskPR) { p.PRNumber = -1 }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newPRMergedTestService(t)
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
pr := mergedPR()
tt.mutate(pr)
publishPRUpdated(t, svc, pr)
select {
case evt := <-fired:
t.Fatalf("expected no fire, got %+v", evt)
default:
}
})
}
}
// TestGitHubPRMergedSubscriberWrongPayloadType verifies gate 1: publishing
// the wrong payload type is silently ignored.
func TestGitHubPRMergedSubscriberWrongPayloadType(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
// Publish a string instead of *github.TaskPR.
if err := svc.eventBus.Publish(context.Background(), events.GitHubTaskPRUpdated, bus.NewEvent(
events.GitHubTaskPRUpdated, "test", "not-a-task-pr",
)); err != nil {
t.Fatal(err)
}
select {
case evt := <-fired:
t.Fatalf("expected no fire, got %+v", evt)
default:
}
}
// TestGitHubPRMergedSubscriberAcceptsJSONDecodedPayload verifies the NATS wire
// representation is normalized to the same TaskPR accepted by the in-memory
// bus. NATS decodes Event.Data into an untyped JSON object.
func TestGitHubPRMergedSubscriberAcceptsJSONDecodedPayload(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
wireData, err := json.Marshal(mergedPR())
if err != nil {
t.Fatalf("marshal TaskPR: %v", err)
}
var decodedData map[string]interface{}
if err := json.Unmarshal(wireData, &decodedData); err != nil {
t.Fatalf("unmarshal TaskPR wire data: %v", err)
}
if err := svc.eventBus.Publish(context.Background(), events.GitHubTaskPRUpdated, bus.NewEvent(
events.GitHubTaskPRUpdated, "nats-wire", decodedData,
)); err != nil {
t.Fatalf("Publish: %v", err)
}
select {
case evt := <-fired:
if evt.AutomationID != a.ID {
t.Fatalf("unexpected automation id: %q", evt.AutomationID)
}
case <-time.After(time.Second):
t.Fatal("expected AutomationTriggered event for JSON-decoded payload")
}
}
// TestGitHubPRMergedSubscriberLateLookupRecovers verifies that the subscriber
// remains attached when the lookup is temporarily unwired and starts working
// on the next event after the lookup is installed.
func TestGitHubPRMergedSubscriberLateLookupRecovers(t *testing.T) {
svc := newPRMergedTestService(t)
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("NewFromZap: %v", err)
}
sub := NewGitHubPRMergedSubscriber(svc, svc.eventBus, log)
t.Cleanup(sub.Stop)
sub.Start(context.Background())
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
svc.SetTaskOriginLookup(lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
publishPRUpdated(t, svc, mergedPR())
select {
case evt := <-fired:
if evt.AutomationID != a.ID {
t.Fatalf("unexpected automation id: %q", evt.AutomationID)
}
case <-time.After(time.Second):
t.Fatal("expected AutomationTriggered event after late lookup wiring")
}
}
// TestGitHubPRMergedSubscriberStateVariants verifies gate 3 allows
// case-insensitive "Merged" / " merged " variants.
func TestGitHubPRMergedSubscriberStateVariants(t *testing.T) {
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
cases := []string{"Merged", "MERGED", " merged "}
for _, state := range cases {
t.Run(state, func(t *testing.T) {
svc := newPRMergedTestService(t)
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
pr := mergedPR()
pr.State = state
publishPRUpdated(t, svc, pr)
select {
case <-fired:
default:
t.Fatalf("expected fire for state %q", state)
}
})
}
}
// TestGitHubPRMergedSubscriberLoopGuard verifies gate 7: tasks created by
// an automation run do not trigger further automation runs.
func TestGitHubPRMergedSubscriberLoopGuard(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_auto": {workspaceID: "ws-1", isAutomationRun: true, ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
pr := mergedPR()
pr.TaskID = "t_auto"
publishPRUpdated(t, svc, pr)
select {
case evt := <-fired:
t.Fatalf("loop guard failed: automation fired for automation-run task %+v", evt)
default:
}
}
// TestGitHubPRMergedSubscriberLookupNotResolved verifies gate 6: if the
// task lookup returns ok=false, no trigger fires.
func TestGitHubPRMergedSubscriberLookupNotResolved(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{results: map[string]fakeOriginResult{}}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
publishPRUpdated(t, svc, mergedPR())
select {
case evt := <-fired:
t.Fatalf("expected no fire when task not resolved, got %+v", evt)
default:
}
}
// TestGitHubPRMergedSubscriberWorkspaceScoping verifies gate 8: the
// automation's workspace must match the resolved workspace from the task
// lookup. TaskPR.WorkspaceID is completely ignored.
func TestGitHubPRMergedSubscriberWorkspaceScoping(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
// Task resolves to ws-2, not ws-1 where the automation lives.
"t_abc123": {workspaceID: "ws-2", ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
// Even though TaskPR.WorkspaceID matches ws-1, the lookup's ws-2 wins.
pr := mergedPR()
pr.WorkspaceID = "ws-1"
publishPRUpdated(t, svc, pr)
select {
case evt := <-fired:
t.Fatalf("workspace scoping failed: fired for wrong workspace %+v", evt)
default:
}
}
// TestGitHubPRMergedSubscriberRepoFilter verifies gate 9.
func TestGitHubPRMergedSubscriberRepoFilter(t *testing.T) {
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
tests := []struct {
name string
cfg GitHubPRMergedTriggerConfig
pr func() *github.TaskPR
wantFire bool
}{
{
name: "all_repos fires any",
cfg: GitHubPRMergedTriggerConfig{AllRepos: true},
pr: func() *github.TaskPR {
p := mergedPR()
p.Owner, p.Repo = "other", "whatever"
return p
},
wantFire: true,
},
{
name: "exact owner+name match",
cfg: GitHubPRMergedTriggerConfig{
Repos: []github.RepoFilter{{Owner: "acme", Name: "api"}},
},
pr: mergedPR,
wantFire: true,
},
{
name: "case-insensitive owner+name",
cfg: GitHubPRMergedTriggerConfig{
Repos: []github.RepoFilter{{Owner: "ACME", Name: "API"}},
},
pr: mergedPR,
wantFire: true,
},
{
name: "org wildcard (empty name)",
cfg: GitHubPRMergedTriggerConfig{
Repos: []github.RepoFilter{{Owner: "acme", Name: ""}},
},
pr: mergedPR,
wantFire: true,
},
{
name: "wrong owner",
cfg: GitHubPRMergedTriggerConfig{
Repos: []github.RepoFilter{{Owner: "other", Name: "api"}},
},
pr: mergedPR,
wantFire: false,
},
{
name: "wrong repo name",
cfg: GitHubPRMergedTriggerConfig{
Repos: []github.RepoFilter{{Owner: "acme", Name: "other"}},
},
pr: mergedPR,
wantFire: false,
},
{
name: "all_repos=false empty list never matches",
cfg: GitHubPRMergedTriggerConfig{AllRepos: false, Repos: nil},
pr: mergedPR,
wantFire: false,
},
{
name: "empty owner entry skipped (no match)",
cfg: GitHubPRMergedTriggerConfig{
Repos: []github.RepoFilter{{Owner: "", Name: ""}},
},
pr: mergedPR,
wantFire: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newPRMergedTestService(t)
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, tt.cfg)
publishPRUpdated(t, svc, tt.pr())
select {
case <-fired:
if !tt.wantFire {
t.Fatal("expected no fire, but got one")
}
default:
if tt.wantFire {
t.Fatal("expected fire, but got none")
}
}
})
}
}
// TestGitHubPRMergedSubscriberBranchFilter verifies gate 10.
func TestGitHubPRMergedSubscriberBranchFilter(t *testing.T) {
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
tests := []struct {
name string
branches []string
baseBranch string
wantFire bool
}{
{"empty list matches all", nil, "main", true},
{"all-empty entries matches all", []string{" ", ""}, "main", true},
{"exact match", []string{"main"}, "main", true},
{"glob match", []string{"release/*"}, "release/v2", true},
{"no match", []string{"main", "develop"}, "feature/x", false},
{"trim whitespace", []string{" main "}, "main", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := newPRMergedTestService(t)
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{
AllRepos: true,
BaseBranches: tt.branches,
})
pr := mergedPR()
pr.BaseBranch = tt.baseBranch
publishPRUpdated(t, svc, pr)
select {
case <-fired:
if !tt.wantFire {
t.Fatal("expected no fire")
}
default:
if tt.wantFire {
t.Fatal("expected fire")
}
}
})
}
}
// TestGitHubPRMergedSubscriberPerEventFiredKeySet verifies gate 11: when one
// automation has two matching triggers, it fires at most once per event.
func TestGitHubPRMergedSubscriberPerEventFiredKeySet(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
// One automation with TWO matching triggers.
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
publishPRUpdated(t, svc, mergedPR())
// Exactly one fire expected.
select {
case <-fired:
default:
t.Fatal("expected exactly one fire, got none")
}
// No second fire.
select {
case evt := <-fired:
t.Fatalf("per-event fired-key set failed: got second fire %+v", evt)
default:
}
}
// TestGitHubPRMergedSubscriberTwoAutomationsBothFire verifies that two
// different automations both fire for the same event (keyed per-automation).
func TestGitHubPRMergedSubscriberTwoAutomationsBothFire(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a1 := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a1.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
a2 := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a2.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
publishPRUpdated(t, svc, mergedPR())
count := 0
for i := 0; i < 2; i++ {
select {
case <-fired:
count++
default:
}
}
if count != 2 {
t.Fatalf("expected 2 fires (one per automation), got %d", count)
}
}
// TestGitHubPRMergedSubscriberStartIdempotent verifies that calling Start a
// second time is a no-op.
func TestGitHubPRMergedSubscriberStartIdempotent(t *testing.T) {
svc := newPRMergedTestService(t)
log, _ := logger.NewFromZap(zap.NewNop())
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
svc.SetTaskOriginLookup(lookup)
sub := NewGitHubPRMergedSubscriber(svc, svc.eventBus, log)
t.Cleanup(sub.Stop)
sub.Start(context.Background())
sub.Start(context.Background()) // second Start is a no-op
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
publishPRUpdated(t, svc, mergedPR())
select {
case <-fired:
default:
t.Fatal("expected fire after idempotent Start")
}
}
// TestGitHubPRMergedSubscriberStartWithNilLookup verifies that Start with no
// lookup wired sets started=true (subsequent starts are no-ops), emits exactly
// one warn log naming github_pr_merged, and remains subscribed for late lookup
// wiring.
// Spec lines 1368-1371: "emits exactly one log line at warn naming github_pr_merged."
func TestGitHubPRMergedSubscriberStartWithNilLookup(t *testing.T) {
svc := newPRMergedTestService(t)
core, obs := observer.New(zap.WarnLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatalf("NewFromZap: %v", err)
}
// Do NOT set lookup.
sub := NewGitHubPRMergedSubscriber(svc, svc.eventBus, log)
t.Cleanup(sub.Stop)
sub.Start(context.Background())
if !sub.started {
t.Fatal("started should be true even when lookup is nil")
}
if sub.sub == nil {
t.Fatal("sub should subscribe even when lookup is nil")
}
entries := obs.All()
if len(entries) != 1 {
t.Fatalf("expected exactly 1 log entry on nil-lookup Start, got %d: %v", len(entries), entries)
}
if entries[0].Level != zap.WarnLevel {
t.Errorf("log entry level = %v, want Warn", entries[0].Level)
}
if !strings.Contains(entries[0].Message, string(TriggerTypeGitHubPRMerged)) {
t.Errorf("log message %q does not contain trigger type %q", entries[0].Message, TriggerTypeGitHubPRMerged)
}
}
// TestGitHubPRMergedSubscriberStartWiredLookupLogsInfo verifies that Start with
// a wired lookup emits exactly one info log and no warn logs.
// Spec lines 1372-1375: "emits a line at info ... no warn line. Both branches are asserted."
func TestGitHubPRMergedSubscriberStartWiredLookupLogsInfo(t *testing.T) {
svc := newPRMergedTestService(t)
core, obs := observer.New(zap.DebugLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatalf("NewFromZap: %v", err)
}
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
svc.SetTaskOriginLookup(lookup)
sub := NewGitHubPRMergedSubscriber(svc, svc.eventBus, log)
t.Cleanup(sub.Stop)
sub.Start(context.Background())
var infoCount, warnCount int
for _, e := range obs.All() {
switch e.Level {
case zap.InfoLevel:
infoCount++
case zap.WarnLevel:
warnCount++
}
}
if infoCount == 0 {
t.Error("expected at least one Info log on wired-lookup Start, got none")
}
if warnCount != 0 {
t.Errorf("expected no Warn logs on wired-lookup Start, got %d", warnCount)
}
}
// TestGitHubPRMergedSubscriberStopIdempotent verifies Stop can be called
// multiple times without panicking.
func TestGitHubPRMergedSubscriberStopIdempotent(t *testing.T) {
svc := newPRMergedTestService(t)
log, _ := logger.NewFromZap(zap.NewNop())
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
svc.SetTaskOriginLookup(lookup)
sub := NewGitHubPRMergedSubscriber(svc, svc.eventBus, log)
sub.Start(context.Background())
sub.Stop()
sub.Stop() // second Stop is a no-op
}
// TestGitHubPRMergedSubscriberDedupKeyFormat verifies the dedup key is
// lowercase owner/repo to ensure case-normalization.
func TestGitHubPRMergedSubscriberDedupKeyFormat(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
pr := mergedPR()
pr.Owner = "ACME"
pr.Repo = "API"
pr.PRNumber = 42
publishPRUpdated(t, svc, pr)
select {
case evt := <-fired:
want := "pr_merged:t_abc123:acme/api#42"
if evt.DedupKey != want {
t.Fatalf("dedup key = %q, want %q", evt.DedupKey, want)
}
default:
t.Fatal("expected fire")
}
}
// TestGitHubPRMergedSubscriberNilMergedAt verifies a nil MergedAt produces
// an empty string for merged_at in the trigger data (not a panic).
func TestGitHubPRMergedSubscriberNilMergedAt(t *testing.T) {
svc := newPRMergedTestService(t)
lookup := &fakeTaskOriginLookup{
results: map[string]fakeOriginResult{
"t_abc123": {workspaceID: "ws-1", ok: true},
},
}
newPRMergedSubscriber(t, svc, lookup)
fired := subscribeAutomationTriggered(t, svc.eventBus)
a := createTestAutomation(t, svc, "ws-1")
addTestTrigger(t, svc, a.ID, TriggerTypeGitHubPRMerged, GitHubPRMergedTriggerConfig{AllRepos: true})
pr := mergedPR()
pr.MergedAt = nil
publishPRUpdated(t, svc, pr)
select {
case evt := <-fired:
var data map[string]interface{}
if err := json.Unmarshal(evt.TriggerData, &data); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := data["merged_at"]; got != "" {
t.Fatalf("merged_at = %v, want empty string", got)
}
default:
t.Fatal("expected fire")
}
}
// Unit tests for matchesMergedRepo and matchesMergedBranches helpers.
func TestMatchesMergedRepo(t *testing.T) {
tests := []struct {
name string
owner string
repo string
cfg GitHubPRMergedTriggerConfig
want bool
}{
{
name: "all_repos=true always matches",
owner: "x", repo: "y",
cfg: GitHubPRMergedTriggerConfig{AllRepos: true},
want: true,
},
{
name: "empty list never matches",
owner: "acme", repo: "api",
cfg: GitHubPRMergedTriggerConfig{},
want: false,
},
{
name: "exact match",
owner: "acme", repo: "api",
cfg: GitHubPRMergedTriggerConfig{Repos: []github.RepoFilter{{Owner: "acme", Name: "api"}}},
want: true,
},
{
name: "case-insensitive owner",
owner: "ACME", repo: "api",
cfg: GitHubPRMergedTriggerConfig{Repos: []github.RepoFilter{{Owner: "acme", Name: "api"}}},
want: true,
},
{
name: "case-insensitive name",
owner: "acme", repo: "API",
cfg: GitHubPRMergedTriggerConfig{Repos: []github.RepoFilter{{Owner: "acme", Name: "api"}}},
want: true,
},
{
name: "org wildcard matches any name under owner",
owner: "acme", repo: "anything",
cfg: GitHubPRMergedTriggerConfig{Repos: []github.RepoFilter{{Owner: "acme", Name: ""}}},
want: true,
},
{
name: "org wildcard wrong owner",
owner: "other", repo: "anything",
cfg: GitHubPRMergedTriggerConfig{Repos: []github.RepoFilter{{Owner: "acme", Name: ""}}},
want: false,
},
{
name: "empty owner in filter is skipped",
owner: "acme", repo: "api",
cfg: GitHubPRMergedTriggerConfig{Repos: []github.RepoFilter{{Owner: "", Name: "api"}}},
want: false,
},
{
name: "wrong name",
owner: "acme", repo: "other",
cfg: GitHubPRMergedTriggerConfig{Repos: []github.RepoFilter{{Owner: "acme", Name: "api"}}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchesMergedRepo(tt.owner, tt.repo, tt.cfg)
if got != tt.want {
t.Fatalf("matchesMergedRepo(%q, %q, ...) = %v, want %v", tt.owner, tt.repo, got, tt.want)
}
})
}
}
func TestMatchesMergedBranches(t *testing.T) {
tests := []struct {
name string
baseBranch string
filters []string
want bool
}{
{"nil filters match all", "main", nil, true},
{"empty filters match all", "main", []string{}, true},
{"all-whitespace filters match all", "main", []string{" ", " "}, true},
{"exact match", "main", []string{"main"}, true},
{"glob match", "release/v2", []string{"release/*"}, true},
{"no match", "feature/x", []string{"main", "develop"}, false},
{"trim whitespace in filter", "main", []string{" main "}, true},
{"multiple with one matching", "develop", []string{"main", "develop"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchesMergedBranches(tt.baseBranch, tt.filters)
if got != tt.want {
t.Fatalf("matchesMergedBranches(%q, %v) = %v, want %v", tt.baseBranch, tt.filters, got, tt.want)
}
})
}
}