c19ecab35d
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
331 lines
9.8 KiB
Go
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)
|
|
}
|