diff --git a/console/import_winter_test.go b/console/import_winter_test.go new file mode 100644 index 0000000..655e9a4 --- /dev/null +++ b/console/import_winter_test.go @@ -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":"

A1

"},{"question":"Q2","answer":null}]`, `[map[answer:

A1

question:Q1] map[answer: 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) + } + } +} diff --git a/controllers/api/faqs_test.go b/controllers/api/faqs_test.go new file mode 100644 index 0000000..9006605 --- /dev/null +++ b/controllers/api/faqs_test.go @@ -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 := `

About.

` + faq := models.FAQ{Name: "Shipping", Slug: "shipping", Description: &desc, Content: lagoon.Jsonable[models.Content]{Valid: true, Data: models.Content{ + {"question": "How long?", "answer": "

Two days & more.

"}, + {"question": 42.0, "answer": `

Here

`}, + {"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":"

About.

","content":[` + + `{"question":"How long?","answer":"

Two days & more.

"},` + + `{"question":"42","answer":"

Here

"},` + + `{"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, "ok

"}]`) + 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 != "

ok

" { + 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) + } +} diff --git a/models/faq_test.go b/models/faq_test.go new file mode 100644 index 0000000..9cfae7c --- /dev/null +++ b/models/faq_test.go @@ -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":"

A

"},{"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) + } +}