feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links

- docs/backend: admin controllers, forms, lists and filters, relation
  manager, users and permissions, settings, partials and widgets, admin SPA
- docs/services: storage, outbound HTTP, realtime, Web Push, search, parity
  testing and the Frontend and AJAX (not provided) page
- Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and
  its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse
  and beachcomber TestDocs* regions run on their Postgres harnesses
- concept map rows link their guide pages and the not-provided rows the
  Frontend and AJAX page; index lists Backend, Database and Services
- TestDocsRequiredPages asserts the D-08 section order
This commit is contained in:
Jakub Zych
2026-09-30 23:18:35 +02:00
parent f7dfe68707
commit 44bd1446f5
40 changed files with 2882 additions and 30 deletions

View File

@@ -0,0 +1,196 @@
package lighthouse_test
import (
"context"
"fmt"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"gorm.io/gorm"
)
// newApp returns an app whose config selects a realtime driver.
func newApp(settings map[string]any) (*backpack.App, error) {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
return nil, err
}
for k, v := range settings {
if err := cfg.Set(k, v); err != nil {
return nil, err
}
}
return backpack.New(cfg), nil
}
// isMember stands in for the application's own membership query.
func isMember(ctx context.Context, userID uint, blogID int64) bool {
return userID == 42 && blogID == 7
}
func ExampleRegistry_Register() {
app, err := newApp(map[string]any{"realtime.driver": "memory"})
if err != nil {
fmt.Println(err)
return
}
svc, err := lighthouse.From(app)
if err != nil {
fmt.Println(err)
return
}
// blog:{entity}:{id} channels are open to members of the blog only. The
// authorizer runs on every subscribe; nothing is cached.
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
if isMember(ctx, userID, lighthouse.ChannelID(channel)) {
return lighthouse.Allowed(nil)
}
return lighthouse.Denied("not a member of the blog")
}))
if err != nil {
fmt.Println(err)
return
}
for _, sub := range []struct {
user uint
channel string
}{{42, "blog:7"}, {42, "blog:8"}, {42, "presence:blog:7"}, {42, "shop:7"}} {
ns, presence := lighthouse.ParseChannel(sub.channel)
auth, ok := svc.Registry().Get(ns)
if !ok {
fmt.Println(sub.channel, "no authorizer")
continue
}
res := auth.Authorize(context.Background(), sub.user, sub.channel)
fmt.Printf("%d %s namespace=%s presence=%v allowed=%v reason=%q\n", sub.user, sub.channel, ns, presence, res.Allowed, res.Reason())
}
fmt.Println(lighthouse.ChannelID("blog:12abc"), lighthouse.FormatChannels("acme", []string{"Blog:7"}))
// Output:
// 42 blog:7 namespace=blog presence=false allowed=true reason=""
// 42 blog:8 namespace=blog presence=false allowed=false reason="not a member of the blog"
// 42 presence:blog:7 namespace=blog presence=true allowed=false reason="not a member of the blog"
// shop:7 no authorizer
// 12 [acme:blog:7]
}
// Post is the acme.blog post model. It knows nothing about realtime.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
BlogID uint `gorm:"column:blog_id"`
Title string `gorm:"column:title"`
}
func (Post) TableName() string { return "acme_blog_posts" }
// bindPosts registers the broadcast contract of Post from the plugin's Boot.
func bindPosts(svc *lighthouse.Service) error {
// docs:start bind
return lighthouse.Bind[Post](svc, lighthouse.Binding[Post]{
Alias: "blog.post",
Channels: func(ctx context.Context, tx *gorm.DB, p *Post) ([]string, error) {
return []string{"blog:" + strconv.FormatUint(uint64(p.BlogID), 10)}, nil
},
})
// docs:end bind
}
// publishPost creates a post; its broadcast is published after commit.
func publishPost(ctx context.Context, db *gorm.DB, fail bool) error {
// docs:start write
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Create(&Post{BlogID: 7, Title: "Hello"}).Error; err != nil {
return err
}
// The broadcast job is now queued in this transaction. It is
// published only if the transaction commits.
if fail {
return fmt.Errorf("rolled back")
}
return nil
})
// docs:end write
}
// importPosts writes many posts and publishes one summary event.
func importPosts(ctx context.Context, svc *lighthouse.Service, db *gorm.DB, titles []string) error {
// docs:start bulk
return lighthouse.WithoutBroadcasting[Post](ctx, func(ctx context.Context) error {
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
for _, title := range titles {
if err := tx.Create(&Post{BlogID: 7, Title: title}).Error; err != nil {
return err
}
}
return svc.Emit(ctx, tx, lighthouse.Broadcast{
Channels: []string{"blog:7"},
Event: "blog.posts_imported",
Payload: struct {
Count int `json:"count"`
}{len(titles)},
})
})
})
// docs:end bulk
}
// waitFor waits until the memory driver has recorded n publications.
func waitFor(t *testing.T, mem *lighthouse.MemoryDriver, n int) []lighthouse.Publication {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for len(mem.Publications()) < n {
if time.Now().After(deadline) {
t.Fatalf("got %d publications, want %d", len(mem.Publications()), n)
}
time.Sleep(20 * time.Millisecond)
}
time.Sleep(300 * time.Millisecond) // catch extra publications
pubs := mem.Publications()
if len(pubs) != n {
t.Fatalf("got %d publications, want exactly %d: %+v", len(pubs), n, pubs)
}
return pubs
}
// TestDocsBroadcast runs the bind, write and bulk regions of the Realtime
// page on the package's Postgres harness with a job worker.
func TestDocsBroadcast(t *testing.T) {
_, svc, db := lighthouse.DocsEnv(t)
if err := db.Exec(`CREATE TABLE acme_blog_posts (id SERIAL PRIMARY KEY, blog_id INTEGER NOT NULL, title TEXT NOT NULL)`).Error; err != nil {
t.Fatal(err)
}
if err := bindPosts(svc); err != nil {
t.Fatal(err)
}
mem, ok := svc.Driver().(*lighthouse.MemoryDriver)
if !ok {
t.Fatalf("driver is %T", svc.Driver())
}
ctx := t.Context()
if err := publishPost(ctx, db, true); err == nil {
t.Fatal("rolled-back publish succeeded")
}
if err := publishPost(ctx, db, false); err != nil {
t.Fatal(err)
}
pubs := waitFor(t, mem, 1)
if pubs[0].Event != "created.blog.post" || len(pubs[0].Channels) != 1 || pubs[0].Channels[0] != "blog:7" {
t.Fatalf("publication = %+v", pubs[0])
}
if err := importPosts(ctx, svc, db, []string{"A", "B", "C"}); err != nil {
t.Fatal(err)
}
pubs = waitFor(t, mem, 2)
if pubs[1].Event != "blog.posts_imported" || string(pubs[1].Payload) != `{"count":3}` {
t.Fatalf("summary = %+v %s", pubs[1], pubs[1].Payload)
}
}