- 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
147 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|