Files
gitea/services/project_events/events_test.go
T
oleks c19ecab35d fix(project): push SSE update when an issue on a board is closed/reopened
Closing or reopening an issue did not notify project boards that carry
it as a card, so board tabs showed stale state until a manual reload.
CloseIssue/ReopenIssue only published milestone events and the issue
timeline notification — nothing project-scoped.

Add a CardStateChanged project event, published per linked project from
CloseIssue/ReopenIssue (best-effort; never fails the state change). The
board frontend flips the issue-state octicon in place and refetches the
affected column so state-filtered boards and counts stay correct. The
dispatch check precedes the CardUnlinked branch so a close/reopen is
not mistaken for a card removal.

Also switch a pre-existing String#match to RegExp#exec in the same file
to keep it lint-clean.

Closes #19
2026-05-17 20:58:17 +03:00

331 lines
9.8 KiB
Go

// 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)
}