Files
sm-user-plugin/organisation_logo_test.go
Jakub Zych 9730110d55 fix: the avatar and logo URL fallback follows the configured public path
When the thumbnail fails, AvatarURL and OrganisationLogoURL answered
"/" + the blob key, which skips storage.uploads.public_path_prefix and
public_url (a 404 with the WinterCMS layout). They now answer the
original's attach.File.URL.
2026-10-23 21:11:04 +02:00

170 lines
6.6 KiB
Go

package user
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob/memblob"
)
// uploadOrganisationFile posts an image to an organisation form's file
// field under a form session key.
func (e *adminEnv) uploadOrganisationFile(t *testing.T, token string, id uint, field, name, sessionKey string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file_data", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(jpegBytes(t)); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
if up := e.send(t, http.MethodPost, organisationPath(id, "/files/"+field), token, buf.Bytes(), mw.FormDataContentType(), map[string]string{cabana.SessionKeyHeader: sessionKey}); up.Code != http.StatusCreated && up.Code != http.StatusOK {
t.Fatalf("%s upload = %d %s", field, up.Code, up.Body.String())
}
}
// organisationFiles counts an organisation's system_files rows of field.
func (e *adminEnv) organisationFiles(t *testing.T, id uint, field string) int64 {
t.Helper()
var n int64
if err := e.gdb.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = ?`,
(models.Organisation{}).MorphName(), strconv.FormatUint(uint64(id), 10), field).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
// TestOrganisationLogo pins E-1 (P-38): the Organisations form has a logo
// fileupload right after the avatar; a logo uploaded through the admin form
// API is stored as a system_files row with field logo; OrganisationLogoFields
// answers has_logo, the disk name and a URL, or false and nils without a
// logo, falling back to the original's public URL when the thumbnail fails;
// the avatar keeps working beside it; and deleting the organisation
// removes the logo attachment as it does the avatar. Postgres only: under
// -short nothing runs.
func TestOrganisationLogo(t *testing.T) {
if testing.Short() {
return
}
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
ctx := context.Background()
t.Run("the form has a logo fileupload next to the avatar", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/form"), token, nil)
var schema struct {
Data struct {
Fields []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"fields"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &schema); err != nil {
t.Fatal(err)
}
at := -1
for i, f := range schema.Data.Fields {
if f.Name == "avatar" {
at = i
}
}
if at < 0 || at+1 >= len(schema.Data.Fields) || schema.Data.Fields[at+1].Name != models.OrganisationLogoField || schema.Data.Fields[at+1].Type != "fileupload" {
t.Fatalf("form fields = %+v, want logo:fileupload right after avatar", schema.Data.Fields)
}
})
t.Run("without a logo the fields answer false and nils", func(t *testing.T) {
org := env.seedOrganisation(t, "No Logo "+adminStamp())
fields, err := classes.OrganisationLogoFields(ctx, env.app, org)
if err != nil {
t.Fatal(err)
}
if fields["has_logo"] != false || fields["logo"] != nil || fields["logo_url"] != nil {
t.Fatalf("fields = %+v", fields)
}
if _, ok, err := classes.OrganisationLogoURL(ctx, env.app, org, 64); err != nil || ok {
t.Fatalf("OrganisationLogoURL = %v, %v", ok, err)
}
})
t.Run("upload, payload fields, the avatar beside it and the delete", func(t *testing.T) {
name := "Logo Org " + adminStamp()
org := env.seedOrganisation(t, name)
key := adminSessionKey(t)
env.uploadOrganisationFile(t, token, org.ID, "avatar", "avatar.jpg", key)
env.uploadOrganisationFile(t, token, org.ID, models.OrganisationLogoField, "logo.jpg", key)
body, err := json.Marshal(map[string]any{"name": name, "slug": org.Slug})
if err != nil {
t.Fatal(err)
}
if saved := env.send(t, http.MethodPut, organisationPath(org.ID, ""), token, body, "application/json", map[string]string{cabana.SessionKeyHeader: key}); saved.Code != http.StatusOK {
t.Fatalf("save = %d %s", saved.Code, saved.Body.String())
}
if logos, avatars := env.organisationFiles(t, org.ID, "logo"), env.organisationFiles(t, org.ID, "avatar"); logos != 1 || avatars != 1 {
t.Fatalf("system_files rows: logo %d, avatar %d, want 1 and 1", logos, avatars)
}
fields, err := classes.OrganisationLogoFields(ctx, env.app, env.organisation(t, org.ID))
if err != nil {
t.Fatal(err)
}
disk, _ := fields["logo"].(string)
url, _ := fields["logo_url"].(string)
if fields["has_logo"] != true || disk == "" || url == "" {
t.Fatalf("fields = %+v", fields)
}
var logoDisk, avatarDisk string
if err := env.gdb.Raw(`SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'logo'`,
(models.Organisation{}).MorphName(), strconv.FormatUint(uint64(org.ID), 10)).Scan(&logoDisk).Error; err != nil {
t.Fatal(err)
}
if err := env.gdb.Raw(`SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`,
(models.Organisation{}).MorphName(), strconv.FormatUint(uint64(org.ID), 10)).Scan(&avatarDisk).Error; err != nil {
t.Fatal(err)
}
if disk != logoDisk || disk == avatarDisk {
t.Fatalf("logo disk name %q, stored logo %q, avatar %q", disk, logoDisk, avatarDisk)
}
if strings.Contains(url, avatarDisk) {
t.Fatalf("logo URL %q points at the avatar", url)
}
// A thumbnail that cannot be made (the storage fails) falls back to
// the original's public URL, under the public path prefix
// (RC-IN-03).
closed := memblob.OpenBucket(nil)
_ = closed.Close()
failing := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
if err := failing.Publish(env.gdb); err != nil {
t.Fatal(err)
}
if err := attach.Publish(failing, closed); err != nil {
t.Fatal(err)
}
if got, ok, err := classes.OrganisationLogoURL(ctx, failing, env.organisation(t, org.ID), 64); err != nil || !ok || got != attach.PublicURL(attach.BlobKey(logoDisk)) {
t.Fatalf("failing logo thumbnail = %q %v %v, want %s", got, ok, err, attach.PublicURL(attach.BlobKey(logoDisk)))
}
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(org.ID, ""), token, nil)
if logos, avatars := env.organisationFiles(t, org.ID, "logo"), env.organisationFiles(t, org.ID, "avatar"); logos != 0 || avatars != 0 {
t.Fatalf("after the delete: logo rows %d, avatar rows %d, want none", logos, avatars)
}
})
}