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"]) } }