- add jz_bm_materials.revision (default 1), read-only for GORM
- bump it only on content changes (models.ReviseMaterial)
- PUT /materials/{id} locks the row and answers 409 to a stale revision
- management answers carry revision and updated_at, the student detail
carries revision
182 lines
6.3 KiB
Go
182 lines
6.3 KiB
Go
package bm_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
|
|
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
)
|
|
|
|
// TestMaterialRevisionAgainstDatabase checks the revision of materials
|
|
// through the management API (D-01..D-05): a new material is at revision 1;
|
|
// a write with a stale revision is a 409 with the current version and
|
|
// changes nothing; a write without revision overwrites; only content
|
|
// changes bump; and the student detail shows the same revision.
|
|
func TestMaterialRevisionAgainstDatabase(t *testing.T) {
|
|
app, plugins, db := migrated(t)
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
|
|
var courses []models.Course
|
|
if err := db.Order("id").Limit(2).Find(&courses).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(courses) < 2 {
|
|
t.Fatalf("the fixture has %d courses, want 2", len(courses))
|
|
}
|
|
userID := createUser(t, db, "bm-revision@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)
|
|
}
|
|
if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM notes", []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)
|
|
}
|
|
student := apiClient{t: t, h: handler, token: tokenFor(t, userID)}
|
|
|
|
send := func(method, path string, payload any) (int, map[string]any) {
|
|
t.Helper()
|
|
var rd io.Reader
|
|
if payload != nil {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rd = bytes.NewReader(raw)
|
|
}
|
|
req := httptest.NewRequest(method, path, rd)
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
var out map[string]any
|
|
if rec.Body.Len() > 0 {
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
}
|
|
return rec.Code, out
|
|
}
|
|
data := func(out map[string]any) map[string]any {
|
|
t.Helper()
|
|
d, _ := out["data"].(map[string]any)
|
|
if d == nil {
|
|
t.Fatalf("no data object in %v", out)
|
|
}
|
|
return d
|
|
}
|
|
|
|
note := map[string]any{"title": "Rewizje", "type": "notatka", "course_id": courses[0].ID, "body": "pierwsza\n", "tags": []string{"Globalizacja"}}
|
|
code, out := send(http.MethodPost, "/api/v1/bm/materials", note)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("create = %d %v", code, out)
|
|
}
|
|
created := data(out)
|
|
if created["revision"] != float64(1) || created["updated_at"] == nil {
|
|
t.Fatalf("created = %v, want revision 1 and updated_at", created)
|
|
}
|
|
path := "/api/v1/bm/materials/" + strconv.Itoa(int(created["id"].(float64)))
|
|
studentPath := "/_bm/api/v1/materials/" + strconv.Itoa(int(created["id"].(float64)))
|
|
|
|
revisionOf := func() float64 {
|
|
t.Helper()
|
|
code, out := send(http.MethodGet, path, nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("show = %d %v", code, out)
|
|
}
|
|
r, _ := data(out)["revision"].(float64)
|
|
return r
|
|
}
|
|
if r := revisionOf(); r != 1 {
|
|
t.Fatalf("new material revision = %v, want 1", r)
|
|
}
|
|
with := func(changes map[string]any) map[string]any {
|
|
out := maps.Clone(note)
|
|
maps.Copy(out, changes)
|
|
return out
|
|
}
|
|
|
|
code, out = send(http.MethodPut, path, with(map[string]any{"body": "druga\n", "revision": 1}))
|
|
if code != http.StatusOK || data(out)["revision"] != float64(2) {
|
|
t.Fatalf("PUT with the current revision = %d %v, want 200 and revision 2", code, out)
|
|
}
|
|
note["body"] = "druga\n"
|
|
|
|
code, out = send(http.MethodPut, path, with(map[string]any{"body": "nieaktualna\n", "revision": 1}))
|
|
if code != http.StatusConflict {
|
|
t.Fatalf("PUT with a stale revision = %d %v, want 409", code, out)
|
|
}
|
|
if out["error"] != true || out["revision"] != float64(2) || out["updated_at"] == nil || out["message"] == nil {
|
|
t.Errorf("409 body = %v, want error, message, revision 2 and updated_at", out)
|
|
}
|
|
if data(out)["body"] != "druga\n" {
|
|
t.Errorf("409 data.body = %v, want the stored body", data(out)["body"])
|
|
}
|
|
var stored models.Material
|
|
if err := db.First(&stored, created["id"]).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Body == nil || *stored.Body != "druga\n" || stored.Revision != 2 {
|
|
t.Errorf("after the 409 the row is body %v revision %d, want unchanged", stored.Body, stored.Revision)
|
|
}
|
|
|
|
code, out = send(http.MethodPut, path, with(map[string]any{"body": "trzecia\n"}))
|
|
if code != http.StatusOK || data(out)["revision"] != float64(3) {
|
|
t.Fatalf("PUT without revision = %d %v, want 200 and revision 3", code, out)
|
|
}
|
|
note["body"] = "trzecia\n"
|
|
|
|
for name, changes := range map[string]map[string]any{
|
|
"published_at only": {"published_at": "2026-10-01T10:00:00+02:00"},
|
|
"is_private only": {"is_private": true},
|
|
"identical content": {},
|
|
"identical, current": {"revision": 3},
|
|
} {
|
|
code, out = send(http.MethodPut, path, with(changes))
|
|
if code != http.StatusOK || data(out)["revision"] != float64(3) {
|
|
t.Errorf("PUT with %s = %d %v, want 200 and revision 3", name, code, out)
|
|
}
|
|
}
|
|
|
|
code, out = send(http.MethodPut, path, with(map[string]any{"tags": []string{"Globalizacja", "Teoria"}}))
|
|
if code != http.StatusOK || data(out)["revision"] != float64(4) {
|
|
t.Fatalf("PUT changing the tags = %d %v, want revision 4", code, out)
|
|
}
|
|
note["tags"] = []string{"Teoria", "Globalizacja"}
|
|
code, out = send(http.MethodPut, path, note)
|
|
if code != http.StatusOK || data(out)["revision"] != float64(4) {
|
|
t.Errorf("PUT with the same tags reordered = %d %v, want revision 4", code, out)
|
|
}
|
|
code, out = send(http.MethodPut, path, with(map[string]any{"course_id": courses[1].ID}))
|
|
if code != http.StatusOK || data(out)["revision"] != float64(5) {
|
|
t.Fatalf("PUT changing the course = %d %v, want revision 5", code, out)
|
|
}
|
|
note["course_id"] = courses[1].ID
|
|
|
|
detail := obj(student.ok(http.MethodGet, studentPath)["data"])
|
|
if detail["revision"] != float64(5) {
|
|
t.Errorf("student detail revision = %v, want 5", detail["revision"])
|
|
}
|
|
}
|