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 }