Files
sm-journal-plugin/journal_api_matrix_test.go
Jakub Zych d09edde080 test(15-04): complete PHPUnit map, migrations, FormatHTML, and search-gate tests
Named JOURNAL-001/002 fillable, FormatHTML unsafe tags, rollback/remigrate, RSS/categories/tags/media, and TestSearchGateOff with zero Typesense HTTP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 19:18:53 +02:00

175 lines
5.4 KiB
Go

package journal_test
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"time"
"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/bouncer"
)
func TestJournalAPIShowNeighbors(t *testing.T) {
env := newPostsEnv(t)
past := time.Now().UTC().Add(-2 * time.Hour)
mid := past.Add(time.Hour)
for i, slug := range []string{"alpha", "beta", "gamma"} {
p := models.Post{
Title: "Post " + slug,
Slug: slug,
Content: "body " + slug,
Published: true,
PublishedAt: ptrTime(past.Add(time.Duration(i) * time.Hour)),
}
if err := env.gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
}
_ = mid
rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts/beta", "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("show beta = %d: %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
data, _ := body["data"].(map[string]any)
if data == nil {
t.Fatalf("missing data: %s", rec.Body.String())
}
for _, key := range []string{"previous_post", "next_post", "related_posts"} {
if _, ok := data[key]; !ok {
t.Fatalf("show missing %s: %s", key, rec.Body.String())
}
}
title, _ := data["title"].(string)
if strings.Contains(title, console.UnpublishedTitlePrefix) {
t.Fatalf("unpublished lock prefix leaked onto JSON title: %q", title)
}
}
func TestJournalPerPageClamp(t *testing.T) {
env := newPostsEnv(t)
past := time.Now().UTC().Add(-time.Hour)
for i := 0; i < 12; i++ {
p := models.Post{
Title: "Row " + strconv.Itoa(i),
Slug: "row-" + strconv.Itoa(i),
Content: "x",
Published: true,
PublishedAt: &past,
}
if err := env.gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
}
nine := env.call(t, http.MethodGet, "/_journal/api/v1/posts?per_page=9", "", nil)
if nine.Code != http.StatusOK {
t.Fatalf("per_page=9 = %d: %s", nine.Code, nine.Body.String())
}
var nineBody struct {
Data []any `json:"data"`
Meta struct {
PerPage int `json:"per_page"`
} `json:"meta"`
}
if err := json.Unmarshal(nine.Body.Bytes(), &nineBody); err != nil {
t.Fatal(err)
}
if nineBody.Meta.PerPage != 9 {
t.Fatalf("per_page=9 meta = %d", nineBody.Meta.PerPage)
}
if len(nineBody.Data) != 9 {
t.Fatalf("per_page=9 data len = %d", len(nineBody.Data))
}
capped := env.call(t, http.MethodGet, "/_journal/api/v1/posts?per_page=30", "", nil)
var capBody struct {
Meta struct {
PerPage int `json:"per_page"`
} `json:"meta"`
}
if err := json.Unmarshal(capped.Body.Bytes(), &capBody); err != nil {
t.Fatal(err)
}
if capBody.Meta.PerPage != 30 {
t.Fatalf("per_page=30 meta = %d, want 30", capBody.Meta.PerPage)
}
}
func TestJournalEditorListDrafts(t *testing.T) {
env := newPostsEnv(t)
tok := env.admin(t, controllers.PermissionAccessPosts)
if err := env.gdb.Create(&models.Post{Title: "Hidden draft", Slug: "hidden-draft", Content: "x", Published: false}).Error; err != nil {
t.Fatal(err)
}
anon := env.call(t, http.MethodGet, "/_journal/api/v1/posts", "", nil)
if strings.Contains(anon.Body.String(), "hidden-draft") {
t.Fatalf("anonymous list leaked draft: %s", anon.Body.String())
}
editor := env.call(t, http.MethodGet, "/_journal/api/v1/posts", tok, nil)
if editor.Code != http.StatusOK {
t.Fatalf("editor list = %d: %s", editor.Code, editor.Body.String())
}
if !strings.Contains(editor.Body.String(), "hidden-draft") {
t.Fatalf("editor Bearer list omitted drafts: %s", editor.Body.String())
}
}
func TestJournalAPIMassAssignRedactor(t *testing.T) {
env := newPostsEnv(t)
tok := env.admin(t, controllers.PermissionAccessPosts)
created := env.call(t, http.MethodPost, "/_journal/api/v1/posts", tok, map[string]any{
"title": "Assign trap",
"content": "body",
"redactor_id": 99,
"user_id": 1,
})
if created.Code != http.StatusCreated {
t.Fatalf("create = %d: %s", created.Code, created.Body.String())
}
var stored models.Post
if err := env.gdb.Where("slug = ?", "assign-trap").Or("title = ?", "Assign trap").First(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.RedactorID != nil {
t.Fatalf("redactor_id persisted from body: %#v", stored.RedactorID)
}
authorID := env.lastUserID(t)
if stored.UserID == nil || *stored.UserID != authorID {
t.Fatalf("user_id = %#v, want stamped principal %d", stored.UserID, authorID)
}
}
func TestPostsAdminForbidden(t *testing.T) {
env := newPostsEnv(t)
noPerm := env.admin(t)
rec := env.call(t, http.MethodGet, adminAPI(adminPostsPath), noPerm, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("admin list without access_posts = %d, want 403: %s", rec.Code, rec.Body.String())
}
}
func TestJournalWriteFrontendAudience(t *testing.T) {
env := newPostsEnv(t)
front, _, err := bouncer.MintAudience(adminJWTSecret, "1", "https://app.example.test/login", time.Hour, bouncer.AudienceUser)
if err != nil {
t.Fatal(err)
}
denied := env.call(t, http.MethodPost, "/_journal/api/v1/posts", front, map[string]any{
"title": "Frontend",
"content": "Should not write",
})
if denied.Code != http.StatusUnauthorized {
t.Fatalf("frontend-audience POST = %d, want 401: %s", denied.Code, denied.Body.String())
}
if got, _ := phpErrorString(t, denied.Body.Bytes()); got != "Authentication required" {
t.Fatalf("frontend-audience error = %q", got)
}
}