feat: tags, unpublish and image upload for the author
- 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
This commit is contained in:
@@ -1,22 +1,39 @@
|
||||
package bm_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
"gocloud.dev/blob"
|
||||
_ "gocloud.dev/blob/memblob"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// rules, that an edit keeps the course and the author, the tag dictionary,
|
||||
// unpublish to drafts and the editor image upload with its refusals.
|
||||
func TestMaterialEditAgainstDatabase(t *testing.T) {
|
||||
app, plugins, gdb := migrated(t)
|
||||
seedFixture(t, plugins, "testdata/seed.example.json")
|
||||
bucket, err := blob.OpenBucket(t.Context(), "mem://")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { bucket.Close() })
|
||||
if err := app.Publish(bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
@@ -171,4 +188,91 @@ VALUES (?, ?, 'notatka', 'Stara treść', `+published+`, ?, ?) RETURNING id`, co
|
||||
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"])
|
||||
}
|
||||
|
||||
// The tag dictionary: managers only, by name.
|
||||
var names []string
|
||||
for _, tag := range list(author.ok("GET", base+"/tags")) {
|
||||
names = append(names, tag["name"].(string))
|
||||
}
|
||||
if strings.Join(names, ",") != "Cyber,Lektura,Pokój,Prawo,Wojna" {
|
||||
t.Errorf("tags = %v, want the dictionary by name", names)
|
||||
}
|
||||
plain.want(http.StatusForbidden, "GET", base+"/tags", "")
|
||||
|
||||
// Editor images: one PNG per request, public, no revision bump.
|
||||
pngBytes := func() []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}()
|
||||
upload := func(c apiClient, materialID uint, name string, content []byte) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write(content)
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", path(materialID)+"/images", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
rec := httptest.NewRecorder()
|
||||
c.h.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("upload %s = %d, not JSON: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Code, out
|
||||
}
|
||||
code, out := upload(author, own, "obraz.png", pngBytes)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("PNG upload = %d %v, want 201", code, out)
|
||||
}
|
||||
if img := obj(out["data"]); !strings.HasPrefix(img["url"].(string), "/storage/app/uploads/public/") || img["mime"] != "image/png" || img["name"] != "obraz.png" {
|
||||
t.Errorf("uploaded image = %v", img)
|
||||
}
|
||||
if m := row(own); m.Revision != 2 {
|
||||
t.Errorf("revision after the upload = %d, want 2", m.Revision)
|
||||
}
|
||||
if code, out := upload(author, own, "image.png", []byte("to nie jest obrazek")); code != http.StatusUnprocessableEntity || out["reason"] != "type" {
|
||||
t.Errorf("text as image.png = %d %v, want 422 reason type", code, out)
|
||||
}
|
||||
if code, out := upload(author, own, "rysunek.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)); code != http.StatusUnprocessableEntity || out["reason"] != "type" {
|
||||
t.Errorf("SVG = %d %v, want 422 reason type", code, out)
|
||||
}
|
||||
if code, out := upload(other, own, "obraz.png", pngBytes); code != http.StatusForbidden {
|
||||
t.Errorf("another manager's upload = %d %v, want 403", code, out)
|
||||
}
|
||||
if code, out := upload(author, othersPrivate, "obraz.png", pngBytes); code != http.StatusNotFound {
|
||||
t.Errorf("upload to an invisible material = %d %v, want 404", code, out)
|
||||
}
|
||||
for i := 1; i < 20; i++ {
|
||||
if code, out := upload(author, own, "obraz.png", pngBytes); code != http.StatusCreated {
|
||||
t.Fatalf("upload %d = %d %v, want 201", i+1, code, out)
|
||||
}
|
||||
}
|
||||
if code, out := upload(author, own, "obraz.png", pngBytes); code != http.StatusUnprocessableEntity || out["reason"] != "count" {
|
||||
t.Errorf("21st file = %d %v, want 422 reason count", code, out)
|
||||
}
|
||||
if d := obj(author.ok("GET", path(own))["data"]); d["files_count"] != float64(20) {
|
||||
t.Errorf("files_count = %v, want 20", d["files_count"])
|
||||
}
|
||||
|
||||
// Unpublish: a draft, 404 even for the author afterwards, revision kept.
|
||||
other.want(http.StatusForbidden, "POST", path(own)+"/unpublish", "")
|
||||
author.want(http.StatusNotFound, "POST", path(othersPrivate)+"/unpublish", "")
|
||||
if code, _ := author.send("POST", path(own)+"/unpublish", ""); code != http.StatusNoContent {
|
||||
t.Fatalf("unpublish = %d, want 204", code)
|
||||
}
|
||||
author.want(http.StatusNotFound, "GET", path(own), "")
|
||||
if m := row(own); m.PublishedAt != nil || m.Revision != 2 || m.AuthorUserID == nil || *m.AuthorUserID != authorID {
|
||||
t.Errorf("row after unpublish = %+v, want a draft at revision 2 with its author", m)
|
||||
}
|
||||
author.want(http.StatusNotFound, "POST", path(own)+"/unpublish", "")
|
||||
author.want(http.StatusNotFound, "PUT", path(own), edit(2, nil))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user