package journal_test import ( "encoding/json" "net/http" "net/http/httptest" "reflect" "sort" "strconv" "strings" "testing" "time" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" ) // flattenCategoryRows walks the categories tree (data plus children) into one // slice of rows. func flattenCategoryRows(rows []map[string]any) []map[string]any { var out []map[string]any for _, row := range rows { out = append(out, row) if kids, ok := row["children"].([]any); ok { nested := make([]map[string]any, 0, len(kids)) for _, k := range kids { if m, ok := k.(map[string]any); ok { nested = append(nested, m) } } out = append(out, flattenCategoryRows(nested)...) } } return out } func categoryRowsBySlug(t *testing.T, env *adminEnv, path string) map[string]map[string]any { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, path, "", nil) var body struct { Data []map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("categories json: %s err=%v", rec.Body.String(), err) } out := map[string]map[string]any{} for _, row := range flattenCategoryRows(body.Data) { slug, _ := row["slug"].(string) out[slug] = row } return out } func assertNullContent(t *testing.T, rows map[string]map[string]any, slug string) { t.Helper() row, ok := rows[slug] if !ok { t.Fatalf("category %s not listed", slug) } v, present := row["content"] if !present || v != nil { t.Fatalf("%s content = %#v (present %v), want JSON null", slug, v, present) } } func TestJournalCategoryContentAPI(t *testing.T) { env := newPostsEnv(t) html := "

Acme news

\n

Pillar copy

" left, right, depth := 3, 4, 0 news := models.Category{Name: "News", Slug: "news", Content: &html, NestLeft: &left, NestRight: &right, NestDepth: &depth} if err := env.gdb.Create(&news).Error; err != nil { t.Fatal(err) } quiet := models.Category{Name: "Quiet", Slug: "quiet"} if err := env.gdb.Create(&quiet).Error; err != nil { t.Fatal(err) } past := time.Now().UTC().Add(-time.Hour) post := models.Post{Title: "Listed", Slug: "listed-content", Content: "x", Published: true, PublishedAt: &past} if err := env.gdb.Create(&post).Error; err != nil { t.Fatal(err) } if err := env.gdb.Create(&models.PostCategory{PostID: post.ID, CategoryID: news.ID}).Error; err != nil { t.Fatal(err) } all := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1") if got, ok := all["news"]["content"].(string); !ok || got != html { t.Fatalf("news content = %#v, want %q", all["news"]["content"], html) } assertNullContent(t, all, "quiet") assertNullContent(t, all, "uncategorized") listed := categoryRowsBySlug(t, env, "/_journal/api/v1/categories") if got, ok := listed["news"]["content"].(string); !ok || got != html { t.Fatalf("news content without include_empty = %#v, want %q", listed["news"]["content"], html) } if _, ok := listed["quiet"]; ok { t.Fatal("empty category quiet listed without include_empty") } rec := env.expect(t, http.StatusOK, http.MethodGet, "/_journal/api/v1/posts", "", nil) var posts struct { Data []map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &posts); err != nil { t.Fatalf("posts json: %s err=%v", rec.Body.String(), err) } var found bool for _, p := range posts.Data { if p["slug"] != "listed-content" { continue } found = true cats, ok := p["categories"].([]any) if !ok || len(cats) == 0 { t.Fatalf("post categories = %#v", p["categories"]) } first, ok := cats[0].(map[string]any) if !ok { t.Fatalf("post category entry = %#v", cats[0]) } keys := make([]string, 0, len(first)) for k := range first { keys = append(keys, k) } sort.Strings(keys) if want := []string{"id", "name", "slug"}; !reflect.DeepEqual(keys, want) { t.Fatalf("post category keys = %v, want %v", keys, want) } } if !found { t.Fatalf("post listed-content missing from /posts: %s", rec.Body.String()) } } const adminCategoriesPath = "/golem15/journal/categories" func TestCategoriesAdminContent(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, controllers.PermissionAccessCategories) schema := adminDecode(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminCategoriesPath+"/schema/form"), token, nil)) var form struct { Fields []struct { Name string `json:"name"` Type string `json:"type"` Size string `json:"size"` } `json:"fields"` } if err := json.Unmarshal(schema.Data, &form); err != nil { t.Fatalf("form schema %s: %v", schema.Data, err) } contentAt := -1 for i, f := range form.Fields { if f.Name != "content" { continue } contentAt = i if f.Type != "blogeditor" || f.Size != "huge" { t.Fatalf("content field = %+v, want blogeditor size huge", f) } } if contentAt < 1 || form.Fields[contentAt-1].Name != "description" { t.Fatalf("content field must follow description: %s", schema.Data) } safe := "

