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:
Jakub Zych
2026-11-09 01:05:54 +01:00
parent 7bf18de987
commit 40e4a2438b
9 changed files with 499 additions and 27 deletions

View File

@@ -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))
}