Files
sm-journal-plugin/related_media_test.go
Jakub Zych ee3cb263dc feat: opt-in scored related posts and category neighbours
- golem15.journal.related_mode (category | scored, default category) and
  golem15.journal.related_count (3 to 6, default 3, clamped) in
  config/config.yaml; classes.ReadRelatedSettings reads them
- the default mode answers GET /posts/{slug} exactly as before
- scored: related posts ranked by 2 points per shared tag plus 1 per
  shared category, ties by newest, in one scoring query
  (classes.ScoredRelatedPosts), published only for visitors; the answer
  adds previous_in_category and next_in_category, the neighbours by
  publish time within the post's first category by name
  (classes.CategoryNeighbour), in the direction of previous_post and
  next_post
- TestRelatedDefaultUnchanged, TestRelatedScored (a draft and a future
  post sharing every tag never shown) and TestRelatedScoredCountClamped
2026-11-04 14:14:09 +01:00

235 lines
8.2 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 (
"encoding/json"
"net/http"
"slices"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/models"
)
// 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)
}
})
}
}