Acme

\n

Pillar copy

" name := map[string]any{"en": "Acme news", "pl": "WiadomoĊ›ci Acme"} slug := map[string]any{"en": "acme-news", "pl": "wiadomosci-acme"} rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{ "name": name, "slug": slug, "content": safe, }) if notices := adminNotices(t, rec); notices != nil { t.Fatalf("safe create carries meta.notices: %s", rec.Body.String()) } var created struct { ID uint `json:"id"` } if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 { t.Fatalf("create data %s: %v", rec.Body.String(), err) } storedContent := func(t *testing.T) *string { t.Helper() var cat models.Category if err := env.gdb.First(&cat, created.ID).Error; err != nil { t.Fatal(err) } return cat.Content } if got := storedContent(t); got == nil || *got != safe { t.Fatalf("stored content = %#v, want %q", got, safe) } rows := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1") if got, ok := rows["acme-news"]["content"].(string); !ok || got != safe { t.Fatalf("API content = %#v, want %q", rows["acme-news"]["content"], safe) } // assertContentStripped checks a save the blogeditor Decode stripped // (D-10 amended, D-19): the status, and one meta.notices entry for // content whose message names what was removed. assertContentStripped := func(t *testing.T, rec *httptest.ResponseRecorder, status int, removed string) { t.Helper() if rec.Code != status { t.Fatalf("status = %d, want %d: %s", rec.Code, status, rec.Body.String()) } notices := adminNotices(t, rec) if len(notices) != 1 || notices[0].Field != "content" || !strings.Contains(notices[0].Message, removed) { t.Fatalf("meta.notices = %#v, want one content notice naming %s", notices, removed) } } t.Run("unsafe create stripped", func(t *testing.T) { rec := env.call(t, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{ "name": map[string]any{"en": "Unsafe", "pl": "Niebezpieczna"}, "slug": map[string]any{"en": "unsafe-acme", "pl": "niebezpieczna-acme"}, "content": "

x

", }) assertContentStripped(t, rec, http.StatusCreated, "onclick") var cat models.Category if err := env.gdb.Where("slug = ?", "unsafe-acme").Take(&cat).Error; err != nil { t.Fatalf("unsafe-acme row: %v", err) } if cat.Content == nil || *cat.Content != "

x

" { t.Fatalf("stored content = %#v, want

x

", cat.Content) } }) itemPath := adminAPI(adminCategoriesPath + "/" + strconv.FormatUint(uint64(created.ID), 10)) t.Run("unsafe update stripped", func(t *testing.T) { rec := env.call(t, http.MethodPut, itemPath, token, map[string]any{ "name": name, "slug": slug, "content": "x", }) assertContentStripped(t, rec, http.StatusOK, "removed: a") if got := storedContent(t); got == nil || *got != "x" { t.Fatalf("stored content after stripped update = %#v, want x", got) } }) t.Run("blank update stores null", func(t *testing.T) { rec := env.call(t, http.MethodPut, itemPath, token, map[string]any{ "name": name, "slug": slug, "content": " ", }) if rec.Code < 200 || rec.Code > 299 { t.Fatalf("blank update = %d, want 2xx: %s", rec.Code, rec.Body.String()) } if got := storedContent(t); got != nil { t.Fatalf("stored content = %q, want NULL", *got) } rows := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1") assertNullContent(t, rows, "acme-news") }) t.Run("posts-only token forbidden", func(t *testing.T) { postsOnly := env.admin(t, controllers.PermissionAccessPosts) rec := env.call(t, http.MethodPost, adminAPI(adminCategoriesPath), postsOnly, map[string]any{ "name": map[string]any{"en": "Denied", "pl": "Odmowa"}, "slug": map[string]any{"en": "denied-acme", "pl": "odmowa-acme"}, "content": safe, }) if rec.Code != http.StatusForbidden { t.Fatalf("posts-only create = %d, want 403: %s", rec.Code, rec.Body.String()) } }) }