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()) } }