- 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
176 lines
9.2 KiB
Go
176 lines
9.2 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const bom = "\ufeff"
|
|
|
|
// transferAction calls the import and export screen's widget action.
|
|
func (e *testEnv) transferAction(t *testing.T, token string, delimiter string, payload map[string]any) map[string]any {
|
|
t.Helper()
|
|
rec := e.expect(t, http.StatusOK, http.MethodPost, adminAPI("/golem15/seo/transfer/widgets/_transfer"), token, map[string]any{
|
|
"record_id": 1,
|
|
"values": map[string]any{"csv_delimiter": delimiter},
|
|
"payload": payload,
|
|
})
|
|
var out struct {
|
|
Data struct {
|
|
Data map[string]any `json:"data"`
|
|
Message string `json:"message"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
|
}
|
|
if out.Data.Data == nil {
|
|
out.Data.Data = map[string]any{}
|
|
}
|
|
out.Data.Data["_message"] = out.Data.Message
|
|
return out.Data.Data
|
|
}
|
|
|
|
// summary reads an import answer's summary as counts.
|
|
func summary(t *testing.T, body map[string]any) map[string]int {
|
|
t.Helper()
|
|
raw, _ := body["summary"].(map[string]any)
|
|
out := map[string]int{}
|
|
for k, v := range raw {
|
|
f, _ := v.(float64)
|
|
out[k] = int(f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestCSVTransfer checks D-13 import and export (P-27, T-32-24/25/29):
|
|
// the export is UTF-8 with a BOM, the chosen delimiter and formula cells
|
|
// prefixed with an apostrophe, and importing it back changes nothing;
|
|
// missing columns and unreadable files are refused; a looping row is
|
|
// refused and blocks the apply; a records row without a record becomes a
|
|
// URL rule; the admin's apply token is refused once the data changed and
|
|
// applies one import batch when fresh; size caps hold on both paths.
|
|
func TestCSVTransfer(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})
|
|
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
|
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/e1", "target": "/t1", "note": "=cmd"})
|
|
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/e2", "target": "/t2", "status": 302})
|
|
|
|
exp := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/export?dataset=redirects&delimiter=%3B", agent, nil)
|
|
csv, _ := exp["csv"].(string)
|
|
if !strings.HasPrefix(csv, bom+"source;match;target;status;query;ignore_case;trailing_slash;note") {
|
|
t.Fatalf("export = %q, want a BOM and the semicolon header", csv)
|
|
}
|
|
if !strings.Contains(csv, "'=cmd") || strings.Contains(csv, ";=cmd") {
|
|
t.Fatalf("export = %q, want the formula cell prefixed with an apostrophe", csv)
|
|
}
|
|
if !strings.HasPrefix(fmt.Sprint(exp["filename"]), "seo-redirects-") {
|
|
t.Fatalf("filename = %v", exp["filename"])
|
|
}
|
|
|
|
preview := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": csv, "delimiter": ";", "dry_run": true})
|
|
if s := summary(t, preview); s["unchanged"] != 2 || s["new"] != 0 || s["changed"] != 0 || s["refused"] != 0 {
|
|
t.Fatalf("re-import of the export = %v, want 2 unchanged", preview)
|
|
}
|
|
|
|
// Refusals of the file as a whole.
|
|
rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": "target;status\n/x;301\n", "delimiter": ";", "dry_run": true})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"error":"missing_column"`) || !strings.Contains(rec.Body.String(), "source") {
|
|
t.Fatalf("missing column = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": "source;target\n\"/x;/y\n", "delimiter": ";", "dry_run": true})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"error":"unreadable"`) {
|
|
t.Fatalf("unreadable = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "nope", "csv": "a\n"}); rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("unknown dataset = %d, want 422", rec.Code)
|
|
}
|
|
|
|
// A looping row is refused and nothing is applied.
|
|
looping := "source,target\n/new1,/t1\n/e1x,/e1\n/t1,/new1\n"
|
|
preview = env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": looping, "delimiter": ",", "dry_run": true})
|
|
if s := summary(t, preview); s["refused"] != 1 || s["new"] != 2 {
|
|
t.Fatalf("looping preview = %v", preview)
|
|
}
|
|
if !strings.Contains(fmt.Sprint(preview["rows"]), "Creates a redirect loop") {
|
|
t.Fatalf("looping rows = %v, want transfer.row_loop", preview["rows"])
|
|
}
|
|
before := env.redirectCount(t)
|
|
rec = env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": looping, "delimiter": ","})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"error":"import_refused"`) {
|
|
t.Fatalf("looping apply = %d %s, want 422 import_refused", rec.Code, rec.Body.String())
|
|
}
|
|
if n := env.redirectCount(t); n != before {
|
|
t.Fatalf("redirects after a refused import = %d, want %d", n, before)
|
|
}
|
|
|
|
// Records address pages by URL; no record means a URL rule.
|
|
records := "url;locale;title;robots\n/pages/a;en;Imported;noindex\n/no-record;;Rule title;\n"
|
|
preview = env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "records", "csv": records, "delimiter": ";", "dry_run": true})
|
|
if s := summary(t, preview); s["changed"] != 1 || s["new"] != 1 {
|
|
t.Fatalf("records preview = %v", preview)
|
|
}
|
|
if !strings.Contains(fmt.Sprint(preview["rows"]), "No record at this address") {
|
|
t.Fatalf("records rows = %v, want transfer.row_rule", preview["rows"])
|
|
}
|
|
applied := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "records", "csv": records, "delimiter": ";"})
|
|
if applied["applied"] != true || applied["count"] != float64(2) {
|
|
t.Fatalf("records apply = %v", applied)
|
|
}
|
|
if got := env.resolve(t, "/no-record"); got["action"] != "ok" || got["seo"].(map[string]any)["title"] != "Rule title" {
|
|
t.Fatalf("resolve /no-record = %v, want the imported rule", got)
|
|
}
|
|
|
|
// The admin screen: a stale token is refused, a fresh one applies.
|
|
if fields := env.formSchema(t, token, "golem15/seo/transfer"); len(fields) < 2 {
|
|
t.Fatalf("transfer form = %v", fields)
|
|
}
|
|
file := "source;target;status\n/admin1;/pages/a;301\n/e2;/t2-new;302\n"
|
|
prev := env.transferAction(t, token, ";", map[string]any{"op": "preview", "dataset": "redirects", "csv": file})
|
|
tok, _ := prev["token"].(string)
|
|
if tok == "" || summary(t, prev)["new"] != 1 || summary(t, prev)["changed"] != 1 {
|
|
t.Fatalf("admin preview = %v", prev)
|
|
}
|
|
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/admin1", "target": "/elsewhere"})
|
|
stale := env.transferAction(t, token, ";", map[string]any{"op": "apply", "token": tok})
|
|
if stale["error"] != "stale" || !strings.Contains(fmt.Sprint(stale["message"]), "The data changed after the preview") {
|
|
t.Fatalf("stale apply = %v, want transfer.stale", stale)
|
|
}
|
|
forged := env.transferAction(t, token, ";", map[string]any{"op": "apply", "token": tok + "x"})
|
|
if forged["error"] == nil {
|
|
t.Fatalf("forged token = %v, want a refusal", forged)
|
|
}
|
|
prev = env.transferAction(t, token, ";", map[string]any{"op": "preview", "dataset": "redirects", "csv": file})
|
|
done := env.transferAction(t, token, ";", map[string]any{"op": "apply", "token": prev["token"]})
|
|
if done["applied"] != true || !strings.Contains(fmt.Sprint(done["_message"]), "Applied 2 changes as one batch") {
|
|
t.Fatalf("apply = %v", done)
|
|
}
|
|
var vias []string
|
|
if err := env.db.Raw(`SELECT DISTINCT via FROM golem15_seo_revisions WHERE batch_id = ?`, done["batch_id"]).Scan(&vias).Error; err != nil || len(vias) != 1 || vias[0] != "import" {
|
|
t.Fatalf("import batch vias = %v err=%v, want import", vias, err)
|
|
}
|
|
var origin string
|
|
if err := env.db.Raw(`SELECT origin FROM golem15_seo_redirects WHERE source = '/e2'`).Scan(&origin).Error; err != nil || origin != "mcp" {
|
|
t.Fatalf("an updated redirect keeps its origin: %q err=%v", origin, err)
|
|
}
|
|
|
|
// Export through the screen, and the size caps.
|
|
out := env.transferAction(t, token, ",", map[string]any{"op": "export", "dataset": "rules"})
|
|
if c := fmt.Sprint(out["csv"]); !strings.HasPrefix(c, bom+"url,match,title") || !strings.Contains(c, "/no-record") {
|
|
t.Fatalf("admin export = %v", out)
|
|
}
|
|
big := "source;target\n" + strings.Repeat("/a-long-source-address;/a-long-target-address\n", 1350)
|
|
if tooLarge := env.transferAction(t, token, ";", map[string]any{"op": "preview", "dataset": "redirects", "csv": big}); tooLarge["error"] != "too_large" {
|
|
t.Fatalf("admin import over 60 KB = %v, want too_large", tooLarge)
|
|
}
|
|
huge := "source;target\n" + strings.Repeat("/a-long-source-address;/a-long-target-address\n", 21000)
|
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": huge, "dry_run": true}); rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("MCP import over 900 KB = %d, want 413", rec.Code)
|
|
}
|
|
}
|