feat(api): list an author's published posts at /authors/{author}/posts

- AuthorPosts reuses the /posts published predicate, per_page clamp, sort allow-list and row serializer
- Bearer tokens are ignored; drafts and scheduled posts never appear
- Both author routes share one resolve-or-respond helper for identical 404/500 paths
- Document query keys, envelope and 404 in README
This commit is contained in:
2026-10-09 13:20:39 +02:00
parent 908ade9c78
commit 57a347c97b
4 changed files with 188 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import (
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
)
@@ -149,3 +150,136 @@ func TestJournalAuthorProfile(t *testing.T) {
t.Fatalf("fresh-author profile = %v", data)
}
}
type authorPostsPage struct {
Data []map[string]any `json:"data"`
Meta struct {
CurrentPage int `json:"current_page"`
LastPage int `json:"last_page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
} `json:"meta"`
}
func (p authorPostsPage) slugs() []string {
out := make([]string, 0, len(p.Data))
for _, row := range p.Data {
slug, _ := row["slug"].(string)
out = append(out, slug)
}
return out
}
func getAuthorPosts(t *testing.T, env *adminEnv, path, token string) authorPostsPage {
t.Helper()
rec := env.call(t, http.MethodGet, path, token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200: %s", path, rec.Code, rec.Body.String())
}
var page authorPostsPage
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
t.Fatalf("GET %s body %s: %v", path, rec.Body.String(), err)
}
if page.Data == nil {
t.Fatalf("GET %s: data is not an array: %s", path, rec.Body.String())
}
return page
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}
func expectMeta(t *testing.T, label string, p authorPostsPage, current, last, perPage int, total int64) {
t.Helper()
m := p.Meta
if m.CurrentPage != current || m.LastPage != last || m.PerPage != perPage || m.Total != total {
t.Fatalf("%s meta = %+v, want {current_page:%d last_page:%d per_page:%d total:%d}", label, m, current, last, perPage, total)
}
}
func TestJournalAuthorPosts(t *testing.T) {
env := newPostsEnv(t)
now := time.Now().UTC()
at := func(d time.Duration) *time.Time { v := now.Add(d); return &v }
janeID := insertBackendUser(t, env, "Jane", "Doe", "jdoe", "jane-doe", false)
insertAuthorPost(t, env, janeID, "a1", true, at(-3*time.Hour))
insertAuthorPost(t, env, janeID, "a2", true, at(-2*time.Hour))
insertAuthorPost(t, env, janeID, "a3", true, at(-time.Hour))
insertAuthorPost(t, env, janeID, "a-draft", false, at(-30*time.Minute))
insertAuthorPost(t, env, janeID, "a-future", true, at(time.Hour))
bobID := insertBackendUser(t, env, "Bob", "Brown", "bob", "", false)
insertAuthorPost(t, env, bobID, "b1", true, at(-90*time.Minute))
carolID := insertBackendUser(t, env, "Carol", "Smith", "carol", "", false)
insertAuthorPost(t, env, carolID, "carol-draft", false, nil)
insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false)
base := authorAPIPrefix + "jane-doe/posts"
page := getAuthorPosts(t, env, base, "")
if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) {
t.Fatalf("default order = %v, want [a3 a2 a1]", got)
}
expectMeta(t, "default", page, 1, 1, 9, 3)
// Each row has the GET /posts row shape.
all := getAuthorPosts(t, env, "/_journal/api/v1/posts", "")
var listRow map[string]any
for _, row := range all.Data {
if row["slug"] == "a3" {
listRow = row
}
}
if listRow == nil {
t.Fatalf("a3 missing from GET /posts: %v", all.slugs())
}
if got, want := sortedKeys(page.Data[0]), sortedKeys(listRow); !slices.Equal(got, want) {
t.Fatalf("author row keys = %v, want /posts row keys %v", got, want)
}
author, _ := page.Data[0]["author"].(map[string]any)
if author == nil || author["login"] != "jdoe" {
t.Fatalf("row author = %v, want login jdoe", page.Data[0]["author"])
}
page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "")
if got := page.slugs(); !slices.Equal(got, []string{"a1"}) {
t.Fatalf("page 2 = %v, want [a1]", got)
}
expectMeta(t, "page 2", page, 2, 2, 2, 3)
page = getAuthorPosts(t, env, base+"?per_page=100", "")
expectMeta(t, "per_page=100", page, 1, 1, 30, 3)
page = getAuthorPosts(t, env, base+"?page=abc", "")
expectMeta(t, "page=abc", page, 1, 1, 9, 3)
page = getAuthorPosts(t, env, base+"?sort=published_at+asc", "")
if got := page.slugs(); !slices.Equal(got, []string{"a1", "a2", "a3"}) {
t.Fatalf("asc order = %v, want [a1 a2 a3]", got)
}
// An editor token is ignored: drafts and scheduled posts stay hidden.
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
page = getAuthorPosts(t, env, base+"?published=0", token)
if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) {
t.Fatalf("editor list = %v, want published only [a3 a2 a1]", got)
}
expectMeta(t, "editor", page, 1, 1, 9, 3)
page = getAuthorPosts(t, env, authorAPIPrefix+"jdoe/posts", "")
expectMeta(t, "login", page, 1, 1, 9, 3)
page = getAuthorPosts(t, env, authorAPIPrefix+"fresh-author/posts", "")
if len(page.Data) != 0 {
t.Fatalf("fresh-author rows = %v, want none", page.slugs())
}
expectMeta(t, "fresh-author", page, 1, 1, 9, 0)
expectAuthorNotFound(t, env, authorAPIPrefix+"nobody/posts")
expectAuthorNotFound(t, env, authorAPIPrefix+"carol/posts")
}