// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package project_events import ( "context" "sync" "testing" "time" project_model "code.gitea.io/gitea/models/project" "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // capturedCall is one observed broadcast: the recipient uid set plus the // constructed Event. type capturedCall struct { uids []int64 event *eventsource.Event } // installFakes swaps every package-level seam used by publishEvent for // test doubles: a fake uid lister, a stubbed project lookup that // returns a synthetic project (no DB hit), an "everyone passes" access // checker, and a broadcaster that pushes calls onto a buffered channel. // // The returned restore func reverts every seam; defer it in the test. func installFakes(t *testing.T, uids []int64) (<-chan capturedCall, func()) { t.Helper() calls := make(chan capturedCall, 16) origBroadcast := broadcastFn origLister := connectedUIDsLister origChecker := projectAccessChecker origLookup := projectLookup broadcastFn = func(uids []int64, event *eventsource.Event) { calls <- capturedCall{uids: append([]int64(nil), uids...), event: event} } connectedUIDsLister = func() []int64 { return append([]int64(nil), uids...) } projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) { return &project_model.Project{ID: id}, nil } projectAccessChecker = func(_ context.Context, _ int64, _ *project_model.Project) (bool, error) { return true, nil } return calls, func() { broadcastFn = origBroadcast connectedUIDsLister = origLister projectAccessChecker = origChecker projectLookup = origLookup } } // awaitCall blocks until one capturedCall arrives or the test deadline // elapses. It fails the test on timeout. func awaitCall(t *testing.T, ch <-chan capturedCall) capturedCall { t.Helper() select { case c := <-ch: return c case <-time.After(2 * time.Second): t.Fatal("timed out waiting for broadcast") return capturedCall{} } } func TestEventNameFormat(t *testing.T) { assert.Equal(t, "project-board.42", eventName(42)) assert.Equal(t, "project-board.0", eventName(0)) } func TestPublishHelpers_NameAndPayload(t *testing.T) { cases := []struct { name string invoke func(ctx context.Context) wantName string wantData any }{ { name: "card.moved", wantName: "project-board.10", invoke: func(ctx context.Context) { PublishCardMoved(ctx, CardMoved{ ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3, }) }, wantData: CardMoved{ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3}, }, { name: "card.linked", wantName: "project-board.11", invoke: func(ctx context.Context) { PublishCardLinked(ctx, CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9}) }, wantData: CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9}, }, { name: "card.unlinked", wantName: "project-board.12", invoke: func(ctx context.Context) { PublishCardUnlinked(ctx, CardUnlinked{ProjectID: 12, IssueID: 8}) }, wantData: CardUnlinked{ProjectID: 12, IssueID: 8}, }, { name: "card.state_changed", wantName: "project-board.12", invoke: func(ctx context.Context) { PublishCardStateChanged(ctx, CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true}) }, wantData: CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true}, }, { name: "column.created", wantName: "project-board.13", invoke: func(ctx context.Context) { PublishColumnCreated(ctx, ColumnCreated{ ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true, }) }, wantData: ColumnCreated{ ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true, }, }, { name: "column.updated", wantName: "project-board.14", invoke: func(ctx context.Context) { PublishColumnUpdated(ctx, ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"}) }, wantData: ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"}, }, { name: "column.deleted", wantName: "project-board.15", invoke: func(ctx context.Context) { PublishColumnDeleted(ctx, ColumnDeleted{ProjectID: 15, ColumnID: 5}) }, wantData: ColumnDeleted{ProjectID: 15, ColumnID: 5}, }, { name: "column.reordered", wantName: "project-board.16", invoke: func(ctx context.Context) { PublishColumnReordered(ctx, ColumnReordered{ ProjectID: 16, Columns: []ColumnSort{ {ColumnID: 1, Sorting: 0}, {ColumnID: 2, Sorting: 1}, }, }) }, wantData: ColumnReordered{ ProjectID: 16, Columns: []ColumnSort{ {ColumnID: 1, Sorting: 0}, {ColumnID: 2, Sorting: 1}, }, }, }, { name: "project.updated", wantName: "project-board.17", invoke: func(ctx context.Context) { PublishProjectUpdated(ctx, ProjectUpdated{ ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false, }) }, wantData: ProjectUpdated{ ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false, }, }, { name: "project.deleted", wantName: "project-board.18", invoke: func(ctx context.Context) { PublishProjectDeleted(ctx, ProjectDeleted{ProjectID: 18}) }, wantData: ProjectDeleted{ProjectID: 18}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ch, restore := installFakes(t, []int64{1}) defer restore() tc.invoke(context.Background()) c := awaitCall(t, ch) assert.Equal(t, tc.wantName, c.event.Name) gotJSON, ok := c.event.Data.([]byte) require.True(t, ok, "Event.Data should be []byte") wantJSON, err := json.Marshal(tc.wantData) require.NoError(t, err) assert.JSONEq(t, string(wantJSON), string(gotJSON)) }) } } // TestSessionTagPropagation verifies that when a publish is invoked // inside a context decorated by WithSessionTag, the emitted JSON // payload carries the tag. func TestSessionTagPropagation(t *testing.T) { ch, restore := installFakes(t, []int64{1}) defer restore() ctx := WithSessionTag(context.Background(), "abc-123") PublishCardMoved(ctx, CardMoved{ ProjectID: 99, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0, }) c := awaitCall(t, ch) var payload CardMoved require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload)) assert.Equal(t, "abc-123", payload.SessionTag) } // TestSessionTagExplicitOverridesContext verifies that an explicit // SessionTag set on the payload struct is preserved. func TestSessionTagExplicitOverridesContext(t *testing.T) { ch, restore := installFakes(t, []int64{1}) defer restore() ctx := WithSessionTag(context.Background(), "from-ctx") PublishCardMoved(ctx, CardMoved{ ProjectID: 1, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0, SessionTag: "explicit", }) c := awaitCall(t, ch) var payload CardMoved require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload)) assert.Equal(t, "explicit", payload.SessionTag) } // TestConnectedUIDsWithProjectAccess_FiltersByPermission ensures the // helper drops uids the access checker rejects. func TestConnectedUIDsWithProjectAccess_FiltersByPermission(t *testing.T) { origLister := connectedUIDsLister origChecker := projectAccessChecker origLookup := projectLookup defer func() { connectedUIDsLister = origLister projectAccessChecker = origChecker projectLookup = origLookup }() connectedUIDsLister = func() []int64 { return []int64{1, 2, 3, 4} } projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) { return &project_model.Project{ID: id}, nil } allowed := map[int64]bool{1: true, 3: true} projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) { return allowed[uid], nil } got := connectedUIDsWithProjectAccess(context.Background(), 42) assert.ElementsMatch(t, []int64{1, 3}, got) } // TestConnectedUIDsWithProjectAccess_NoConnections shortcuts when no // users are connected; the project lookup must not be called. func TestConnectedUIDsWithProjectAccess_NoConnections(t *testing.T) { origLister := connectedUIDsLister origLookup := projectLookup defer func() { connectedUIDsLister = origLister projectLookup = origLookup }() connectedUIDsLister = func() []int64 { return nil } called := false projectLookup = func(_ context.Context, _ int64) (*project_model.Project, error) { called = true return &project_model.Project{}, nil } got := connectedUIDsWithProjectAccess(context.Background(), 42) assert.Empty(t, got) assert.False(t, called, "project lookup should be skipped when no uids are connected") } // TestPublishEvent_BroadcastsToAllowedUIDs exercises publishEvent // directly to verify the uid set computed by the access filter is // what gets handed to broadcastFn. func TestPublishEvent_BroadcastsToAllowedUIDs(t *testing.T) { origBroadcast := broadcastFn origLister := connectedUIDsLister origChecker := projectAccessChecker origLookup := projectLookup defer func() { broadcastFn = origBroadcast connectedUIDsLister = origLister projectAccessChecker = origChecker projectLookup = origLookup }() var mu sync.Mutex var got []int64 broadcastFn = func(uids []int64, _ *eventsource.Event) { mu.Lock() got = append([]int64(nil), uids...) mu.Unlock() } connectedUIDsLister = func() []int64 { return []int64{10, 20, 30} } projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) { return &project_model.Project{ID: id}, nil } projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) { return uid != 20, nil } publishEvent(context.Background(), 1, ColumnDeleted{ProjectID: 1, ColumnID: 5}) mu.Lock() defer mu.Unlock() assert.ElementsMatch(t, []int64{10, 30}, got) }