- golem15_journal_image_variants (migration 202610280002): a thumbnail
per width of golem15.journal.image_widths (480, 800, 1200 by default,
never wider than the original) and its WebP copy, with the original's
size
- conga job golem15.journal.image_variants (three attempts), queued after
a featured image upload and after an admin save of a post whose images
lack a width; the upload request never encodes
- journal:image-variants [--all] backfills
- featured_images[].variants {width, height, url, webp_url, source_width,
source_height} on the post answers (additive key)
459 lines
16 KiB
Go
459 lines
16 KiB
Go
package journal_test
|
|
|
|
// Related posts and category neighbours on GET /posts/{slug} (D-82, P33-44):
|
|
// the default answer is today's (same category, three, newest first, no new
|
|
// keys); golem15.journal.related_mode scored ranks by shared tags (2 points
|
|
// each) and shared categories (1 point each), ties by newest, counts 3 to 6
|
|
// (golem15.journal.related_count, clamped), and adds previous_in_category
|
|
// and next_in_category within the post's first category by name.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/conga"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"gocloud.dev/blob"
|
|
)
|
|
|
|
// relatedFixture plants the target post and its candidates. Each candidate's
|
|
// score against "target" is in its comment; the newer of two equal scores
|
|
// ranks first.
|
|
type relatedFixture struct {
|
|
byName map[string]uint
|
|
}
|
|
|
|
func plantRelated(t *testing.T, env *adminEnv) relatedFixture {
|
|
t.Helper()
|
|
db := env.gdb
|
|
now := time.Now().UTC()
|
|
cat := func(name, slug string) uint {
|
|
c := models.Category{Name: name, Slug: slug}
|
|
if err := db.Create(&c).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c.ID
|
|
}
|
|
tag := func(name string) uint {
|
|
tg := models.Tag{Name: name, Slug: name}
|
|
if err := db.Create(&tg).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tg.ID
|
|
}
|
|
// "Alpha" sorts before "Beta", so Alpha is the target's first category.
|
|
alpha, beta, other := cat("Alpha", "alpha"), cat("Beta", "beta"), cat("Other", "other")
|
|
t1, t2, t3 := tag("t1"), tag("t2"), tag("t3")
|
|
f := relatedFixture{byName: map[string]uint{}}
|
|
post := func(slug string, age time.Duration, published bool, future bool, cats []uint, tags []uint) {
|
|
at := now.Add(-age)
|
|
if future {
|
|
at = now.Add(age)
|
|
}
|
|
p := models.Post{Title: "Post " + slug, Slug: slug, Content: "body " + slug, Published: published, PublishedAt: &at}
|
|
if err := db.Create(&p).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range cats {
|
|
if err := db.Create(&models.PostCategory{PostID: p.ID, CategoryID: c}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, tg := range tags {
|
|
if err := db.Create(&models.PostTag{PostID: p.ID, TagID: tg}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
f.byName[slug] = p.ID
|
|
}
|
|
hour := time.Hour
|
|
post("target", 50*hour, true, false, []uint{alpha, beta}, []uint{t1, t2})
|
|
post("a", 90*hour, true, false, []uint{alpha}, []uint{t1, t2}) // 5
|
|
post("b", 80*hour, true, false, []uint{alpha, beta}, []uint{t1}) // 4 (older than c)
|
|
post("c", 10*hour, true, false, nil, []uint{t1, t2}) // 4
|
|
post("g", 70*hour, true, false, nil, []uint{t2}) // 2
|
|
post("d", 20*hour, true, false, []uint{beta}, nil) // 1 (newer than h)
|
|
post("h", 95*hour, true, false, []uint{alpha}, nil) // 1
|
|
post("e", 5*hour, true, false, []uint{other}, []uint{t3}) // 0: never related
|
|
post("draft", 1*hour, false, false, []uint{alpha, beta}, []uint{t1, t2}) // 6, unpublished
|
|
post("later", 30*hour, true, true, []uint{alpha, beta}, []uint{t1, t2}) // 6, published in the future
|
|
// In Alpha, by published_at: h (95 h ago), a (90), b (80), target (50);
|
|
// the draft and the future post never count. Site-wide, target's nearest
|
|
// newer published post is d (20 h ago) and its nearest older one g (70).
|
|
return f
|
|
}
|
|
|
|
type showAnswer struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
|
|
func showPost(t *testing.T, env *adminEnv, slug string) showAnswer {
|
|
t.Helper()
|
|
rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts/"+slug, "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("show %s = %d: %s", slug, rec.Code, rec.Body.String())
|
|
}
|
|
var out showAnswer
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func relatedSlugs(t *testing.T, a showAnswer) []string {
|
|
t.Helper()
|
|
var rows []struct {
|
|
Slug string `json:"slug"`
|
|
}
|
|
if err := json.Unmarshal(a.Data["related_posts"], &rows); err != nil {
|
|
t.Fatalf("related_posts: %v", err)
|
|
}
|
|
out := make([]string, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, r.Slug)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func siblingSlug(t *testing.T, a showAnswer, key string) string {
|
|
t.Helper()
|
|
raw, ok := a.Data[key]
|
|
if !ok {
|
|
t.Fatalf("answer lacks %s", key)
|
|
}
|
|
if string(raw) == "null" {
|
|
return ""
|
|
}
|
|
var s struct {
|
|
ID uint `json:"id"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
t.Fatalf("%s: %v", key, err)
|
|
}
|
|
if s.ID == 0 || s.Title == "" {
|
|
t.Fatalf("%s lacks id or title: %s", key, raw)
|
|
}
|
|
return s.Slug
|
|
}
|
|
|
|
func TestRelatedDefaultUnchanged(t *testing.T) {
|
|
for name, env := range map[string]*adminEnv{
|
|
"no config": newPostsEnv(t),
|
|
"category mode": newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=category", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT=6"),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
plantRelated(t, env)
|
|
a := showPost(t, env, "target")
|
|
// Today's rule: posts sharing a category, newest first, three,
|
|
// whatever their tags; drafts and future posts left out for a
|
|
// visitor.
|
|
if got, want := relatedSlugs(t, a), []string{"d", "b", "a"}; !slices.Equal(got, want) {
|
|
t.Fatalf("related_posts = %v, want %v", got, want)
|
|
}
|
|
for _, key := range []string{"previous_in_category", "next_in_category"} {
|
|
if _, ok := a.Data[key]; ok {
|
|
t.Fatalf("default answer has %s: %s", key, a.Data[key])
|
|
}
|
|
}
|
|
for _, key := range []string{"previous_post", "next_post", "related_posts", "content_h1_to_h2"} {
|
|
if _, ok := a.Data[key]; !ok {
|
|
t.Fatalf("default answer lacks %s", key)
|
|
}
|
|
}
|
|
// The site-wide neighbours keep their meaning.
|
|
if got := siblingSlug(t, a, "previous_post"); got != "d" {
|
|
t.Fatalf("previous_post = %q, want d", got)
|
|
}
|
|
if got := siblingSlug(t, a, "next_post"); got != "g" {
|
|
t.Fatalf("next_post = %q, want g", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRelatedScored(t *testing.T) {
|
|
env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=scored", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT=5")
|
|
plantRelated(t, env)
|
|
a := showPost(t, env, "target")
|
|
// 2 per shared tag, 1 per shared category, ties by newest; e scores 0,
|
|
// the draft and the future post never appear for a visitor (T-33-70).
|
|
if got, want := relatedSlugs(t, a), []string{"a", "c", "b", "g", "d"}; !slices.Equal(got, want) {
|
|
t.Fatalf("related_posts = %v, want %v", got, want)
|
|
}
|
|
// Neighbours in the first category (Alpha), with the same direction as
|
|
// previous_post and next_post: previous is the next newer published
|
|
// post, next the next older one.
|
|
if got := siblingSlug(t, a, "previous_in_category"); got != "" {
|
|
t.Fatalf("previous_in_category = %q, want none (no newer published post in Alpha)", got)
|
|
}
|
|
if got := siblingSlug(t, a, "next_in_category"); got != "b" {
|
|
t.Fatalf("next_in_category = %q, want b", got)
|
|
}
|
|
// The site-wide neighbours are unchanged in scored mode.
|
|
if got := siblingSlug(t, a, "previous_post"); got != "d" {
|
|
t.Fatalf("previous_post = %q, want d", got)
|
|
}
|
|
b := showPost(t, env, "b")
|
|
if got := siblingSlug(t, b, "previous_in_category"); got != "target" {
|
|
t.Fatalf("b previous_in_category = %q, want target", got)
|
|
}
|
|
if got := siblingSlug(t, b, "next_in_category"); got != "a" {
|
|
t.Fatalf("b next_in_category = %q, want a", got)
|
|
}
|
|
for _, r := range relatedSlugs(t, b) {
|
|
if r == "draft" || r == "later" || r == "b" {
|
|
t.Fatalf("b related_posts holds %q: %v", r, relatedSlugs(t, b))
|
|
}
|
|
}
|
|
// A post without tags or categories in common with anything has none.
|
|
if got := relatedSlugs(t, showPost(t, env, "e")); len(got) != 0 {
|
|
t.Fatalf("e related_posts = %v, want none", got)
|
|
}
|
|
if got := siblingSlug(t, showPost(t, env, "c"), "next_in_category"); got != "" {
|
|
t.Fatalf("c (no category) next_in_category = %q, want none", got)
|
|
}
|
|
}
|
|
|
|
func TestRelatedScoredCountClamped(t *testing.T) {
|
|
for count, want := range map[string][]string{
|
|
"10": {"a", "c", "b", "g", "d", "h"},
|
|
"1": {"a", "c", "b"},
|
|
"x": {"a", "c", "b"},
|
|
} {
|
|
t.Run(count, func(t *testing.T) {
|
|
env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=scored", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT="+count)
|
|
plantRelated(t, env)
|
|
if got := relatedSlugs(t, showPost(t, env, "target")); !slices.Equal(got, want) {
|
|
t.Fatalf("related_count %s: related_posts = %v, want %v", count, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// jpegOf is a JPEG picture of w by h.
|
|
func jpegOf(t *testing.T, w, h int) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
img.Set(x, y, color.RGBA{uint8(x), uint8(y), 90, 255})
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// bucketKeys lists every key of the uploads bucket.
|
|
func bucketKeys(t *testing.T, env *adminEnv) []string {
|
|
t.Helper()
|
|
bucket, ok := env.app.Lookup[*blob.Bucket]()
|
|
if !ok {
|
|
t.Fatal("no uploads bucket")
|
|
}
|
|
var out []string
|
|
iter := bucket.List(nil)
|
|
for {
|
|
obj, err := iter.Next(t.Context())
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out = append(out, obj.Key)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
// queuedVariantJobs counts the image variants jobs on the queue.
|
|
func queuedVariantJobs(t *testing.T, env *adminEnv) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := env.gdb.Raw(`SELECT COUNT(*) FROM river_job WHERE kind = ?`, classes.ImageVariantsKind).Scan(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// runVariantsJob runs the Journal's image variants job.
|
|
func runVariantsJob(t *testing.T, env *adminEnv, ids ...uint) {
|
|
t.Helper()
|
|
for _, p := range env.plugins {
|
|
if jp, ok := p.(*journal.Plugin); ok {
|
|
for _, j := range jp.Jobs() {
|
|
if info, ok := conga.Describe(j); ok && info.Kind == classes.ImageVariantsKind {
|
|
if err := j.Work(t.Context(), classes.ImageVariantsArgs{FileIDs: ids}); err != nil {
|
|
t.Fatalf("image variants job: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
t.Fatal("the Journal registers no image variants job")
|
|
}
|
|
|
|
// TestImageVariants covers the featured image variants (D-85, P33-43): an
|
|
// upload answers before any encoding and queues the job; the job writes
|
|
// the default widths no wider than the original with a WebP copy of each;
|
|
// the post API lists them as featured_images[].variants; a second run
|
|
// writes nothing; an admin save of a post whose image lacks variants
|
|
// queues the job after the commit.
|
|
func TestImageVariants(t *testing.T) {
|
|
if got := classes.ImageWidths(nil); !slices.Equal(got, []int{480, 800, 1200}) {
|
|
t.Fatalf("default widths = %v", got)
|
|
}
|
|
if testShort() {
|
|
t.Skip("Postgres")
|
|
}
|
|
env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS=1200, 480,800,480")
|
|
if got := classes.ImageWidths(env.app.Config); !slices.Equal(got, []int{480, 800, 1200}) {
|
|
t.Fatalf("widths from the environment = %v", got)
|
|
}
|
|
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, controllers.PermissionAccessOtherPosts)
|
|
post := models.Post{Title: "Mushrooms", Slug: "mushrooms", Content: "<p>x</p>", Published: true}
|
|
at := time.Now().UTC().Add(-time.Hour)
|
|
post.PublishedAt = &at
|
|
if err := env.gdb.Create(&post).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
before := queuedVariantJobs(t, env)
|
|
rec := env.callMultipart(t, http.MethodPost, fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", post.ID), token, nil, "file", "cover.jpg", jpegOf(t, 1000, 600))
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("upload = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var up struct {
|
|
Data struct {
|
|
ID uint `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil || up.Data.ID == 0 {
|
|
t.Fatalf("upload body %s: %v", rec.Body.String(), err)
|
|
}
|
|
// The request encoded nothing: one key (the original), no rows, one job.
|
|
if keys := bucketKeys(t, env); len(keys) != 1 {
|
|
t.Fatalf("after the upload the bucket holds %v, want only the original", keys)
|
|
}
|
|
var rows int64
|
|
env.gdb.Model(&models.ImageVariant{}).Count(&rows)
|
|
if rows != 0 || queuedVariantJobs(t, env) != before+1 {
|
|
t.Fatalf("after the upload: %d variant rows, %d jobs (was %d)", rows, queuedVariantJobs(t, env), before)
|
|
}
|
|
|
|
runVariantsJob(t, env, up.Data.ID)
|
|
var variants []models.ImageVariant
|
|
if err := env.gdb.Where("file_id = ?", up.Data.ID).Order("width ASC").Find(&variants).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(variants) != 2 || variants[0].Width != 480 || variants[0].Height != 288 || variants[1].Width != 800 || variants[1].Height != 480 {
|
|
t.Fatalf("variants = %+v, want 480x288 and 800x480 (1200 is wider than the original)", variants)
|
|
}
|
|
bucket, _ := env.app.Lookup[*blob.Bucket]()
|
|
for _, v := range variants {
|
|
raw, err := bucket.ReadAll(t.Context(), v.WebPKey)
|
|
if err != nil || len(raw) < 12 || string(raw[0:4]) != "RIFF" || string(raw[8:12]) != "WEBP" {
|
|
t.Fatalf("webp %s: err=%v", v.WebPKey, err)
|
|
}
|
|
if ok, err := bucket.Exists(t.Context(), v.Key); err != nil || !ok || !strings.HasSuffix(v.WebPKey, ".webp") || v.SourceWidth != 1000 || v.SourceHeight != 600 {
|
|
t.Fatalf("variant %+v: thumbnail exists %v err=%v", v, ok, err)
|
|
}
|
|
}
|
|
keys := bucketKeys(t, env)
|
|
|
|
show := showPost(t, env, "mushrooms")
|
|
var images []struct {
|
|
ID uint `json:"id"`
|
|
Variants []struct {
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
URL string `json:"url"`
|
|
WebPURL string `json:"webp_url"`
|
|
SourceWidth int `json:"source_width"`
|
|
SourceHeight int `json:"source_height"`
|
|
} `json:"variants"`
|
|
}
|
|
if err := json.Unmarshal(show.Data["featured_images"], &images); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(images) != 1 || len(images[0].Variants) != 2 {
|
|
t.Fatalf("featured_images = %s", show.Data["featured_images"])
|
|
}
|
|
v := images[0].Variants[1]
|
|
if v.Width != 800 || v.Height != 480 || !strings.HasSuffix(v.URL, "_800_480_0_0_exact.jpg") || !strings.HasSuffix(v.WebPURL, "_800_480_0_0_exact.webp") || v.SourceWidth != 1000 {
|
|
t.Fatalf("variant answer = %+v", v)
|
|
}
|
|
|
|
// A second run writes nothing.
|
|
runVariantsJob(t, env, up.Data.ID)
|
|
if again := bucketKeys(t, env); !slices.Equal(again, keys) {
|
|
t.Fatalf("second run changed the bucket: %v -> %v", keys, again)
|
|
}
|
|
env.gdb.Model(&models.ImageVariant{}).Count(&rows)
|
|
if rows != 2 {
|
|
t.Fatalf("variant rows after the second run = %d", rows)
|
|
}
|
|
|
|
// The admin save of a post whose image has no variants queues the job.
|
|
other := models.Post{Title: "Other", Slug: "other", Content: "<p>y</p>"}
|
|
if err := env.gdb.Create(&other).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := bucket.WriteAll(t.Context(), attach.BlobKey("abc123def456ghi789jkl0.jpg"), jpegOf(t, 600, 400), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file := attach.File{DiskName: "abc123def456ghi789jkl0.jpg", FileName: "b.jpg", ContentType: "image/jpeg", Field: "featured_images", AttachmentType: other.MorphName(), AttachmentID: strconv.FormatUint(uint64(other.ID), 10)}
|
|
if err := env.gdb.Create(&file).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
editor := token
|
|
queued := queuedVariantJobs(t, env)
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, other.ID)), editor, map[string]any{
|
|
"title": map[string]any{"en": "Other"}, "slug": map[string]any{"en": "other"}, "content": map[string]any{"en": "<p>y</p>"},
|
|
})
|
|
if got := queuedVariantJobs(t, env); got != queued+1 {
|
|
t.Fatalf("admin save queued %d jobs, want 1", got-queued)
|
|
}
|
|
// Saving the first post again queues nothing: its image has every width
|
|
// no wider than the original.
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, post.ID)), editor, map[string]any{
|
|
"title": map[string]any{"en": "Mushrooms"}, "slug": map[string]any{"en": "mushrooms"}, "content": map[string]any{"en": "<p>x</p>"},
|
|
})
|
|
if got := queuedVariantJobs(t, env); got != queued+1 {
|
|
t.Fatalf("saving a post whose variants exist queued a job (%d)", got-queued)
|
|
}
|
|
|
|
// Deleting the image deletes its variant rows and blobs.
|
|
env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", post.ID, up.Data.ID), token, nil)
|
|
env.gdb.Model(&models.ImageVariant{}).Where("file_id = ?", up.Data.ID).Count(&rows)
|
|
if rows != 0 {
|
|
t.Fatalf("variant rows after the delete = %d", rows)
|
|
}
|
|
for _, k := range bucketKeys(t, env) {
|
|
if strings.Contains(k, fmt.Sprintf("thumb_%d_", up.Data.ID)) {
|
|
t.Fatalf("a variant blob survived the delete: %s", k)
|
|
}
|
|
}
|
|
}
|