Files
pad/internal/server/bug3773_watch_slug_cache_test.go
T
xarmian 4d0944c4c8 fix(server): a watch stream resolves each workspace slug once per tick, not per notification (BUG-3773) (#2210)
watchEventPayloadFor read the workspace row for every delivered
notification, on replay and live delivery alike, so a write burst cost
N notifications x M open streams reads. The stream's watchVisCache now
memoises the slug per workspace and clears it on the reval tick, which
bounds a soft-deleted workspace's id fallback by the same tick
visibility already uses. A failed resolve is not memoised.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 09:58:53 -04:00

54 lines
2.3 KiB
Go

package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/watchevents"
)
// BUG-3773: a stream resolves each workspace's slug once per reval tick,
// not once per notification. The memo is observed through the payload: a
// workspace soft-deleted mid-tick still reads by its slug until reset(),
// and a failed resolve is retried rather than remembered.
func TestBUG3773WatchPayloadSlugIsMemoisedPerTick(t *testing.T) {
srv := testServerWithWatchEvents(t)
user, err := srv.store.CreateUser(models.UserCreate{Email: "bug3773@example.com", Name: "Watcher", Password: "pass-bug3773-xyz"})
if err != nil {
t.Fatalf("create user: %v", err)
}
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Slug Memo 3773"})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
cache := newWatchVisCache(srv, httptest.NewRequest(http.MethodGet, "/api/v1/watch/events", nil), user)
n := watchevents.Notification{ID: 1, WorkspaceID: ws.ID, Kind: watchevents.KindStatusChange, ItemRef: "TASK-1"}
if got := watchEventPayloadFor(cache, n).Workspace; got != ws.Slug {
t.Fatalf("first payload workspace %q, want slug %q", got, ws.Slug)
}
if err := srv.store.DeleteWorkspace(ws.Slug); err != nil {
t.Fatalf("delete workspace: %v", err)
}
// Same tick: served from the memo, with no store read that would see
// the deletion.
if got := watchEventPayloadFor(cache, n).Workspace; got != ws.Slug {
t.Fatalf("payload in the same tick read the store again: workspace %q, want memoised slug %q", got, ws.Slug)
}
// Next tick: the memo is gone, and a deleted workspace degrades to its id.
cache.reset()
if got := watchEventPayloadFor(cache, n).Workspace; got != ws.ID {
t.Fatalf("payload after reset: workspace %q, want the raw id %q", got, ws.ID)
}
// That failed resolve was not memoised: once the workspace is restored,
// the very next payload in the same tick carries the slug again.
if err := srv.store.RestoreWorkspace(ws.Slug); err != nil {
t.Fatalf("restore workspace: %v", err)
}
if got := watchEventPayloadFor(cache, n).Workspace; got != ws.Slug {
t.Fatalf("payload after restore: workspace %q, want slug %q (a failed resolve was memoised)", got, ws.Slug)
}
}