- JSON lines import keeping ids, locale_id looked up by locale code (an unknown code aborts before any write) - one transaction, setval past the copied ids, --force replaces, --dry-run reports, an empty file copies nothing - scripts/winter-export.sql: SELECT JSON_OBJECT with the translate locale code - TestFaqImportSmoke
367 lines
13 KiB
Go
367 lines
13 KiB
Go
package faq_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "git.golem15.com/golem15/sm-faq-plugin"
|
|
"git.golem15.com/golem15/sm-faq-plugin/console"
|
|
"git.golem15.com/golem15/sm-faq-plugin/models"
|
|
_ "git.golem15.com/golem15/sm-translate-plugin"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const permFAQs = "golem15.faq.faqs"
|
|
|
|
// faqEnv is a booted application with the translate and FAQ plugins.
|
|
type faqEnv struct {
|
|
h http.Handler
|
|
app *backpack.App
|
|
gdb *gorm.DB
|
|
}
|
|
|
|
func bootFAQ(t *testing.T) *faqEnv {
|
|
t.Helper()
|
|
sqlDB := freshDB(t)
|
|
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: faq-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{
|
|
Dir: dir,
|
|
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=faq-smoke-test-only-hs256-secret-key-0001"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for key, value := range map[string]any{
|
|
"http.body_limits.default_bytes": 1048576,
|
|
"http.body_limits.upload_bytes": 8388608,
|
|
"mail.driver": "log",
|
|
"mail.from": "noreply@example.test",
|
|
} {
|
|
if err := cfg.Set(key, value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.faq"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &faqEnv{h: h, app: app, gdb: gdb}
|
|
}
|
|
|
|
// localeID returns the id of the seeded translate locale code.
|
|
func (e *faqEnv) localeID(t *testing.T, code string) uint {
|
|
t.Helper()
|
|
var id uint
|
|
if err := e.gdb.Raw(`SELECT id FROM golem15_translate_locales WHERE code = ?`, code).Scan(&id).Error; err != nil || id == 0 {
|
|
t.Fatalf("locale %s: id=%d err=%v", code, id, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
var adminSeq atomic.Uint64
|
|
|
|
// call sends an admin API request with an optional bearer token and JSON
|
|
// body.
|
|
func (e *faqEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader io.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
}
|
|
req := httptest.NewRequest(method, cabana.DefaultAdminPrefix+"/api/v1"+path, reader)
|
|
req.Host = "api.example.test"
|
|
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
|
|
req.Header.Set("Accept-Language", "en")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// admin signs in a new administrator whose role grants permissions.
|
|
func (e *faqEnv) admin(t *testing.T, permissions ...string) string {
|
|
t.Helper()
|
|
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
|
|
login, password := "admin-"+stamp, "admin-secret-"+stamp
|
|
hash, err := bouncer.HashPassword(10, password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
grants := map[string]int{}
|
|
for _, code := range permissions {
|
|
grants[code] = 1
|
|
}
|
|
raw, _ := json.Marshal(grants)
|
|
var roleID uint
|
|
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
|
|
"Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 {
|
|
t.Fatalf("backend role: id=%d err=%v", roleID, err)
|
|
}
|
|
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
|
|
login, login+"@example.test", hash, roleID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := e.call(t, http.MethodPost, "/auth/login", "", map[string]string{"login": login, "password": password})
|
|
var body struct {
|
|
Data struct {
|
|
AccessToken string `json:"access_token"`
|
|
} `json:"data"`
|
|
}
|
|
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" {
|
|
t.Fatalf("admin login: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
return body.Data.AccessToken
|
|
}
|
|
|
|
// adminRecord is a create or update answer.
|
|
type adminRecord struct {
|
|
Data map[string]any `json:"data"`
|
|
Meta struct {
|
|
Notices []cabana.FieldNotice `json:"notices"`
|
|
} `json:"meta"`
|
|
}
|
|
|
|
// TestFaqAdminSmoke creates a FAQ with two questions through the admin API.
|
|
// The second answer holds a script element and an onclick attribute: the
|
|
// stored answer has neither, a notice names content.1.answer, and the list
|
|
// shows the row with its locale's name.
|
|
func TestFaqAdminSmoke(t *testing.T) {
|
|
e := bootFAQ(t)
|
|
token := e.admin(t, permFAQs)
|
|
const base = "/golem15/faq/faqs"
|
|
en := e.localeID(t, "en")
|
|
|
|
rec := e.call(t, http.MethodGet, base+"/schema/form", token, nil)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"type":"repeater"`) || !strings.Contains(rec.Body.String(), `"type":"richeditor"`) {
|
|
t.Fatalf("form schema: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
body := map[string]any{
|
|
"name": "Shipping",
|
|
"slug": "shipping",
|
|
"locale": en,
|
|
"is_active": true,
|
|
"description": "<p>Everything about shipping.</p>",
|
|
"content": []any{
|
|
map[string]any{"question": "How long?", "answer": "<p>Two days.</p>"},
|
|
map[string]any{"question": "Where?", "answer": `<p onclick="steal()">Everywhere.</p><script>alert(1)</script>`},
|
|
},
|
|
}
|
|
rec = e.call(t, http.MethodPost, base, token, body)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var created adminRecord
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var notice bool
|
|
for _, n := range created.Meta.Notices {
|
|
if n.Field == "content.1.answer" && n.Message != "" {
|
|
notice = true
|
|
}
|
|
if n.Field == "content.0.answer" || n.Field == "description" {
|
|
t.Errorf("clean HTML reported a notice: %+v", n)
|
|
}
|
|
}
|
|
if !notice {
|
|
t.Fatalf("no notice under content.1.answer: %+v", created.Meta.Notices)
|
|
}
|
|
|
|
var stored models.FAQ
|
|
if err := e.gdb.Where("slug = ?", "shipping").First(&stored).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows := stored.Items()
|
|
if stored.LocaleID != en || !stored.IsActive || len(rows) != 2 {
|
|
t.Fatalf("stored = %+v rows=%v", stored, rows)
|
|
}
|
|
if rows[0]["answer"] != "<p>Two days.</p>" || rows[1]["question"] != "Where?" {
|
|
t.Fatalf("stored rows = %v", rows)
|
|
}
|
|
answer, _ := rows[1]["answer"].(string)
|
|
if strings.Contains(answer, "script") || strings.Contains(answer, "onclick") || !strings.Contains(answer, "Everywhere.") {
|
|
t.Fatalf("stored answer not sanitized: %q", answer)
|
|
}
|
|
|
|
// The list shows the row with its locale's name.
|
|
rec = e.call(t, http.MethodGet, base, token, nil)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"shipping"`) || !strings.Contains(rec.Body.String(), `"English"`) {
|
|
t.Fatalf("list: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// A FAQ without a locale is refused.
|
|
rec = e.call(t, http.MethodPost, base, token, map[string]any{"name": "No locale", "slug": "no-locale"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"locale":["golem15.faq::lang.errors.locale_required"]`) {
|
|
t.Fatalf("create without locale: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var n int64
|
|
e.gdb.Model(&models.FAQ{}).Where("slug = ?", "no-locale").Count(&n)
|
|
if n != 0 {
|
|
t.Fatalf("a refused create left %d rows", n)
|
|
}
|
|
|
|
// Without the permission the screen answers 403.
|
|
other := e.admin(t)
|
|
if rec := e.call(t, http.MethodGet, base, other, nil); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("list without permission: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func writeFile(t *testing.T, dir, name, body string) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func testOutput() (bonfire.Output, *bytes.Buffer) {
|
|
var buf bytes.Buffer
|
|
return bonfire.NewOutput(strings.NewReader(""), &buf, &buf), &buf
|
|
}
|
|
|
|
func (e *faqEnv) countFAQs(t *testing.T) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := e.gdb.Model(&models.FAQ{}).Count(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestFaqImportSmoke copies a Winter export through faq:import-winter: two
|
|
// locales by code, ids kept, the sequence moved past them, an unknown code
|
|
// aborting before any write, a second copy refused without --force and an
|
|
// empty file reporting zero.
|
|
func TestFaqImportSmoke(t *testing.T) {
|
|
e := bootFAQ(t)
|
|
ctx := t.Context()
|
|
dir := t.TempDir()
|
|
en, pl := e.localeID(t, "en"), e.localeID(t, "pl")
|
|
|
|
content, _ := json.Marshal(`[{"question":"How long?","answer":"<p>Two days.</p>"},{"question":"Where?","answer":"<p>Everywhere.</p>"}]`)
|
|
good := writeFile(t, dir, "faqs.jsonl",
|
|
`{"id": 4, "name": "Shipping", "slug": "shipping", "locale_code": "en", "is_active": 1, "description": "<p>About shipping.</p>", "content": `+string(content)+`, "created_at": "2024-03-01 10:00:00.000000", "updated_at": "2024-03-02 11:30:00.000000"}`+"\n"+
|
|
`{"id": 9, "name": "Wysyłka", "slug": "shipping", "locale_code": "pl", "is_active": 0, "description": null, "content": [{"question":"Jak długo?","answer":"<p>Dwa dni.</p>"}], "created_at": null, "updated_at": null}`+"\n\n")
|
|
unknown := writeFile(t, dir, "unknown.jsonl",
|
|
`{"id": 1, "name": "A", "slug": "a", "locale_code": "en", "is_active": 1, "content": null}`+"\n"+
|
|
`{"id": 2, "name": "B", "slug": "b", "locale_code": "de", "is_active": 1, "content": null}`+"\n")
|
|
empty := writeFile(t, dir, "empty.jsonl", "\n")
|
|
|
|
// An unknown locale code aborts before anything is written.
|
|
out, buf := testOutput()
|
|
if _, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: unknown}, out); err == nil || !strings.Contains(err.Error(), "'de'") {
|
|
t.Fatalf("unknown locale: %v\n%s", err, buf.String())
|
|
}
|
|
if n := e.countFAQs(t); n != 0 {
|
|
t.Fatalf("unknown locale wrote %d rows", n)
|
|
}
|
|
|
|
// A dry run reports and writes nothing.
|
|
out, buf = testOutput()
|
|
if report, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: good, DryRun: true}, out); err != nil || report.FAQs != 2 {
|
|
t.Fatalf("dry run: %+v %v\n%s", report, err, buf.String())
|
|
}
|
|
if n := e.countFAQs(t); n != 0 || !strings.Contains(buf.String(), "Dry run complete") {
|
|
t.Fatalf("dry run wrote %d rows\n%s", n, buf.String())
|
|
}
|
|
|
|
out, buf = testOutput()
|
|
report, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: good}, out)
|
|
if err != nil || report.FAQs != 2 {
|
|
t.Fatalf("import: %+v %v\n%s", report, 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].ID != 4 || rows[0].LocaleID != en || !rows[0].IsActive || rows[1].ID != 9 || rows[1].LocaleID != pl || rows[1].IsActive {
|
|
t.Fatalf("copied rows = %+v", rows)
|
|
}
|
|
if items := rows[0].Items(); len(items) != 2 || items[1]["question"] != "Where?" || rows[0].CreatedAt == nil || !rows[0].CreatedAt.Equal(time.Date(2024, 3, 1, 10, 0, 0, 0, time.UTC)) {
|
|
t.Fatalf("copied content = %v created %v", items, rows[0].CreatedAt)
|
|
}
|
|
if items := rows[1].Items(); len(items) != 1 || items[0]["answer"] != "<p>Dwa dni.</p>" {
|
|
t.Fatalf("copied pl content = %v", items)
|
|
}
|
|
|
|
// The sequence moved past the copied ids: an admin create gets id 10.
|
|
token := e.admin(t, permFAQs)
|
|
rec := e.call(t, http.MethodPost, "/golem15/faq/faqs", token, map[string]any{"name": "Returns", "slug": "returns", "locale": en})
|
|
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"id":10`) {
|
|
t.Fatalf("create after import: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// A second copy is refused without --force and replaces with it.
|
|
out, buf = testOutput()
|
|
if _, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: good}, out); err == nil || !strings.Contains(err.Error(), "--force") {
|
|
t.Fatalf("second import: %v\n%s", err, buf.String())
|
|
}
|
|
if n := e.countFAQs(t); n != 3 {
|
|
t.Fatalf("refused import changed the table: %d rows", n)
|
|
}
|
|
out, buf = testOutput()
|
|
if _, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: good, Force: true}, out); err != nil {
|
|
t.Fatalf("forced import: %v\n%s", err, buf.String())
|
|
}
|
|
if n := e.countFAQs(t); n != 2 {
|
|
t.Fatalf("forced import left %d rows", n)
|
|
}
|
|
|
|
// An empty file writes nothing and reports zero.
|
|
out, buf = testOutput()
|
|
if report, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: empty}, out); err != nil || report.FAQs != 0 || !strings.Contains(buf.String(), "0 FAQs") {
|
|
t.Fatalf("empty file: %+v %v\n%s", report, err, buf.String())
|
|
}
|
|
if n := e.countFAQs(t); n != 2 {
|
|
t.Fatalf("empty file changed the table: %d rows", n)
|
|
}
|
|
}
|