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/console" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/summercms/modules/bonfire" "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: "
x
", 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: "y
"} 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": "y
"}, }) 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": "x
"}, }) 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) } } } // TestImageVariantsTallOriginal pins L1 CR-01 (D-85): a width whose // variant would be taller than the thumbnailer's 4096 px edge limit is // skipped and counted, and the job and journal:image-variants go on with // the next widths and images instead of failing on the tall original. func TestImageVariantsTallOriginal(t *testing.T) { if testShort() { t.Skip("Postgres") } env := newPostsEnv(t) bucket, _ := env.app.Lookup[*blob.Bucket]() post := models.Post{Title: "Infographic", Slug: "infographic", Content: "x
", Published: true} if err := env.gdb.Create(&post).Error; err != nil { t.Fatal(err) } upload := func(disk string, w, h int) attach.File { t.Helper() if err := bucket.WriteAll(t.Context(), attach.BlobKey(disk), jpegOf(t, w, h), nil); err != nil { t.Fatal(err) } f := attach.File{DiskName: disk, FileName: disk, ContentType: "image/jpeg", Field: classes.FeaturedImagesField, AttachmentType: post.MorphName(), AttachmentID: strconv.FormatUint(uint64(post.ID), 10)} if err := env.gdb.Create(&f).Error; err != nil { t.Fatal(err) } return f } tall := upload("tall23def456ghi789jkl0.jpg", 1000, 9000) wide := upload("wide23def456ghi789jkl0.jpg", 900, 600) res, err := classes.GenerateImageVariants(t.Context(), env.gdb, bucket, classes.ImageWidths(env.app.Config), []uint{tall.ID, wide.ID}) if err != nil { t.Fatalf("the job failed on the tall original: %v", err) } if res.Skipped < 3 { t.Fatalf("result = %+v, want the tall original's three widths skipped (and the normal one's 1200)", res) } var tallRows, wideRows []models.ImageVariant env.gdb.Where("file_id = ?", tall.ID).Find(&tallRows) env.gdb.Where("file_id = ?", wide.ID).Order("width ASC").Find(&wideRows) for _, v := range tallRows { if v.Height > classes.MaxImageWidth { t.Fatalf("a variant above the edge limit: %+v", v) } } if len(wideRows) != 2 || wideRows[0].Width != 480 || wideRows[1].Width != 800 { t.Fatalf("variants of the image after the tall one = %+v", wideRows) } var buf strings.Builder root, err := bonfire.NewRootIO("summer", []bonfire.Command{console.ImageVariants(env.app)}, strings.NewReader(""), &buf, &buf) if err != nil { t.Fatal(err) } if code := bonfire.Execute(t.Context(), root, []string{"journal:image-variants", "--all"}); code != 0 { t.Fatalf("journal:image-variants --all: %d\n%s", code, buf.String()) } }