Files
sm-bm-plugin/controllers/api/material_edit_test.go
Jakub Zych 7bf18de987 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
2026-11-09 01:01:25 +01:00

147 lines
4.4 KiB
Go

package api
import (
"strings"
"testing"
)
const fullEditBody = `{"revision":3,"title":"Notatka","type":"notatka","body":"Tekst","source":null,"url":null,"meeting_id":null,"tags":["lektura"]}`
// TestDecodeMaterialEdit checks the student edit body (D-09): every key is
// required, revision, title, type and tags may not be null, and unknown keys
// such as course_id or published_at are refused.
func TestDecodeMaterialEdit(t *testing.T) {
in, errs, err := decodeMaterialEdit([]byte(fullEditBody))
if err != nil {
t.Fatal(err)
}
if len(errs) != 0 {
t.Errorf("full body errors = %v, want none", errs)
}
if in.Revision == nil || *in.Revision != 3 || deref(in.Title) != "Notatka" || in.Tags == nil || len(*in.Tags) != 1 {
t.Errorf("full body decoded to %+v", in)
}
for _, key := range materialEditKeys {
raw := dropKey(t, fullEditBody, key)
_, errs, err := decodeMaterialEdit([]byte(raw))
if err != nil {
t.Errorf("without %s: decode error %v", key, err)
continue
}
if _, ok := errs[key]; !ok || len(errs) != 1 {
t.Errorf("without %s: errors = %v, want one on %s", key, errs, key)
}
}
for _, key := range []string{"revision", "title", "type", "tags"} {
raw := nullKey(t, fullEditBody, key)
_, errs, err := decodeMaterialEdit([]byte(raw))
if err != nil {
t.Errorf("null %s: decode error %v", key, err)
continue
}
if _, ok := errs[key]; !ok || len(errs) != 1 {
t.Errorf("null %s: errors = %v, want one on %s", key, errs, key)
}
}
for _, key := range []string{"body", "source", "url", "meeting_id"} {
_, errs, err := decodeMaterialEdit([]byte(nullKey(t, fullEditBody, key)))
if err != nil || len(errs) != 0 {
t.Errorf("null %s = %v %v, want no error", key, errs, err)
}
}
for _, extra := range []string{`"course_id":1`, `"published_at":null`, `"author_user_id":2`, `"is_private":true`, `"share_user_ids":[]`} {
raw := strings.TrimSuffix(fullEditBody, "}") + "," + extra + "}"
if _, _, err := decodeMaterialEdit([]byte(raw)); err == nil {
t.Errorf("body with %s decoded, want an unknown-field error", extra)
}
}
if _, _, err := decodeMaterialEdit([]byte(`[1]`)); err == nil {
t.Error("an array body decoded, want an error")
}
}
// dropKey removes key and its value from the flat JSON object raw.
func dropKey(t *testing.T, raw, key string) string {
t.Helper()
parts := splitTop(raw)
out := make([]string, 0, len(parts))
for _, p := range parts {
if !strings.HasPrefix(p, `"`+key+`":`) {
out = append(out, p)
}
}
if len(out) == len(parts) {
t.Fatalf("key %s not in %s", key, raw)
}
return "{" + strings.Join(out, ",") + "}"
}
// nullKey sets key to null in the flat JSON object raw.
func nullKey(t *testing.T, raw, key string) string {
t.Helper()
parts := splitTop(raw)
found := false
for i, p := range parts {
if strings.HasPrefix(p, `"`+key+`":`) {
parts[i] = `"` + key + `":null`
found = true
}
}
if !found {
t.Fatalf("key %s not in %s", key, raw)
}
return "{" + strings.Join(parts, ",") + "}"
}
// splitTop splits a flat JSON object into its "key":value members, keeping
// commas inside arrays.
func splitTop(raw string) []string {
inner := strings.TrimSuffix(strings.TrimPrefix(raw, "{"), "}")
var parts []string
depth, start := 0, 0
for i, c := range inner {
switch c {
case '[':
depth++
case ']':
depth--
case ',':
if depth == 0 {
parts = append(parts, inner[start:i])
start = i + 1
}
}
}
return append(parts, inner[start:])
}
// TestNormalizeBody checks the body normalisation of the edit route (A6):
// LF line ends, no surrounding blank lines, one trailing newline, and an
// empty or whitespace-only body as null.
func TestNormalizeBody(t *testing.T) {
s := func(v string) *string { return &v }
cases := []struct {
name string
in *string
want *string
}{
{"nil", nil, nil},
{"empty", s(""), nil},
{"whitespace", s(" \n\t\r\n "), nil},
{"crlf", s("a\r\nb\r\n"), s("a\nb\n")},
{"cr", s("a\rb"), s("a\nb\n")},
{"blank lines around", s("\n\n# Tytuł\n\ntekst\n\n\n"), s("# Tytuł\n\ntekst\n")},
{"no newline", s("tekst"), s("tekst\n")},
{"already normal", s("tekst\n"), s("tekst\n")},
}
for _, tc := range cases {
got := normalizeBody(tc.in)
switch {
case tc.want == nil && got != nil:
t.Errorf("%s: normalizeBody = %q, want nil", tc.name, *got)
case tc.want != nil && (got == nil || *got != *tc.want):
t.Errorf("%s: normalizeBody = %v, want %q", tc.name, got, *tc.want)
}
}
}