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
This commit is contained in:
185
material_watch_postgres_test.go
Normal file
185
material_watch_postgres_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user