Files
sm-bm-plugin/material_watch_postgres_test.go
Jakub Zych 2a47186a7a feat: announce material revisions on materials:{id}
- every revision bump (API writes and /bmadmin saves) enqueues
  updated.jz.bm.material with {material_id, revision} in a savepoint
- the materials namespace authorizer allows a subscribe only to a
  visible material the user may manage
- api.ManagesMaterials and api.CanWatchMaterial
2026-11-09 01:12:49 +01:00

186 lines
6.0 KiB
Go

package bm_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"testing"
"time"
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/classes"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// The realtime config of the material watch test: the Centrifugo driver, so
// Emit enqueues broadcast jobs.
const (
watchProxySecret = "material-watch-proxy-secret-0123456789"
watchTokenSecret = "bm-test-only-material-watch-token-secret-32b"
watchAppKey = "bm-test-only-material-watch-app-key"
)
// TestMaterialWatchAgainstDatabase checks the live revision heads-up (D-20)
// against Postgres: who may follow materials:{id} (api.CanWatchMaterial),
// and that a management PUT which changes the content queues exactly one
// broadcast on the material's channel while a visibility-only PUT queues
// none.
func TestMaterialWatchAgainstDatabase(t *testing.T) {
app, plugins, db := migratedWith(t,
"SUMMER_REALTIME__DRIVER=centrifugo",
"SUMMER_REALTIME__CENTRIFUGO__PROXY_SECRET="+watchProxySecret,
"SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET="+watchTokenSecret,
"SUMMER_APP__KEY="+watchAppKey,
)
seedFixture(t, plugins, "testdata/seed.example.json")
var course models.Course
if err := db.Order("id").First(&course).Error; err != nil {
t.Fatal(err)
}
author := createUser(t, db, "bm-watch-author@example.test")
other := createUser(t, db, "bm-watch-other@example.test")
student := createUser(t, db, "bm-watch-student@example.test")
var groupID uint
if err := db.Raw(`
INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
VALUES ('BM materials', 'bm-materials', '{"jz.bm.access_materials":1}', NOW(), NOW())
RETURNING id`).Scan(&groupID).Error; err != nil {
t.Fatal(err)
}
for _, id := range []uint{author, other} {
if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, id, groupID).Error; err != nil {
t.Fatal(err)
}
}
now := time.Now()
past := now.Add(-time.Hour)
insert := func(title string, published *time.Time, private bool, authorID *uint) uint {
t.Helper()
var id uint
err := db.Raw(`
INSERT INTO jz_bm_materials (course_id, title, type, body, published_at, is_private, author_user_id, created_at, updated_at)
VALUES (?, ?, 'notatka', 'tekst', ?, ?, ?, NOW(), NOW())
RETURNING id`, course.ID, title, published, private, authorID).Scan(&id).Error
if err != nil {
t.Fatal(err)
}
return id
}
authored := insert("Autorska", &past, false, &author)
orphan := insert("Bez autora", &past, false, nil)
hidden := insert("Prywatna", &past, true, nil)
draft := insert("Szkic", nil, false, nil)
for _, c := range []struct {
name string
userID uint
id uint
want bool
}{
{"the author manager", author, authored, true},
{"another manager on an authored material", other, authored, false},
{"any manager on a material with no author", other, orphan, true},
{"a non-manager", student, orphan, false},
{"a private material the user cannot see", other, hidden, false},
{"a draft", other, draft, false},
{"an unknown material", author, draft + 1000, false},
} {
got, err := api.CanWatchMaterial(t.Context(), db, now, c.userID, c.id)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
if got != c.want {
t.Errorf("%s: CanWatchMaterial = %v, want %v", c.name, got, c.want)
}
}
secret, token, err := userclasses.MintAPIToken("usr_", author, "BM watch", []string{"read", "write"}, nil, nil)
if err != nil {
t.Fatal(err)
}
if err := db.Create(token).Error; err != nil {
t.Fatal(err)
}
handler, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
send := func(method, path string, payload any) (int, map[string]any) {
t.Helper()
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(raw))
req.Header.Set("Authorization", "Bearer "+secret)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
note := map[string]any{"title": "Na żywo", "type": "notatka", "course_id": course.ID, "body": "pierwsza\n"}
code, out := send(http.MethodPost, "/api/v1/bm/materials", note)
if code != http.StatusCreated {
t.Fatalf("create = %d %v", code, out)
}
d, _ := out["data"].(map[string]any)
id := uint(d["id"].(float64))
path := "/api/v1/bm/materials/" + strconv.FormatUint(uint64(id), 10)
channel := classes.MaterialChannel(id)
if n := revisionJobs(t, db, channel); n != 0 {
t.Fatalf("create queued %d revision broadcasts, want 0", n)
}
note["body"] = "druga\n"
if code, out := send(http.MethodPut, path, note); code != http.StatusOK {
t.Fatalf("content PUT = %d %v", code, out)
}
if n := revisionJobs(t, db, channel); n != 1 {
t.Fatalf("content PUT queued %d revision broadcasts on %s, want 1", n, channel)
}
note["is_private"] = true
if code, out := send(http.MethodPut, path, note); code != http.StatusOK {
t.Fatalf("visibility PUT = %d %v", code, out)
}
if n := revisionJobs(t, db, channel); n != 1 {
t.Errorf("visibility PUT changed the revision broadcasts to %d, want still 1", n)
}
}
// revisionJobs counts the queued broadcast jobs that announce a material
// revision on channel.
func revisionJobs(t *testing.T, db *gorm.DB, channel string) int {
t.Helper()
var rows []string
if err := db.Raw(`SELECT args::text FROM river_job WHERE kind = 'summer.broadcast'`).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
n := 0
for _, raw := range rows {
var args struct {
Channels []string `json:"channels"`
Event string `json:"event"`
}
if err := json.Unmarshal([]byte(raw), &args); err != nil {
t.Fatal(err)
}
if args.Event == classes.MaterialRevisedEvent && slices.Contains(args.Channels, channel) {
n++
}
}
return n
}