feat: the author edits a material in the app

- PUT /_bm/api/v1/materials/{id} (jwt.auth only, no token twin): every
  key of the edit body required, revision checked on the row locked FOR
  UPDATE, 409 with the student detail of the server version
- editableMaterialForCaller: 404 for what the caller cannot see, 403 for
  what they see but may not manage
- tags from the dictionary only (at most 4), http(s) urls, the body
  normalised as bm-notatka push does; course, author and publication kept
- loadMaterialDetail shared by GET /materials/{id} and the edit answers
This commit is contained in:
Jakub Zych
2026-11-09 01:01:25 +01:00
parent 263c9c3926
commit 7bf18de987
9 changed files with 725 additions and 11 deletions

View File

@@ -0,0 +1,174 @@
package bm_test
import (
"encoding/json"
"net/http"
"testing"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
// TestMaterialEditAgainstDatabase checks the author's student routes
// (D-07..D-11, D-16) against Postgres: who may edit (the author; 403 for
// another manager or a non-manager; 404 for what the caller cannot see),
// the revision check with the server version in the 409, the edit body's
// rules, and that an edit keeps the course and the author.
func TestMaterialEditAgainstDatabase(t *testing.T) {
app, plugins, gdb := migrated(t)
seedFixture(t, plugins, "testdata/seed.example.json")
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatalf("Assemble: %v", err)
}
authorID := createUser(t, gdb, "edit-author@example.test")
otherID := createUser(t, gdb, "edit-other@example.test")
plainID := createUser(t, gdb, "edit-plain@example.test")
var groupID uint
if err := gdb.Raw(`
INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
VALUES ('BM materials', 'bm-materials-edit', '{"jz.bm.access_materials":1}', NOW(), NOW())
RETURNING id`).Scan(&groupID).Error; err != nil {
t.Fatal(err)
}
for _, uid := range []uint{authorID, otherID} {
if err := gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, uid, groupID).Error; err != nil {
t.Fatal(err)
}
}
author := apiClient{t: t, h: h, token: tokenFor(t, authorID)}
other := apiClient{t: t, h: h, token: tokenFor(t, otherID)}
plain := apiClient{t: t, h: h, token: tokenFor(t, plainID)}
const base = "/_bm/api/v1"
for _, tag := range [][2]string{{"Lektura", "lektura"}, {"Cyber", "cyber"}, {"Wojna", "wojna"}, {"Pokój", "pokoj"}, {"Prawo", "prawo"}} {
if err := gdb.Exec(`INSERT INTO jz_bm_tags (name, slug, created_at, updated_at) VALUES (?, ?, NOW(), NOW())`, tag[0], tag[1]).Error; err != nil {
t.Fatal(err)
}
}
var course models.Course
if err := gdb.Order("id").Take(&course).Error; err != nil {
t.Fatal(err)
}
var meeting models.Meeting
if err := gdb.Order("number").Take(&meeting).Error; err != nil {
t.Fatal(err)
}
insert := func(title string, private, draft bool, authorUserID *uint) uint {
t.Helper()
published := "NOW() - INTERVAL '1 day'"
if draft {
published = "NULL"
}
var id uint
err := gdb.Raw(`INSERT INTO jz_bm_materials (course_id, title, type, body, published_at, is_private, author_user_id)
VALUES (?, ?, 'notatka', 'Stara treść', `+published+`, ?, ?) RETURNING id`, course.ID, title, private, authorUserID).Scan(&id).Error
if err != nil {
t.Fatal(err)
}
return id
}
own := insert("Notatka autora", false, false, &authorID)
othersPrivate := insert("Prywatna innego", true, false, &otherID)
draft := insert("Szkic autora", false, true, &authorID)
insert("Zajęty tytuł", false, false, &authorID)
edit := func(revision uint, mutate func(map[string]any)) string {
t.Helper()
b := map[string]any{
"revision": revision,
"title": "Nowy tytuł",
"type": "notatka",
"body": "\r\n# Nagłówek\r\n\r\ntekst\r\n\r\n",
"source": nil,
"url": "https://example.test/a",
"meeting_id": meeting.ID,
"tags": []string{"Lektura", "cyber"},
}
if mutate != nil {
mutate(b)
}
raw, err := json.Marshal(b)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
path := func(id uint) string { return base + "/materials/" + jsonID(id) }
row := func(id uint) models.Material {
t.Helper()
var m models.Material
if err := gdb.Where("id = ?", id).Take(&m).Error; err != nil {
t.Fatal(err)
}
return m
}
// The author saves: revision +1, the body normalised, course and author
// kept, the meeting and tags as sent.
saved := obj(author.want(http.StatusOK, "PUT", path(own), edit(1, nil))["data"])
if saved["revision"] != float64(2) || saved["body"] != "# Nagłówek\n\ntekst\n" || saved["title"] != "Nowy tytuł" {
t.Errorf("saved = %v, want revision 2 and the normalised body", saved)
}
if id(saved["course_id"]) != jsonID(course.ID) || id(saved["meeting_id"]) != jsonID(meeting.ID) {
t.Errorf("saved course/meeting = %v/%v", saved["course_id"], saved["meeting_id"])
}
if tags, _ := saved["tags"].([]any); len(tags) != 2 {
t.Errorf("saved tags = %v, want 2", saved["tags"])
}
if m := row(own); m.AuthorUserID == nil || *m.AuthorUserID != authorID || m.CourseID == nil || *m.CourseID != course.ID || m.PublishedAt == nil || m.IsPrivate {
t.Errorf("row after the edit = %+v, want author, course and publication kept", m)
}
var tagCount int64
gdb.Model(&models.Tag{}).Count(&tagCount)
if tagCount != 5 {
t.Errorf("tags = %d, want the 5 of the dictionary (none created)", tagCount)
}
// A stale revision: 409 with the server version, nothing written.
conflict := author.want(http.StatusConflict, "PUT", path(own), edit(1, func(b map[string]any) { b["body"] = "Inna treść" }))
if conflict["revision"] != float64(2) || conflict["error"] != true || conflict["updated_at"] == nil {
t.Errorf("409 = %v, want revision 2", conflict)
}
if d := obj(conflict["data"]); d["body"] != "# Nagłówek\n\ntekst\n" || d["revision"] != float64(2) {
t.Errorf("409 data = %v, want the stored body", d)
}
if m := row(own); m.Body == nil || *m.Body != "# Nagłówek\n\ntekst\n" {
t.Errorf("body after the 409 = %v", m.Body)
}
// Who may write: 403 for what the caller sees but may not manage, 404
// for what they cannot see.
other.want(http.StatusForbidden, "PUT", path(own), edit(2, nil))
plain.want(http.StatusForbidden, "PUT", path(own), edit(2, nil))
author.want(http.StatusNotFound, "PUT", path(othersPrivate), edit(1, nil))
author.want(http.StatusNotFound, "PUT", path(draft), edit(1, nil))
author.want(http.StatusNotFound, "PUT", path(999999), edit(1, nil))
// The edit body's rules.
for name, mutate := range map[string]func(map[string]any){
"5 tags": func(b map[string]any) { b["tags"] = []string{"lektura", "cyber", "wojna", "pokoj", "prawo"} },
"unknown tag": func(b map[string]any) { b["tags"] = []string{"nieznany"} },
"ftp url": func(b map[string]any) { b["url"] = "ftp://example.test/a" },
"missing key": func(b map[string]any) { delete(b, "title") },
"published_at": func(b map[string]any) { b["published_at"] = nil },
"course_id": func(b map[string]any) { b["course_id"] = course.ID },
"null tags": func(b map[string]any) { b["tags"] = nil },
"no revision": func(b map[string]any) { delete(b, "revision") },
"bad meeting": func(b map[string]any) { b["meeting_id"] = 999999 },
"taken title": func(b map[string]any) { b["title"] = "Zajęty tytuł" },
} {
code, out := author.send("PUT", path(own), edit(2, mutate))
if code != http.StatusUnprocessableEntity {
t.Errorf("%s = %d %v, want 422", name, code, out)
}
}
if m := row(own); m.Revision != 2 {
t.Errorf("revision after the refused edits = %d, want 2", m.Revision)
}
// An unchanged save keeps the revision.
if again := obj(author.want(http.StatusOK, "PUT", path(own), edit(2, nil))["data"]); again["revision"] != float64(2) {
t.Errorf("unchanged save revision = %v, want 2", again["revision"])
}
}