mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-01 18:44:23 +00:00
13d0f24423
* Closes #36942 * Fixes #19265 Replaces the SSE-based push channel (`/user/events`) with a WebSocket endpoint (`/-/ws`). ### What changes - **New `/-/ws` endpoint** (authenticated). One WebSocket per origin, shared across tabs via a single `SharedWorker`. - **Pubsub broker** (`services/pubsub`) for fan-out by topic, behind a `Broker` interface. `MemoryBroker` is the default (single process); a Redis backend is available for multi-process setups, configured via `[websocket].PUBSUB_TYPE` / `PUBSUB_CONN_STR`. The internal Gitea queue was not usable here because it has FIFO/single-consumer semantics. - **Push-only event production.** Events are emitted by write-triggered notifiers — `NotificationCountChange`, `PublishStopwatchesForUser`, and the logout publisher — wired into the existing `notify.Notifier` interface. No server-side pollers. - **Typed pub/sub on the client.** `web_src/js/modules/worker.ts` is a singleton transport; features subscribe per event type via `onUserEvent('notification-count', cb)` instead of branching on `event.data.type`. - **Wire contract** (`UserEventType` union) is shared between the worker and consumers via `web_src/js/types.ts`, kept in sync with `services/websocket/events.go`. - **Client-side periodic polling fallback** kicks in only when the WebSocket cannot be established (e.g. proxy blocks WS, browser lacks module-SharedWorker support). ### What's removed - `modules/eventsource` (SSE manager, run loop, messenger). - `/user/events` route and `tests/integration/eventsource_test.go`. - All server-side polling for stopwatches and notification counts. ### Stopwatch multi-tab fix The navbar stopwatch icon was previously rendered conditionally on `{{if $activeStopwatch}}`, so tabs loaded before the timer started had no DOM element to update. The icon and popup are now always rendered (toggled with `tw-hidden`), and the start/stop/cancel handlers POST silently so all open tabs reflect the change in real time. ### Deployment note WebSocket needs the upgrade headers to pass through a reverse proxy, e.g. for nginx: ```nginx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; ``` Without them the WebSocket cannot be established, and after 3 consecutive failed opens the shared worker signals `push-unavailable`: the notification count and stopwatch fall back to periodic polling on the existing `[ui.notification]` timeouts. Real-time push is lost, the features keep working. The reverse-proxy docs need the same note (see the `docs-update-needed` label). --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Epid <rexmrj@gmail.com>
144 lines
5.9 KiB
Go
144 lines
5.9 KiB
Go
// Copyright 2017 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package activities_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
activities_model "gitea.dev/models/activities"
|
|
"gitea.dev/models/db"
|
|
issues_model "gitea.dev/models/issues"
|
|
"gitea.dev/models/unittest"
|
|
user_model "gitea.dev/models/user"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestCreateOrUpdateIssueNotifications(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
|
|
|
_, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), issue.ID, 0, 2, 0)
|
|
assert.NoError(t, err)
|
|
|
|
// User 9 is inactive, thus notifications for user 1 and 4 are created
|
|
notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 1, IssueID: issue.ID})
|
|
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
|
|
unittest.CheckConsistencyFor(t, &issues_model.Issue{ID: issue.ID})
|
|
|
|
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 4, IssueID: issue.ID})
|
|
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
|
|
}
|
|
|
|
func TestNotificationsForUser(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
notfs, err := db.Find[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{
|
|
UserID: user.ID,
|
|
Status: []activities_model.NotificationStatus{
|
|
activities_model.NotificationStatusRead,
|
|
activities_model.NotificationStatusUnread,
|
|
},
|
|
})
|
|
assert.NoError(t, err)
|
|
if assert.Len(t, notfs, 3) {
|
|
assert.EqualValues(t, 5, notfs[0].ID)
|
|
assert.Equal(t, user.ID, notfs[0].UserID)
|
|
assert.EqualValues(t, 4, notfs[1].ID)
|
|
assert.Equal(t, user.ID, notfs[1].UserID)
|
|
assert.EqualValues(t, 2, notfs[2].ID)
|
|
assert.Equal(t, user.ID, notfs[2].UserID)
|
|
}
|
|
}
|
|
|
|
func TestNotification_GetRepo(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{RepoID: 1})
|
|
repo, err := notf.GetRepo(t.Context())
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, repo, notf.Repository)
|
|
assert.Equal(t, notf.RepoID, repo.ID)
|
|
}
|
|
|
|
func TestNotification_GetIssue(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{RepoID: 1})
|
|
issue, err := notf.GetIssue(t.Context())
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, issue, notf.Issue)
|
|
assert.Equal(t, notf.IssueID, issue.ID)
|
|
}
|
|
|
|
func TestGetNotificationCount(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
cnt, err := db.Count[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{
|
|
UserID: user.ID,
|
|
Status: []activities_model.NotificationStatus{
|
|
activities_model.NotificationStatusRead,
|
|
},
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.EqualValues(t, 0, cnt)
|
|
|
|
cnt, err = db.Count[activities_model.Notification](t.Context(), activities_model.FindNotificationOptions{
|
|
UserID: user.ID,
|
|
Status: []activities_model.NotificationStatus{
|
|
activities_model.NotificationStatusUnread,
|
|
},
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.EqualValues(t, 1, cnt)
|
|
}
|
|
|
|
func TestSetNotificationStatus(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
notf := unittest.AssertExistsAndLoadBean(t,
|
|
&activities_model.Notification{UserID: user.ID, Status: activities_model.NotificationStatusRead})
|
|
_, err := activities_model.SetNotificationStatus(t.Context(), notf.ID, user, activities_model.NotificationStatusPinned)
|
|
assert.NoError(t, err)
|
|
unittest.AssertExistsAndLoadBean(t,
|
|
&activities_model.Notification{ID: notf.ID, Status: activities_model.NotificationStatusPinned})
|
|
|
|
_, err = activities_model.SetNotificationStatus(t.Context(), 1, user, activities_model.NotificationStatusRead)
|
|
assert.Error(t, err)
|
|
_, err = activities_model.SetNotificationStatus(t.Context(), unittest.NonexistentID, user, activities_model.NotificationStatusRead)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestUpdateNotificationStatuses(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
notfUnread := unittest.AssertExistsAndLoadBean(t,
|
|
&activities_model.Notification{UserID: user.ID, Status: activities_model.NotificationStatusUnread})
|
|
notfRead := unittest.AssertExistsAndLoadBean(t,
|
|
&activities_model.Notification{UserID: user.ID, Status: activities_model.NotificationStatusRead})
|
|
notfPinned := unittest.AssertExistsAndLoadBean(t,
|
|
&activities_model.Notification{UserID: user.ID, Status: activities_model.NotificationStatusPinned})
|
|
_, err := activities_model.UpdateNotificationStatuses(t.Context(), user, activities_model.NotificationStatusUnread, activities_model.NotificationStatusRead)
|
|
assert.NoError(t, err)
|
|
unittest.AssertExistsAndLoadBean(t,
|
|
&activities_model.Notification{ID: notfUnread.ID, Status: activities_model.NotificationStatusRead})
|
|
unittest.AssertExistsAndLoadBean(t,
|
|
&activities_model.Notification{ID: notfRead.ID, Status: activities_model.NotificationStatusRead})
|
|
unittest.AssertExistsAndLoadBean(t,
|
|
&activities_model.Notification{ID: notfPinned.ID, Status: activities_model.NotificationStatusPinned})
|
|
}
|
|
|
|
func TestSetIssueReadBy(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
|
assert.NoError(t, db.WithTx(t.Context(), func(ctx context.Context) error {
|
|
_, err := activities_model.SetIssueReadBy(ctx, issue.ID, user.ID)
|
|
return err
|
|
}))
|
|
|
|
nt, err := activities_model.GetIssueNotification(t.Context(), user.ID, issue.ID)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, activities_model.NotificationStatusRead, nt.Status)
|
|
}
|