- POST /_seo/api/v1/batch runs changes in one transaction: dry_run answers the diff and the conflicts (loops, chains, noindex on a published record, canonicals to redirected or noindex addresses) and rolls back; a refused change refuses the batch (D-12) - undo of a change or a whole batch from History and through MCP, with the undo_stale, undo_loop and undo_gone refusals (D-13) - CSV export and import of redirects, rules and records by URL: BOM, comma or semicolon, formula cells escaped, a dry-run preview, all or nothing with via import; the screen's apply token is HMAC-SHA256 over the file and a fingerprint of the data (transfer.stale) - Import and export singleton with the seo_transfer widget action, History record actions undo and undo_batch, navigation entry - MCP tools undo_change, undo_batch, apply_batch, export_csv, import_csv - TestBatch, TestUndo, TestCSVTransfer, TestLoopRefused mcp and import
323 lines
14 KiB
Go
323 lines
14 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// snapshot dumps every SEO table the batch and undo write, so a dry run
|
|
// can be checked to leave rows and values exactly as they were.
|
|
func (e *testEnv) snapshot(t *testing.T) string {
|
|
t.Helper()
|
|
var b strings.Builder
|
|
for _, q := range []string{
|
|
`SELECT id, source, match, target, status, origin, note FROM golem15_seo_redirects ORDER BY id`,
|
|
`SELECT id, url, match, title, description, canonical, robots FROM golem15_seo_rules ORDER BY id`,
|
|
`SELECT id, model_type, record_id, locale, title, description, canonical, robots, og_image FROM golem15_seo_entries ORDER BY id`,
|
|
`SELECT id, via, subject_kind, subject_id, batch_id, undone_at FROM golem15_seo_revisions ORDER BY id`,
|
|
`SELECT id, content, bots::text FROM golem15_seo_robots ORDER BY id`,
|
|
} {
|
|
rows, err := e.sql.QueryContext(t.Context(), q)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cols, _ := rows.Columns()
|
|
n := 0
|
|
for rows.Next() {
|
|
vals := make([]any, len(cols))
|
|
ptrs := make([]any, len(cols))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fmt.Fprintf(&b, "%v\n", vals)
|
|
n++
|
|
}
|
|
_ = rows.Close()
|
|
fmt.Fprintf(&b, "-- %d rows\n", n)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// redirectChange is a create_redirect change of a batch request.
|
|
func redirectChange(source, target string) map[string]any {
|
|
return map[string]any{"op": "create_redirect", "redirect": map[string]any{"source": source, "target": target}}
|
|
}
|
|
|
|
// conflictKinds lists the kinds of a batch answer's conflicts.
|
|
func conflictKinds(body map[string]any) map[string]bool {
|
|
out := map[string]bool{}
|
|
list, _ := body["conflicts"].([]any)
|
|
for _, c := range list {
|
|
m, _ := c.(map[string]any)
|
|
out[fmt.Sprint(m["kind"])] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestBatch checks D-12 (SC-4): a dry run answers the diff and the
|
|
// conflicts (a chain, noindex on a published record's address, a
|
|
// canonical pointing to a redirect the batch creates) and leaves every
|
|
// row as it was; a batch with a loop is refused as a whole with 422 naming
|
|
// the change; an applied batch writes every change under one batch id.
|
|
func TestBatch(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
|
|
env.createPage(t, token, map[string]any{"title": "Page B", "slug": "b", "excerpt": "About B", "published": true})
|
|
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
|
|
|
changes := []any{
|
|
redirectChange("/r1", "/pages/a"),
|
|
redirectChange("/r2", "/pages/b"),
|
|
redirectChange("/r3", "/r1"),
|
|
map[string]any{"op": "set_seo", "url": "/pages/a", "values": map[string]any{"robots": "noindex"}},
|
|
map[string]any{"op": "set_seo", "url": "/pages/b", "values": map[string]any{"canonical": "/r2"}},
|
|
}
|
|
|
|
before := env.snapshot(t)
|
|
dry := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"dry_run": true, "changes": changes})
|
|
if after := env.snapshot(t); after != before {
|
|
t.Fatalf("a dry run changed the data:\nbefore:\n%s\nafter:\n%s", before, after)
|
|
}
|
|
if dry["dry_run"] != true || dry["applied"] != false || dry["count"] != float64(5) {
|
|
t.Fatalf("dry run = %v", dry)
|
|
}
|
|
items, _ := dry["changes"].([]any)
|
|
if len(items) != 5 {
|
|
t.Fatalf("dry run changes = %v", dry["changes"])
|
|
}
|
|
for i, item := range items {
|
|
if diff, _ := item.(map[string]any)["diff"].([]any); len(diff) == 0 {
|
|
t.Fatalf("change %d has no diff: %v", i, item)
|
|
}
|
|
}
|
|
kinds := conflictKinds(dry)
|
|
for _, k := range []string{"redirect_chain", "noindex_published", "canonical_redirected"} {
|
|
if !kinds[k] {
|
|
t.Fatalf("dry run conflicts %v lack %s: %v", kinds, k, dry["conflicts"])
|
|
}
|
|
}
|
|
|
|
// A loop refuses the whole batch.
|
|
loop := env.mcp(t, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{
|
|
redirectChange("/l0", "/pages/a"),
|
|
redirectChange("/l1", "/l2"),
|
|
redirectChange("/l2", "/l1"),
|
|
}})
|
|
if loop.Code != http.StatusUnprocessableEntity || !strings.Contains(loop.Body.String(), `"error":"redirect_loop"`) || !strings.Contains(loop.Body.String(), `"change":2`) {
|
|
t.Fatalf("loop batch = %d %s, want 422 redirect_loop on change 2", loop.Code, loop.Body.String())
|
|
}
|
|
if after := env.snapshot(t); after != before {
|
|
t.Fatalf("a refused batch wrote:\n%s", after)
|
|
}
|
|
invalid := env.mcp(t, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{
|
|
redirectChange("/ok", "/pages/a"),
|
|
map[string]any{"op": "set_seo", "url": "/pages/a", "values": map[string]any{"robots": "sideways"}},
|
|
}})
|
|
if invalid.Code != http.StatusUnprocessableEntity || !strings.Contains(invalid.Body.String(), `"change":1`) {
|
|
t.Fatalf("invalid batch = %d %s, want 422 naming change 1", invalid.Code, invalid.Body.String())
|
|
}
|
|
if after := env.snapshot(t); after != before {
|
|
t.Fatalf("a refused batch wrote:\n%s", after)
|
|
}
|
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{map[string]any{"op": "explode"}}}); rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("unknown op = %d, want 422", rec.Code)
|
|
}
|
|
|
|
applied := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": changes})
|
|
batch, _ := applied["batch_id"].(string)
|
|
if applied["applied"] != true || batch == "" {
|
|
t.Fatalf("apply = %v", applied)
|
|
}
|
|
var rows []struct {
|
|
Via string
|
|
BatchID string
|
|
}
|
|
if err := env.db.Raw(`SELECT via, COALESCE(batch_id, '') AS batch_id FROM golem15_seo_revisions ORDER BY id`).Scan(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in := 0
|
|
for _, r := range rows {
|
|
if r.BatchID == batch {
|
|
in++
|
|
if r.Via != "mcp" {
|
|
t.Fatalf("batch revision via %s, want mcp", r.Via)
|
|
}
|
|
}
|
|
}
|
|
if in != 5 {
|
|
t.Fatalf("revisions in batch %s = %d, want 5 (%+v)", batch, in, rows)
|
|
}
|
|
if got := env.resolve(t, "/r3"); got["action"] != "redirect" {
|
|
t.Fatalf("resolve /r3 = %v, want the applied redirect", got)
|
|
}
|
|
if hist := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/history?batch="+batch, agent, nil); hist["total"] != float64(5) {
|
|
t.Fatalf("history?batch = %v", hist)
|
|
}
|
|
}
|
|
|
|
// historyIDs returns the ids of the revisions of a subject kind, oldest
|
|
// first.
|
|
func (e *testEnv) historyIDs(t *testing.T, kind string) []string {
|
|
t.Helper()
|
|
var ids []uint
|
|
if err := e.db.Raw(`SELECT id FROM golem15_seo_revisions WHERE subject_kind = ? ORDER BY id`, kind).Scan(&ids).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
out[i] = fmt.Sprint(id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// entryTitle reads a page's stored SEO title in en.
|
|
func (e *testEnv) entryTitle(t *testing.T, id string) string {
|
|
t.Helper()
|
|
var title string
|
|
if err := e.db.Raw(`SELECT COALESCE(MAX(title), '') FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'en'`, pagesModel, id).Scan(&title).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return title
|
|
}
|
|
|
|
// TestUndo checks D-13 undo (T-32-27): one change is undone with its
|
|
// before value and an undo revision; an undo that would overwrite a newer
|
|
// change, recreate a loop or touch a deleted record is refused with
|
|
// history.undo_stale, undo_loop and undo_gone; a batch is undone as a
|
|
// whole; the history screen offers both as record actions.
|
|
func TestUndo(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
pageA := env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
|
|
pageB := env.createPage(t, token, map[string]any{"title": "Page B", "slug": "b", "excerpt": "About B", "published": true})
|
|
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
|
set := func(url, field, value string) {
|
|
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": url, "values": map[string]any{field: value}})
|
|
}
|
|
undo := func(id string) *httptest.ResponseRecorder {
|
|
return env.mcp(t, http.MethodPost, "/_seo/api/v1/history/"+id+"/undo", agent, map[string]any{})
|
|
}
|
|
|
|
// One change.
|
|
set("/pages/a", "title", "One")
|
|
r1 := env.historyIDs(t, "entry")[0]
|
|
if rec := undo(r1); rec.Code != http.StatusOK {
|
|
t.Fatalf("undo = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := env.entryTitle(t, pageA); got != "" {
|
|
t.Fatalf("title after undo = %q, want the value before", got)
|
|
}
|
|
var vias []string
|
|
if err := env.db.Raw(`SELECT via FROM golem15_seo_revisions WHERE subject_kind = 'entry' ORDER BY id`).Scan(&vias).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(vias) != 2 || vias[1] != "undo" {
|
|
t.Fatalf("entry revisions after undo = %v, want an undo revision", vias)
|
|
}
|
|
if rec := undo(r1); rec.Code != http.StatusConflict {
|
|
t.Fatalf("second undo = %d, want 409", rec.Code)
|
|
}
|
|
|
|
// Stale: a newer change of the same field.
|
|
set("/pages/a", "title", "Two")
|
|
ids := env.historyIDs(t, "entry")
|
|
r2 := ids[len(ids)-1]
|
|
set("/pages/a", "title", "Three")
|
|
rec := undo(r2)
|
|
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"error":"undo_stale"`) || !strings.Contains(rec.Body.String(), "MCP: ") || !strings.Contains(rec.Body.String(), "Nothing was undone") {
|
|
t.Fatalf("stale undo = %d %s, want 409 undo_stale naming the author", rec.Code, rec.Body.String())
|
|
}
|
|
if got := env.entryTitle(t, pageA); got != "Three" {
|
|
t.Fatalf("title after a refused undo = %q, want Three", got)
|
|
}
|
|
|
|
// Loop: undoing a target change would recreate a loop.
|
|
created := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/u1", "target": "/u2"})
|
|
u1 := fmt.Sprint(created["data"].(map[string]any)["id"])
|
|
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/redirects/"+u1, agent, map[string]any{"target": "/u3"})
|
|
ids = env.historyIDs(t, "redirect")
|
|
retarget := ids[len(ids)-1]
|
|
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/u2", "target": "/u1"})
|
|
rec = undo(retarget)
|
|
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"error":"undo_loop"`) || !strings.Contains(rec.Body.String(), "/u1") {
|
|
t.Fatalf("loop undo = %d %s, want 409 undo_loop with the path", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Gone: the record was deleted.
|
|
set("/pages/b", "description", "Gone soon")
|
|
ids = env.historyIDs(t, "entry")
|
|
onB := ids[len(ids)-1]
|
|
env.expect(t, http.StatusOK, http.MethodDelete, adminAPI("/acme/pages/pages/"+pageB), token, nil)
|
|
rec = undo(onB)
|
|
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"error":"undo_gone"`) {
|
|
t.Fatalf("gone undo = %d %s, want 409 undo_gone", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// A batch is undone as a whole.
|
|
applied := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{
|
|
redirectChange("/b1", "/pages/a"),
|
|
redirectChange("/b2", "/pages/a"),
|
|
map[string]any{"op": "set_seo", "url": "/pages/a", "values": map[string]any{"title": "Batched"}},
|
|
}})
|
|
batch := applied["batch_id"].(string)
|
|
before := env.redirectCount(t)
|
|
out := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/history/batches/"+batch+"/undo", agent, map[string]any{})
|
|
if out["count"] != float64(3) {
|
|
t.Fatalf("batch undo = %v, want 3", out)
|
|
}
|
|
if n := env.redirectCount(t); n != before-2 {
|
|
t.Fatalf("redirects after the batch undo = %d, want %d", n, before-2)
|
|
}
|
|
if got := env.entryTitle(t, pageA); got != "Three" {
|
|
t.Fatalf("title after the batch undo = %q, want Three", got)
|
|
}
|
|
var open int64
|
|
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions WHERE batch_id = ? AND undone_at IS NULL`, batch).Scan(&open).Error; err != nil || open != 0 {
|
|
t.Fatalf("batch revisions not undone = %d err=%v", open, err)
|
|
}
|
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/history/batches/"+batch+"/undo", agent, map[string]any{}); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("second batch undo = %d, want 404", rec.Code)
|
|
}
|
|
|
|
// The history screen's record actions.
|
|
set("/pages/a", "title", "Four")
|
|
ids = env.historyIDs(t, "entry")
|
|
last := ids[len(ids)-1]
|
|
api := adminAPI("/golem15/seo/history")
|
|
show := env.expect(t, http.StatusOK, http.MethodGet, api+"/"+last, token, nil).Body.String()
|
|
if !strings.Contains(show, `"undo"`) || strings.Contains(show, `"undo_batch"`) {
|
|
t.Fatalf("history preview actions = %s, want undo and no undo_batch for an unbatched change", show)
|
|
}
|
|
res := env.expect(t, http.StatusOK, http.MethodPost, api+"/"+last+"/actions/undo", token, map[string]any{})
|
|
if !strings.Contains(res.Body.String(), "Change undone.") {
|
|
t.Fatalf("undo action = %s", res.Body.String())
|
|
}
|
|
set("/pages/a", "title", "Five")
|
|
set("/pages/a", "title", "Six")
|
|
ids = env.historyIDs(t, "entry")
|
|
stale := ids[len(ids)-2]
|
|
if rec := env.call(t, http.MethodPost, api+"/"+stale+"/actions/undo", token, map[string]any{}); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "has changed since") {
|
|
t.Fatalf("stale undo action = %d %s, want 403 with history.undo_stale", rec.Code, rec.Body.String())
|
|
}
|
|
applied = env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/batch", agent, map[string]any{"changes": []any{redirectChange("/b3", "/pages/a"), redirectChange("/b4", "/pages/a")}})
|
|
var inBatch uint
|
|
if err := env.db.Raw(`SELECT MIN(id) FROM golem15_seo_revisions WHERE batch_id = ?`, applied["batch_id"]).Scan(&inBatch).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
show = env.expect(t, http.StatusOK, http.MethodGet, api+"/"+fmt.Sprint(inBatch), token, nil).Body.String()
|
|
if !strings.Contains(show, `"undo_batch"`) {
|
|
t.Fatalf("batched entry preview = %s, want undo_batch", show)
|
|
}
|
|
res = env.expect(t, http.StatusOK, http.MethodPost, api+"/"+fmt.Sprint(inBatch)+"/actions/undo_batch", token, map[string]any{})
|
|
if !strings.Contains(res.Body.String(), "Batch undone.") {
|
|
t.Fatalf("undo_batch action = %s", res.Body.String())
|
|
}
|
|
}
|