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:
76
console/import_winter_test.go
Normal file
76
console/import_winter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
55
controllers/api/faqs_test.go
Normal file
55
controllers/api/faqs_test.go
Normal 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> & 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> & 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
138
faq_api_test.go
Normal 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
35
models/faq_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user