test(17-06): FAQ plugin unit tests

- TestFaqContentDecode, TestFaqModel and TestFaqSerialize (Docker-free):
  content as list, JSON text or row-keyed object, required export
  fields, the model contract, output sanitizing, null description,
  question text and the slug shape
- TestFaqAPI and TestFaqImport (Postgres): active duplicate behind an
  inactive lower id, default locale per is_default, a disabled default,
  malformed slugs, keyed content order, malformed rows aborting before
  any write
This commit is contained in:
2026-10-12 19:42:42 +02:00
parent 1585f06b3c
commit 919cf06eca
4 changed files with 304 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
package console
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
)
// TestFaqContentDecode pins the Docker-free decoding of a Winter FAQ export
// row: content as a JSON list, as JSON text, as an object keyed by row
// number (in numeric order), or null; the required name, slug and locale
// code; PHP-shaped flags, ids and timestamps; and the refusals.
func TestFaqContentDecode(t *testing.T) {
for _, tc := range []struct {
raw string
want string
}{
{`[{"question":"Q1","answer":"<p>A1</p>"},{"question":"Q2","answer":null}]`, `[map[answer:<p>A1</p> question:Q1] map[answer:<nil> question:Q2]]`},
{`"[{\"question\":\"Q\",\"answer\":\"A\"}]"`, `[map[answer:A question:Q]]`},
{`{"10":{"question":"ten"},"2":{"question":"two"},"1":{"question":"one"}}`, `[map[question:one] map[question:two] map[question:ten]]`},
{`[]`, `[]`},
{`null`, `[]`},
{`"null"`, `[]`},
{``, `[]`},
} {
got, err := decodeContent(json.RawMessage(tc.raw))
if err != nil {
t.Fatalf("decodeContent(%s): %v", tc.raw, err)
}
if s := fmt.Sprint(got); s != tc.want {
t.Fatalf("decodeContent(%s) = %s, want %s", tc.raw, s, tc.want)
}
}
for _, raw := range []string{`"x"`, `5`, `[1,2]`, `{"a":1}`, `true`} {
if _, err := decodeContent(json.RawMessage(raw)); err == nil {
t.Fatalf("decodeContent(%s) accepted", raw)
}
}
row := func(raw string) map[string]json.RawMessage {
t.Helper()
var out map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatal(err)
}
return out
}
faq, code, err := decodeFAQ(row(`{"id": "12", "name": "Shipping", "slug": "shipping", "locale_code": "pl", "is_active": "1", "description": 5, "content": [{"question":"Q","answer":"A"}], "created_at": "2024-03-01 10:00:00.000000", "updated_at": null}`))
if err != nil {
t.Fatal(err)
}
if faq.ID != 12 || faq.Name != "Shipping" || faq.Slug != "shipping" || code != "pl" || !faq.IsActive || faq.Description == nil || *faq.Description != "5" ||
len(faq.Items()) != 1 || !faq.Content.Valid || faq.CreatedAt == nil || !faq.CreatedAt.Equal(time.Date(2024, 3, 1, 10, 0, 0, 0, time.UTC)) || faq.UpdatedAt != nil {
t.Fatalf("decoded = %+v code=%s", faq, code)
}
if faq, _, err := decodeFAQ(row(`{"id": 1, "name": "A", "slug": "a", "locale_code": "en", "is_active": 0, "content": null}`)); err != nil || faq.IsActive || faq.Content.Valid || faq.Description != nil {
t.Fatalf("null content row = %+v %v", faq, err)
}
for raw, want := range map[string]string{
`{"id": 0, "name": "A", "slug": "a", "locale_code": "en"}`: "id",
`{"name": "A", "slug": "a", "locale_code": "en"}`: "id",
`{"id": 1, "slug": "a", "locale_code": "en"}`: "name is missing",
`{"id": 1, "name": "A", "slug": " ", "locale_code": "en"}`: "slug is missing",
`{"id": 1, "name": "A", "slug": "a"}`: "locale_code is missing",
`{"id": 1, "name": "A", "slug": "a", "locale_code": "en", "content": "oops"}`: "content",
`{"id": 1, "name": "A", "slug": "a", "locale_code": "en", "is_active": [1]}`: "is_active",
`{"id": 1, "name": "A", "slug": "a", "locale_code": "en", "created_at": "x"}`: "created_at",
`{"id": 1, "name": "A", "slug": "a", "locale_code": "en", "description": {}}`: "description",
} {
if _, _, err := decodeFAQ(row(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("decodeFAQ(%s) = %v, want %q", raw, err, want)
}
}
}

