diff --git a/pkg/websocket/listener.go b/pkg/websocket/listener.go index 3250d1c45..e46759a71 100644 --- a/pkg/websocket/listener.go +++ b/pkg/websocket/listener.go @@ -21,7 +21,9 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/notifications" "github.com/ThreeDotsLabs/watermill/message" @@ -67,10 +69,50 @@ func (n *NotificationListener) Handle(msg *message.Message) error { return nil } +// TimeEntryListener pushes a user's own timer changes to their WebSocket +// connections. wsEvent is "timer.created", "timer.updated" or "timer.deleted"; +// the payload is the full entry, so the running-elsewhere badge reads end_time +// to know whether a timer is active (and the id to drop a deleted one). Not +// emitted on unlicensed instances. +type TimeEntryListener struct { + wsEvent string +} + +func (l *TimeEntryListener) Name() string { return "websocket.push." + l.wsEvent } + +func (l *TimeEntryListener) Handle(msg *message.Message) error { + if !license.IsFeatureEnabled(license.FeatureTimeTracking) { + return nil + } + + // All TimeEntry events share the {time_entry, doer} shape; only the entry is needed. + var event struct { + TimeEntry *models.TimeEntry `json:"time_entry"` + } + if err := json.Unmarshal(msg.Payload, &event); err != nil { + return err + } + if event.TimeEntry == nil { + return nil + } + + hub := GetHub() + if hub == nil { + log.Warningf("WebSocket: hub not initialized, skipping timer push") + return nil + } + + hub.PublishForUser(event.TimeEntry.UserID, l.wsEvent, event.TimeEntry) + return nil +} + // RegisterListeners registers WebSocket event listeners. func RegisterListeners() { events.RegisterListener( (¬ifications.NotificationCreatedEvent{}).Name(), &NotificationListener{}, ) + events.RegisterListener((&models.TimeEntryCreatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.created"}) + events.RegisterListener((&models.TimeEntryUpdatedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.updated"}) + events.RegisterListener((&models.TimeEntryDeletedEvent{}).Name(), &TimeEntryListener{wsEvent: "timer.deleted"}) } diff --git a/pkg/websocket/main_test.go b/pkg/websocket/main_test.go index 7740dc0e0..21243069b 100644 --- a/pkg/websocket/main_test.go +++ b/pkg/websocket/main_test.go @@ -20,10 +20,14 @@ import ( "os" "testing" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/keyvalue" ) func TestMain(m *testing.M) { log.InitLogger() + config.InitDefaultConfig() + keyvalue.InitStorage() // license.SetForTests persists state through keyvalue os.Exit(m.Run()) } diff --git a/pkg/websocket/time_entry_listener_test.go b/pkg/websocket/time_entry_listener_test.go new file mode 100644 index 000000000..8f67df8b9 --- /dev/null +++ b/pkg/websocket/time_entry_listener_test.go @@ -0,0 +1,113 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "testing" + + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func timerConn(userID int64) *Connection { + return &Connection{ + userID: userID, + subscriptions: map[string]bool{"timer.created": true, "timer.updated": true, "timer.deleted": true}, + send: make(chan OutgoingMessage, 16), + } +} + +func TestTimeEntryListener(t *testing.T) { + t.Run("a create pushes timer.created with the entry to its owner", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryCreatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.created"}) + + require.Len(t, conn.send, 1) + msg := <-conn.send + assert.Equal(t, "timer.created", msg.Event) + te, ok := msg.Data.(*models.TimeEntry) + require.True(t, ok, "payload must be the time entry itself") + assert.Equal(t, int64(4), te.ID) + }) + + t.Run("an update pushes timer.updated", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + require.Len(t, conn.send, 1) + assert.Equal(t, "timer.updated", (<-conn.send).Event) + }) + + t.Run("a delete pushes timer.deleted so other tabs drop it", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryDeletedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.deleted"}) + + require.Len(t, conn.send, 1) + msg := <-conn.send + assert.Equal(t, "timer.deleted", msg.Event) + te, ok := msg.Data.(*models.TimeEntry) + require.True(t, ok) + assert.Equal(t, int64(4), te.ID) + }) + + t.Run("does not push when the feature is disabled", func(t *testing.T) { + InitHub() + conn := timerConn(1) + GetHub().Register(conn) + license.ResetForTests() // free mode + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + assert.Empty(t, conn.send) + }) + + t.Run("only pushes to the entry owner", func(t *testing.T) { + InitHub() + other := timerConn(2) + GetHub().Register(other) + license.SetForTests([]license.Feature{license.FeatureTimeTracking}) + defer license.ResetForTests() + + ev := &models.TimeEntryUpdatedEvent{TimeEntry: &models.TimeEntry{ID: 4, UserID: 1}} + events.TestListener(t, ev, &TimeEntryListener{wsEvent: "timer.updated"}) + + assert.Empty(t, other.send, "a different user must not receive the timer update") + }) +}