- 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
175 lines
7.0 KiB
Go
175 lines
7.0 KiB
Go
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"])
|
|
}
|
|
}
|