View File

@@ -0,0 +1,55 @@
package api
import (
"bytes"
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/sm-faq-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// TestFaqSerialize pins the D-17 response object without a database: the
// description and every answer pass through the sanitizer on output, clean
// HTML byte for byte; a null description stays null; questions are plain
// text (numbers formatted, null empty); content is never null; and the
// slug pattern the handler accepts.
func TestFaqSerialize(t *testing.T) {
desc := `<p>About.</p><img src="/x.png" onerror="alert(1)">`
faq := models.FAQ{Name: "Shipping", Slug: "shipping", Description: &desc, Content: lagoon.Jsonable[models.Content]{Valid: true, Data: models.Content{
{"question": "How long?", "answer": "<p>Two <strong>days</strong> &amp; more.</p>"},
{"question": 42.0, "answer": `<p onclick="x()">Here</p><script>alert(1)</script>`},
{"question": nil, "answer": nil},
}}}
out := serialize(faq, "en")
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(out); err != nil {
t.Fatal(err)
}
raw := strings.TrimSuffix(buf.String(), "\n")
want := `{"name":"Shipping","slug":"shipping","locale":"en","description":"<p>About.</p><img src=\"/x.png\">","content":[` +
`{"question":"How long?","answer":"<p>Two <strong>days</strong> &amp; more.</p>"},` +
`{"question":"42","answer":"<p>Here</p>"},` +
`{"question":"","answer":""}]}`
if raw != want {
t.Fatalf("serialized:\n%s\nwant:\n%s", raw, want)
}
if strings.Contains(raw, "onerror") || strings.Contains(raw, "onclick") || strings.Contains(raw, "<script") {
t.Fatal("unsafe HTML served")
}
bare := serialize(models.FAQ{Name: "Empty", Slug: "empty"}, "pl")
if bare.Description != nil || bare.Content == nil || len(bare.Content) != 0 || bare.Locale != "pl" {
t.Fatalf("bare = %+v", bare)
}
for slug, ok := range map[string]bool{
"shipping": true, "a": true, "faq-2": true, "2024-faq": true,
"Shipping": false, "-faq": false, "faq_2": false, "faq.json": false, "zażółć": false, "": false, "a b": false,
} {
if slugPattern.MatchString(slug) != ok {
t.Errorf("slug %q accepted = %v", slug, !ok)
}
}
}

138
faq_api_test.go Normal file
View File

@@ -0,0 +1,138 @@
package faq_test
import (
"encoding/json"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/sm-faq-plugin/console"
"git.golem15.com/golem15/sm-faq-plugin/models"
)
// TestFaqAPI pins the D-17 rules of GET /_faq/api/v1/faqs/{slug} beyond the
// smoke test: an inactive lower id does not hide an active duplicate, the
// default locale follows is_default and a disabled default answers 404,
// questions are text (numbers formatted, null empty), empty content is [],
// the answer is JSON with no CORS headers of its own, and every malformed
// slug is the JSON 404.
func TestFaqAPI(t *testing.T) {
e := bootFAQ(t)
en, pl := e.localeID(t, "en"), e.localeID(t, "pl")
e.insertFAQ(t, "Old", "help", en, false, nil, `[{"question":"old","answer":"old"}]`)
e.insertFAQ(t, "Help", "help", en, true, nil, `[{"question":42,"answer":null},{"question":null,"answer":"<p>ok</p>"}]`)
e.insertFAQ(t, "Pomoc", "help", pl, true, nil, `[]`)
got := e.getFAQ(t, "/_faq/api/v1/faqs/help")
if got.Data.Name != "Help" || got.Data.Locale != "en" || len(got.Data.Content) != 2 ||
got.Data.Content[0].Question != "42" || got.Data.Content[0].Answer != "" || got.Data.Content[1].Question != "" || got.Data.Content[1].Answer != "<p>ok</p>" {
t.Fatalf("en = %+v", got.Data)
}
rec := e.publicGet(t, "/_faq/api/v1/faqs/help?locale=pl&x=1", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"content":[]`) || !strings.Contains(rec.Body.String(), `"name":"Pomoc"`) {
t.Fatalf("pl = %d %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") || rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("headers = %v", rec.Header())
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil || len(envelope) != 1 || envelope["data"] == nil {
t.Fatalf("envelope = %s", rec.Body.String())
}
// The default locale follows is_default.
if err := e.gdb.Exec(`UPDATE golem15_translate_locales SET is_default = (code = 'pl')`).Error; err != nil {
t.Fatal(err)
}
if got := e.getFAQ(t, "/_faq/api/v1/faqs/help"); got.Data.Name != "Pomoc" || got.Data.Locale != "pl" {
t.Fatalf("pl default = %+v", got.Data)
}
// A disabled default locale serves nothing without ?locale; en still answers.
if err := e.gdb.Exec(`UPDATE golem15_translate_locales SET is_enabled = (code <> 'pl')`).Error; err != nil {
t.Fatal(err)
}
for path, status := range map[string]int{
"/_faq/api/v1/faqs/help": http.StatusNotFound,
"/_faq/api/v1/faqs/help?locale=pl": http.StatusNotFound,
"/_faq/api/v1/faqs/help?locale=en": http.StatusOK,
"/_faq/api/v1/faqs/HELP": http.StatusNotFound,
"/_faq/api/v1/faqs/-help": http.StatusNotFound,
"/_faq/api/v1/faqs/help_2": http.StatusNotFound,
"/_faq/api/v1/faqs/help.json": http.StatusNotFound,
"/_faq/api/v1/faqs/pomo%C4%87": http.StatusNotFound,
"/_faq/api/v1/faqs/%27or%271": http.StatusNotFound,
} {
rec := e.publicGet(t, path, "")
if rec.Code != status {
t.Errorf("GET %s: %d %s, want %d", path, rec.Code, rec.Body.String(), status)
continue
}
if status == http.StatusNotFound && rec.Body.String() != `{"error":"FAQ not found"}` {
t.Errorf("GET %s body = %s", path, rec.Body.String())
}
}
}
// TestFaqImport pins the faq:import-winter edge cases beyond the smoke
// test: content keyed by row number imported in order, content as JSON
// text, a malformed row aborting before any write, and the option and file
// refusals; the dry run reports each FAQ with its question count.
func TestFaqImport(t *testing.T) {
e := bootFAQ(t)
ctx := t.Context()
dir := t.TempDir()
pl := e.localeID(t, "pl")
keyed := writeFile(t, dir, "keyed.jsonl",
`{"id": 3, "name": "Pomoc", "slug": "pomoc", "locale_code": "pl", "is_active": "1", "content": {"2":{"question":"drugie","answer":"b"},"1":{"question":"pierwsze","answer":"a"},"10":{"question":"dziesiąte","answer":"c"}}}`+"\n"+
`{"id": 5, "name": "Text", "slug": "text", "locale_code": "pl", "is_active": 1, "content": "[{\"question\":\"q\",\"answer\":\"a\"}]"}`+"\n")
out, buf := testOutput()
report, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: keyed, DryRun: true}, out)
if err != nil || report.FAQs != 2 || !strings.Contains(buf.String(), "FAQ 3 'pomoc' (pl): 3 questions") || !strings.Contains(buf.String(), "FAQ 5 'text' (pl): 1 questions") {
t.Fatalf("dry run: %+v %v\n%s", report, err, buf.String())
}
if n := e.countFAQs(t); n != 0 {
t.Fatalf("the dry run wrote %d rows", n)
}
out, buf = testOutput()
if _, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: keyed}, out); err != nil {
t.Fatalf("import: %v\n%s", err, buf.String())
}
var rows []models.FAQ
if err := e.gdb.Order("id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].LocaleID != pl || !rows[0].IsActive {
t.Fatalf("rows = %+v", rows)
}
var questions []string
for _, item := range rows[0].Items() {
questions = append(questions, item["question"].(string))
}
if strings.Join(questions, ",") != "pierwsze,drugie,dziesiąte" {
t.Fatalf("keyed content order = %v", questions)
}
if items := rows[1].Items(); len(items) != 1 || items[0]["answer"] != "a" {
t.Fatalf("text content = %v", items)
}
for name, tc := range map[string]struct {
opts console.WinterOptions
want string
}{
"no file": {console.WinterOptions{}, "give --file"},
"missing file": {console.WinterOptions{File: dir + "/missing.jsonl", Force: true}, "missing.jsonl"},
"bad row": {console.WinterOptions{File: writeFile(t, dir, "bad.jsonl",
`{"id": 7, "name": "Good", "slug": "good", "locale_code": "pl", "content": null}`+"\n"+
`{"id": 8, "slug": "nameless", "locale_code": "pl", "content": null}`+"\n"), Force: true}, "row 2"},
"bad content": {console.WinterOptions{File: writeFile(t, dir, "badcontent.jsonl", `{"id": 9, "name": "X", "slug": "x", "locale_code": "pl", "content": 5}`+"\n"), Force: true}, "content"},
} {
out, buf := testOutput()
if _, err := console.ImportWinterFile(ctx, e.gdb, tc.opts, out); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: err = %v, want %q\n%s", name, err, tc.want, buf.String())
}
}
if n := e.countFAQs(t); n != 2 {
t.Fatalf("refused imports changed the table: %d rows", n)
}
}

35
models/faq_test.go Normal file
View File

@@ -0,0 +1,35 @@
package models_test
import (
"slices"
"testing"
"git.golem15.com/golem15/sm-faq-plugin/models"
)
// TestFaqModel pins the FAQ model contract: the PHP class string and table,
// the fillable list (no id or timestamps), the required name and slug, and
// the stored content read back as rows from the JSON column.
func TestFaqModel(t *testing.T) {
var f models.FAQ
if f.TableName() != "golem15_faq_faqs" || f.MorphName() != `Golem15\FAQ\Models\FAQ` {
t.Fatal("table or morph name changed")
}
if fill := f.Fillable(); !slices.Equal(fill, []string{"name", "slug", "locale_id", "is_active", "description", "content"}) {
t.Fatalf("Fillable = %v", fill)
}
if rules := f.Rules(); len(rules) != 2 || rules["name"] != "required" || rules["slug"] != "required" {
t.Fatalf("Rules = %v", rules)
}
if err := f.Content.Scan([]byte(`[{"question":"Q","answer":"<p>A</p>"},{"question":"Q2","answer":3}]`)); err != nil {
t.Fatal(err)
}
items := f.Items()
if len(items) != 2 || items[0]["question"] != "Q" || items[1]["answer"] != float64(3) {
t.Fatalf("items = %v", items)
}
var empty models.FAQ
if err := empty.Content.Scan(nil); err != nil || len(empty.Items()) != 0 {
t.Fatalf("null content = %v %v", empty.Items(), err)
}
}