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"]) } // A huge page number is an empty page, not a panic (WR-14); so is a // list page. far := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/export?dataset=redirects&page=9223372036854777", agent, nil) if c, _ := far["csv"].(string); strings.Contains(c, "/e1") { t.Fatalf("export of a huge page = %v, want no rows", far) } if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects?page=9223372036854775807&per_page=200", agent, nil); len(l["data"].([]any)) != 0 { t.Fatalf("list of a huge page = %v, want no rows", l) } 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. odd := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": "source;match;target;query\n/g1;glob;/x;ignore\n/g2;exact;/x;keep\n", "delimiter": ";", "dry_run": true}) if s := summary(t, odd); s["refused"] != 2 { t.Fatalf("import with an unknown match and query = %v, want 2 refused rows (WR-17)", odd) } 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) } }