Files
summercms/modules/lighthouse/lighthouse_test.go
Jakub Zych 33194a1f98 test(11-07): cover lighthouse realtime and the Centrifugo driver
- lighthouse: TestSuppression (Widget silenced, Gadget not, nesting,
  stale outer ctx), TestBulkEmitsOnce, TestBroadcastEdges (zero-key batch,
  update actor, id-only delete, method contract, multi-channel, savepoint),
  TestBroadcastPublishFailure, TestFromSelectsDriver, TestMountSurfaces,
  TestRegistry under -race, drivers, args JSON, Bind (coverage 91.7%)
- centrifugo: TestTokenClaims, TestTokenHandler, TestClientRequests,
  TestClientLoadConfig and a TestProxy table porting the WinterCMS WS-005,
  WS-007 and WS-013 cases (coverage 92.4%)
2026-09-30 14:21:03 +02:00

249 lines
8.6 KiB
Go

package lighthouse
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"reflect"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"gorm.io/gorm"
)
func configApp(t *testing.T, kv map[string]any) *backpack.App {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
for k, v := range kv {
if err := cfg.Set(k, v); err != nil {
t.Fatal(err)
}
}
return backpack.New(cfg)
}
// TestFromSelectsDriver covers D-11: realtime.driver picks a registered
// driver (null by default, case-insensitive), an unknown name fails and
// lists the registered drivers, and the realtime.* settings are read once.
func TestFromSelectsDriver(t *testing.T) {
cases := []struct {
name string
driver any
want string
}{
{"default_null", nil, "null"},
{"memory", "memory", "memory"},
{"log_case_insensitive", " LOG ", "log"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
kv := map[string]any{}
if c.driver != nil {
kv["realtime.driver"] = c.driver
}
svc, err := From(configApp(t, kv))
if err != nil {
t.Fatal(err)
}
if got := svc.Driver().Name(); got != c.want {
t.Fatalf("driver = %s, want %s", got, c.want)
}
if svc.Driver().Routes() != nil {
t.Fatalf("%s driver declares routes", c.want)
}
})
}
t.Run("unknown_lists_registered", func(t *testing.T) {
_, err := From(configApp(t, map[string]any{"realtime.driver": "nope"}))
if err == nil || !strings.Contains(err.Error(), `unknown realtime.driver "nope"`) || !strings.Contains(err.Error(), "acme-nil-driver, log, memory, null") {
t.Fatalf("err = %v", err)
}
})
t.Run("settings_and_idempotence", func(t *testing.T) {
app := configApp(t, map[string]any{
"realtime.broadcast_namespace": " acme ",
"realtime.broadcast_queue": "rt",
"realtime.broadcast_timeout": 7,
})
svc, err := From(app)
if err != nil {
t.Fatal(err)
}
if svc.Namespace() != "acme" || svc.Queue() != "rt" || svc.Timeout() != 7*time.Second || svc.Registry() == nil {
t.Fatalf("settings = %q %q %s", svc.Namespace(), svc.Queue(), svc.Timeout())
}
again, err := From(app)
if err != nil || again != svc {
t.Fatalf("From twice = %p, %v", again, err)
}
if _, err := From(nil); err == nil {
t.Fatal("From(nil) succeeded")
}
def, err := From(backpack.New(nil))
if err != nil || def.Queue() != DefaultQueue || def.Timeout() != DefaultTimeout || def.Namespace() != "" {
t.Fatalf("config-less service = %+v, %v", def, err)
}
})
t.Run("nil_service_accessors", func(t *testing.T) {
var s *Service
if s.Driver() != nil || s.Registry() != nil || s.Namespace() != "" || s.Queue() != DefaultQueue || s.Timeout() != DefaultTimeout || s.Logger() == nil {
t.Fatal("nil service accessors")
}
s.SetUserLookup(nil)
if u, found, err := s.User(context.Background(), 4); err != nil || !found || u.ID != 4 {
t.Fatalf("nil service User = %+v %v %v", u, found, err)
}
})
}
// TestDurationSetting covers the seconds-or-duration setting parser.
func TestDurationSetting(t *testing.T) {
app := configApp(t, map[string]any{"a": 5, "b": "250ms", "c": "soon", "d": -3, "e": " "})
for key, want := range map[string]time.Duration{"a": 5 * time.Second, "b": 250 * time.Millisecond, "c": 0, "d": 0, "e": 0, "missing": 0} {
if got := DurationSetting(app.Config, key); got != want {
t.Errorf("DurationSetting(%s) = %s, want %s", key, got, want)
}
}
if DurationSetting(nil, "a") != 0 {
t.Fatal("nil config")
}
}
// TestDrivers covers the built-in drivers: the log driver logs channels and
// event but never the payload, the memory driver records copies, and the
// registry panics on misuse.
func TestDrivers(t *testing.T) {
var buf bytes.Buffer
d := &logDriver{log: slog.New(slog.NewTextHandler(&buf, nil))}
ctx := context.Background()
if err := d.Publish(ctx, "room:1", "acme.ping", json.RawMessage(`{"secret":"payload-value"}`)); err != nil {
t.Fatal(err)
}
if err := d.Broadcast(ctx, []string{"room:1", "room:2"}, "acme.ping", json.RawMessage(`{"secret":"payload-value"}`)); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "channel=room:1") || !strings.Contains(out, "event=acme.ping") || strings.Contains(out, "payload-value") {
t.Fatalf("log driver output = %s", out)
}
if (nullDriver{}).Publish(ctx, "", "", nil) != nil || (nullDriver{}).Broadcast(ctx, nil, "", nil) != nil {
t.Fatal("null driver returned an error")
}
m := NewMemoryDriver()
payload := json.RawMessage(`{"a":1}`)
channels := []string{"x:1", "x:2"}
if err := m.Broadcast(ctx, channels, "e", payload); err != nil {
t.Fatal(err)
}
channels[0] = "mutated"
payload[2] = 'Z'
pubs := m.Publications()
if len(pubs) != 1 || pubs[0].Method != "broadcast" || pubs[0].Channels[0] != "x:1" || string(pubs[0].Payload) != `{"a":1}` || m.Name() != "memory" || m.Routes() != nil {
t.Fatalf("memory driver kept references: %+v", pubs)
}
pubs[0].Channels[0] = "changed"
if m.Publications()[0].Channels[0] != "x:1" {
t.Fatal("Publications returned shared slices")
}
var nilMem *MemoryDriver
if nilMem.Publish(ctx, "a", "b", nil) == nil || nilMem.Publications() != nil {
t.Fatal("nil memory driver")
}
for name, fn := range map[string]func(){
"empty_name": func() { RegisterDriver("", func(*backpack.App, *Service) (Driver, error) { return nullDriver{}, nil }) },
"nil_factory": func() { RegisterDriver("acme-nil", nil) },
"duplicate": func() {
RegisterDriver("null", func(*backpack.App, *Service) (Driver, error) { return nullDriver{}, nil })
},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("RegisterDriver %s did not panic", name)
}
}()
fn()
}()
}
if _, err := From(configApp(t, map[string]any{"realtime.driver": "acme-nil-driver"})); err == nil || !strings.Contains(err.Error(), "returned nil") {
t.Fatalf("nil driver: %v", err)
}
if _, err := From(configApp(t, map[string]any{"realtime.driver": "acme-broken"})); err == nil || !strings.Contains(err.Error(), "driver acme-broken") {
t.Fatalf("failing factory: %v", err)
}
}
func init() {
RegisterDriver("acme-nil-driver", func(*backpack.App, *Service) (Driver, error) { return nil, nil })
RegisterDriver("acme-broken", func(*backpack.App, *Service) (Driver, error) { return nil, context.Canceled })
}
// TestBroadcastArgsJSON covers the stored job args: the payload travels as
// a JSON string so JSONB cannot reorder its keys.
func TestBroadcastArgsJSON(t *testing.T) {
a := BroadcastArgs{Channels: []string{"room:1"}, Event: "e", Payload: json.RawMessage(`{"z":1,"a":2}`)}
raw, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
if string(raw) != `{"channels":["room:1"],"event":"e","payload":"{\"z\":1,\"a\":2}"}` {
t.Fatalf("stored args = %s", raw)
}
var back BroadcastArgs
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
if string(back.Payload) != `{"z":1,"a":2}` || back.Event != "e" || back.Kind() != "summer.broadcast" {
t.Fatalf("round trip = %+v", back)
}
if err := json.Unmarshal([]byte(`{"channels":[],"event":"e","payload":""}`), &back); err != nil || back.Payload != nil {
t.Fatalf("empty payload = %s, %v", back.Payload, err)
}
if err := back.UnmarshalJSON([]byte(`not json`)); err == nil {
t.Fatal("malformed args accepted")
}
}
// TestBind covers the Binding rules.
func TestBind(t *testing.T) {
svc, err := From(backpack.New(nil))
if err != nil {
t.Fatal(err)
}
channels := func(context.Context, *gorm.DB, *Gadget) ([]string, error) { return nil, nil }
if err := Bind[Gadget](nil, Binding[Gadget]{Channels: channels}); err == nil {
t.Fatal("nil service accepted")
}
if err := Bind[string](svc, Binding[string]{Channels: func(context.Context, *gorm.DB, *string) ([]string, error) { return nil, nil }}); err == nil {
t.Fatal("non-struct type accepted")
}
if err := Bind[Gadget](svc, Binding[Gadget]{}); err == nil {
t.Fatal("nil Channels accepted")
}
if err := Bind[Gadget](svc, Binding[Gadget]{Channels: channels}); err != nil {
t.Fatal(err)
}
if err := Bind[Gadget](svc, Binding[Gadget]{Channels: channels}); err == nil {
t.Fatal("second binding accepted")
}
h := svc.handlerFor(reflect.TypeFor[Gadget]())
if h == nil || h.alias != "lighthouse.gadget" || h.ttl != DefaultTTL || h.eventName(ActionDeleted) != "deleted.lighthouse.gadget" || !h.allows(nil, ActionUpdated) {
t.Fatalf("binding defaults = %+v", h)
}
if svc.handlerFor(reflect.TypeFor[User]()) != nil {
t.Fatal("a type without a binding or Broadcastable got a handler")
}
if got := defaultAlias(reflect.TypeFor[Widget]()); got != "lighthouse.widget" {
t.Fatalf("defaultAlias = %s", got)
}
}