package sitemap_test import ( "context" "encoding/json" "strings" "testing" "git.golem15.com/golem15/sm-sitemap-plugin/console" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/summercms/modules/signpost" ) // TestImportWinter pins sitemap:import-winter on Postgres (D-12): type maps // and drops with their report lines, the reference check, --theme with // several exported definitions, --dry-run writing nothing, the refusal of a // second copy without --force and the replacement with it, the settings // upsert onto row 1 with PHP defaults for missing keys, and the refusals for // missing, empty and malformed exports. func TestImportWinter(t *testing.T) { e := bootSitemap(t) ctx := context.Background() reg, ok := e.app.Lookup[*signpost.Registry]() if !ok { t.Fatal("signpost registry is not published") } dir := t.TempDir() row := func(id int, theme string, items string) string { quoted, _ := json.Marshal(items) return `{"id": ` + string(rune('0'+id)) + `, "theme": "` + theme + `", "data": ` + string(quoted) + "}\n" } items := `[{"type":"url","url":"/"},` + `{"type":"old-posts","reference":null,"nesting":"1"},` + `{"type":"acme-post","reference":"7"},` + `{"type":"acme-post","reference":"8"},` + `{"type":"gone-type","reference":"1"},` + `{"type":"static-page","reference":"about"},` + `{"type":"remove-me"}]` defs := writeFile(t, dir, "definitions.jsonl", row(1, "main", items)+"\n"+row(2, "other", `[{"type":"url","url":"/other"}]`)) stored := func(t *testing.T) []models.DefinitionItem { t.Helper() var rows []models.Definition if err := e.gdb.Where("theme = ?", models.DefinitionTheme).Find(&rows).Error; err != nil { t.Fatal(err) } if len(rows) == 0 { return nil } if len(rows) != 1 { t.Fatalf("%d definitions", len(rows)) } return rows[0].Items() } run := func(opts console.WinterOptions) (console.WinterReport, string, error) { out, buf := testOutput() report, err := console.ImportWinterFiles(ctx, e.gdb, reg, opts, out) return report, buf.String(), err } t.Run("refusals", func(t *testing.T) { for name, tc := range map[string]struct { opts console.WinterOptions want string }{ "no file": {console.WinterOptions{}, "give --definitions, --settings or both"}, "missing file": {console.WinterOptions{DefinitionsFile: dir + "/missing.jsonl"}, "File not found"}, "empty file": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "empty.jsonl", "\n\n")}, "holds no definition"}, "bad line": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "bad.jsonl", "{\n")}, "line 1"}, "several rows": {console.WinterOptions{DefinitionsFile: defs}, "holds 2 definitions (themes 'main', 'other'); pick one with --theme"}, "unknown theme": {console.WinterOptions{DefinitionsFile: defs, Theme: "nope"}, "no definition of theme 'nope'"}, "data not items": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "scalar.jsonl", `{"id":1,"theme":"t","data":"5"}`)}, "not a list of items"}, "bad settings": {console.WinterOptions{SettingsFile: writeFile(t, dir, "settings-bad.json", `[1]`)}, "must be a JSON object"}, } { _, out, err := run(tc.opts) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("%s: err = %v, want %q\n%s", name, err, tc.want, out) } } if got := stored(t); got != nil { t.Fatalf("a refused import wrote %+v", got) } }) opts := console.WinterOptions{DefinitionsFile: defs, Theme: "main", Maps: map[string]string{"old-posts": "acme-list", "remove-me": ""}} wantLines := []string{ "item 1 (type 'old-posts'): type 'old-posts' mapped to 'acme-list'", "item 3 (type 'acme-post', reference '8'): dropped (reference '8' is not among the current references of 'acme-post')", "item 4 (type 'gone-type', reference '1'): dropped (type 'gone-type' is not registered; map it with --map gone-type=)", "item 5 (type 'static-page', reference 'about'): dropped (static and CMS page types are not supported)", "item 6 (type 'remove-me'): dropped (mapped to nothing)", } t.Run("dry run writes nothing", func(t *testing.T) { dry := opts dry.DryRun = true report, out, err := run(dry) if err != nil { t.Fatalf("dry run: %v\n%s", err, out) } if report.Kept != 3 || report.Dropped != 4 || report.Remapped != 2 { t.Fatalf("report = %+v\n%s", report, out) } for _, line := range wantLines { if !strings.Contains(out, line) { t.Errorf("output lacks %q:\n%s", line, out) } } if !strings.Contains(out, "Dry run: 3 items kept, 2 remapped, 4 dropped.") || !strings.Contains(out, "Dry run complete (no changes made).") { t.Fatalf("dry run output:\n%s", out) } if got := stored(t); got != nil { t.Fatalf("the dry run wrote %+v", got) } }) t.Run("copies, refuses a second copy, replaces with --force", func(t *testing.T) { report, out, err := run(opts) if err != nil { t.Fatalf("import: %v\n%s", err, out) } got := stored(t) if report.Kept != 3 || len(got) != 3 || got[0].Type != "url" || got[1].Type != "acme-list" || !got[1].Nesting || got[2].Type != "acme-post" || models.Text(got[2].Reference) != "7" { t.Fatalf("copied = %+v (report %+v)", got, report) } if !strings.Contains(out, "Definition copied: 3 items kept, 2 remapped, 4 dropped.") || !strings.Contains(out, "theme 'main'") { t.Fatalf("output:\n%s", out) } other := console.WinterOptions{DefinitionsFile: defs, Theme: "other"} if _, out, err := run(other); err == nil || !strings.Contains(err.Error(), "already has 3 items; rerun with --force") { t.Fatalf("second copy: %v\n%s", err, out) } // A dry run over existing items warns instead of failing. dry := other dry.DryRun = true if _, out, err := run(dry); err != nil || !strings.Contains(out, "already has 3 items") { t.Fatalf("dry run over items: %v\n%s", err, out) } if len(stored(t)) != 3 { t.Fatal("a refused copy changed the definition") } other.Force = true if _, out, err := run(other); err != nil { t.Fatalf("--force: %v\n%s", err, out) } if got := stored(t); len(got) != 1 || models.Text(got[0].URL) != "/other" { t.Fatalf("after --force = %+v", got) } }) t.Run("data held as JSON or null", func(t *testing.T) { raw := writeFile(t, dir, "raw.jsonl", `{"id":5,"theme":"t","data":[{"type":"url","url":"/raw"}]}`) if _, out, err := run(console.WinterOptions{DefinitionsFile: raw, Force: true}); err != nil { t.Fatalf("raw JSON data: %v\n%s", err, out) } if got := stored(t); len(got) != 1 || models.Text(got[0].URL) != "/raw" { t.Fatalf("raw data = %+v", got) } null := writeFile(t, dir, "null.jsonl", `{"id":6,"theme":"t","data":null}`) if report, out, err := run(console.WinterOptions{DefinitionsFile: null, Force: true}); err != nil || report.Kept != 0 { t.Fatalf("null data: %+v %v\n%s", report, err, out) } if got := stored(t); len(got) != 0 { t.Fatalf("null data stored %+v", got) } }) t.Run("url and pattern checks", func(t *testing.T) { bad := writeFile(t, dir, "bad-urls.jsonl", row(7, "t", `[{"type":"url","url":"javascript://%0aalert(1)%0a//x"},`+ `{"type":"url","url":"/ok"},`+ `{"type":"acme-post","reference":"7","urlPattern":"//evil.test/:slug"}]`)) report, out, err := run(console.WinterOptions{DefinitionsFile: bad, Force: true}) if err != nil { t.Fatalf("import: %v\n%s", err, out) } if report.Kept != 2 || report.Dropped != 1 { t.Fatalf("report = %+v\n%s", report, out) } for _, line := range []string{ "item 0 (type 'url', url 'javascript://%0aalert(1)%0a//x'): dropped (the url is not a path or an http(s) URL)", "item 2 (type 'acme-post', reference '7'): urlPattern '//evil.test/:slug' cleared (not a path starting with a single /)", } { if !strings.Contains(out, line) { t.Errorf("output lacks %q:\n%s", line, out) } } got := stored(t) if len(got) != 2 || models.Text(got[0].URL) != "/ok" || got[1].URLPattern != nil { t.Fatalf("stored = %+v", got) } }) t.Run("settings upsert", func(t *testing.T) { first := writeFile(t, dir, "settings.json", `{"frontend_url":"https://www.example.test","enable_priority_tiers":"1","priority_blog":0.7,"unknown":"x"}`) report, out, err := run(console.WinterOptions{SettingsFile: first}) if err != nil || !report.Settings || !strings.Contains(out, "setting 'unknown' ignored") || !strings.Contains(out, "Settings copied.") { t.Fatalf("settings: %+v %v\n%s", report, err, out) } second := writeFile(t, dir, "settings2.json", `"{\"frontend_url\":\"https://other.example.test\",\"enable_priority_tiers\":\"0\"}"`) if _, out, err := run(console.WinterOptions{SettingsFile: second}); err != nil { t.Fatalf("second settings: %v\n%s", err, out) } var rows []models.Settings if err := e.gdb.Find(&rows).Error; err != nil { t.Fatal(err) } if len(rows) != 1 || rows[0].ID != 1 || models.Text(rows[0].FrontendURL) != "https://other.example.test" || rows[0].EnablePriorityTiers || rows[0].Priority(models.PriorityBlog) != "0.6" { t.Fatalf("settings rows = %+v", rows) } dry := writeFile(t, dir, "settings3.json", `{"frontend_url":"https://dry.example.test"}`) if _, out, err := run(console.WinterOptions{SettingsFile: dry, DryRun: true}); err != nil || !strings.Contains(out, "Dry run: the settings would be copied.") { t.Fatalf("settings dry run: %v\n%s", err, out) } if err := e.gdb.Find(&rows).Error; err != nil || models.Text(rows[0].FrontendURL) != "https://other.example.test" { t.Fatalf("the dry run changed the settings: %+v %v", rows, err) } // T-17-15: a frontend_url that is not a plain http(s) URL stops the // import, also as a dry run, and the row keeps its value. quoted, _ := json.Marshal(map[string]string{"frontend_url": `https://x"/>