Files
sm-bm-plugin/material_revision_postgres_test.go
Jakub Zych 263c9c3926 feat: revision-check the upsert, MCP and admin writes
- POST /materials locks the matched row and answers 409 to a stale
  revision when it updates; a create ignores revision
- bm_manage_save_material and bm_manage_update_material take revision
- /bmadmin saves bump the revision on content changes (not checked)
- document revisions and the 409 in the README
2026-11-08 23:36:17 +01:00

217 lines
8.4 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"])
}
// POST upserts on title and course: updating the existing material it
// checks a sent revision like PUT; creating one it ignores it.
const upsert = "/api/v1/bm/materials"
code, out = send(http.MethodPost, upsert, with(map[string]any{"body": "nieaktualna\n", "revision": 4}))
if code != http.StatusConflict || out["revision"] != float64(5) {
t.Fatalf("POST with a stale revision = %d %v, want 409 and revision 5", code, out)
}
if err := db.First(&stored, created["id"]).Error; err != nil {
t.Fatal(err)
}
if stored.Body == nil || *stored.Body != "trzecia\n" || stored.Revision != 5 {
t.Errorf("after the POST 409 the row is body %v revision %d, want unchanged", stored.Body, stored.Revision)
}
code, out = send(http.MethodPost, upsert, with(map[string]any{"revision": 5}))
if code != http.StatusOK || out["created"] != false || data(out)["revision"] != float64(5) {
t.Errorf("POST with the current revision and the same content = %d %v, want 200 and revision 5", code, out)
}
code, out = send(http.MethodPost, upsert, with(map[string]any{"body": "czwarta\n", "revision": 5}))
if code != http.StatusOK || out["created"] != false || data(out)["revision"] != float64(6) {
t.Fatalf("POST with the current revision and a new body = %d %v, want 200 and revision 6", code, out)
}
note["body"] = "czwarta\n"
code, out = send(http.MethodPost, upsert, with(map[string]any{"is_private": false}))
if code != http.StatusOK || data(out)["revision"] != float64(6) {
t.Errorf("POST without revision changing only is_private = %d %v, want revision 6", code, out)
}
code, out = send(http.MethodPost, upsert, with(map[string]any{"body": "piąta\n"}))
if code != http.StatusOK || data(out)["revision"] != float64(7) {
t.Errorf("POST without revision and a new body = %d %v, want 200 and revision 7", code, out)
}
code, out = send(http.MethodPost, upsert, map[string]any{"title": "Nowa z rewizją", "type": "notatka", "body": "nowa\n", "revision": 9})
if code != http.StatusCreated || out["created"] != true || data(out)["revision"] != float64(1) {
t.Errorf("POST creating with a revision = %d %v, want 201 and revision 1", code, out)
}
}