- GET /_bm/api/v1/tags: the jz_bm_tags dictionary, to material managers
- POST /materials/{id}/unpublish: back to drafts, no revision bump, the
search document resynced
- POST /materials/{id}/images: one JPEG/PNG/GIF/WebP per request through
storeMaterialUpload, refusals with reason type, size or count
- storeMaterialUpload/fileRefusalFor: attach.ErrNotImage is a type
refusal, not a 500; storeMaterialFile keeps its texts
- the author's routes get their own per-user buckets (the inline
throttle form shares one counter per user)
- README: the author routes and revision on the student detail
185 lines
5.7 KiB
Go
185 lines
5.7 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFileRefusalReasons checks how attach.Store errors become refusals of
|
|
// the material file routes: size for ErrTooLarge, type for ErrFileType,
|
|
// ErrMIMEType and the image-mode ErrNotImage (no longer a 500), and no
|
|
// refusal for other errors.
|
|
func TestFileRefusalReasons(t *testing.T) {
|
|
cases := []struct {
|
|
err error
|
|
reason string
|
|
}{
|
|
{attach.ErrTooLarge, "size"},
|
|
{attach.ErrFileType, "type"},
|
|
{attach.ErrMIMEType, "type"},
|
|
{attach.ErrNotImage, "type"},
|
|
{fmt.Errorf("store: %w", attach.ErrNotImage), "type"},
|
|
}
|
|
for _, tc := range cases {
|
|
why, ok := fileRefusalFor(tc.err, "a.png", imageLimits)
|
|
if !ok || why.Reason != tc.reason || !strings.HasPrefix(why.Text, "a.png: ") {
|
|
t.Errorf("fileRefusalFor(%v) = %+v %v, want reason %s", tc.err, why, ok, tc.reason)
|
|
}
|
|
}
|
|
if why, _ := fileRefusalFor(attach.ErrTooLarge, "a.png", imageLimits); why.Text != "a.png: the file is larger than 50 MB." {
|
|
t.Errorf("size text = %q", why.Text)
|
|
}
|
|
for _, err := range []error{nil, errors.New("disk full")} {
|
|
if why, ok := fileRefusalFor(err, "a.png", imageLimits); ok {
|
|
t.Errorf("fileRefusalFor(%v) = %+v, want no refusal", err, why)
|
|
}
|
|
}
|
|
if !imageLimits.Image || imageLimits.MaxBytes != MaterialFileMaxBytes {
|
|
t.Errorf("imageLimits = %+v", imageLimits)
|
|
}
|
|
}
|