feat(time-tracking): broadcast timer changes over websocket
This commit is contained in:
parent
e197b1912f
commit
cf22f08974
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue