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) } }