feat(17-04): menu:import-winter with the D-05 type map and read-only export SQL
- link to url, blog_categories to all-journal-categories, --map old=new on top; page entries dropped - entries of unknown types or stale registry references dropped with their subtree, one report line per dropped or remapped entry - ids, menu_id, parent_id, nest columns and sort_order kept; setval for both tables; --force, --dry-run, empty files copy nothing - scripts/winter-export-menus.sql and winter-export-entries.sql - TestMenuImportSmoke
This commit is contained in:
@@ -16,14 +16,17 @@ import (
|
||||
"time"
|
||||
|
||||
_ "git.golem15.com/golem15/sm-menu-plugin"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/console"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -34,8 +37,9 @@ const testTypesPluginID = "acme.menutypes"
|
||||
|
||||
const permManage = "golem15.menu.manage_menu"
|
||||
|
||||
// acmeTypes registers acme-page (references 1 Home with 2 About under it)
|
||||
// and acme-list (a list type without references).
|
||||
// acmeTypes registers acme-page (references 1 Home with 2 About under it),
|
||||
// acme-list (a list type without references) and all-journal-categories,
|
||||
// the registry key the Winter import maps blog_categories to.
|
||||
type acmeTypes struct{}
|
||||
|
||||
func (acmeTypes) ID() string { return testTypesPluginID }
|
||||
@@ -72,6 +76,16 @@ func (acmeTypes) MenuItemTypes() []pact.MenuItemType {
|
||||
return &pact.ResolvedMenuItem{Items: items}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "all-journal-categories",
|
||||
Label: "All categories",
|
||||
DefaultPattern: "/:slug",
|
||||
Nesting: true,
|
||||
DynamicItems: true,
|
||||
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return &pact.ResolvedMenuItem{Items: []pact.ResolvedMenuItem{{Title: "News", URL: "/news", Alternates: []pact.MenuItemAlternate{{Locale: "pl", URL: "/news", Title: "News"}}}}}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,3 +386,161 @@ func TestMenuAdminSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func testOutput() (bonfire.Output, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
return bonfire.NewOutput(strings.NewReader(""), &buf, &buf), &buf
|
||||
}
|
||||
|
||||
func (e *menuEnv) counts(t *testing.T) (int64, int64) {
|
||||
t.Helper()
|
||||
var menus, entries int64
|
||||
if err := e.gdb.Model(&models.Menu{}).Count(&menus).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.gdb.Model(&models.MenuEntry{}).Count(&entries).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return menus, entries
|
||||
}
|
||||
|
||||
// winterMenus and winterEntries are a Winter export: two menus; a link with
|
||||
// a divider under it, a page entry with a child, a blog_categories list, a
|
||||
// stale and a current acme-page reference, and a custom type renamed by
|
||||
// --map.
|
||||
const (
|
||||
winterMenus = `{"id": 3, "name": "Main", "code": "main", "is_active": 1, "css_class": "nav", "created_at": "2024-01-01 10:00:00.000000", "updated_at": null}
|
||||
{"id": 7, "name": "Footer", "code": "footer", "is_active": 0, "css_class": null, "created_at": null, "updated_at": null}
|
||||
`
|
||||
winterEntries = `{"id": 10, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "link", "title": "Home", "url": "/", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 11, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 2, "type": "page", "title": "About", "url": null, "reference": "about", "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 12, "menu_id": 3, "parent_id": 11, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "link", "title": "Team", "url": "/team", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 13, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 3, "type": "blog_categories", "title": "Blog", "url": "/blog", "reference": null, "code": "blog", "icon": null, "css_class": "drop", "target": "_self", "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 14, "menu_id": 3, "parent_id": 10, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "divider", "title": "", "url": null, "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 0, "created_at": null, "updated_at": null}
|
||||
{"id": 15, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "acme-page", "title": "Gone", "url": null, "reference": "99", "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 16, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 2, "type": "acme-page", "title": "About us", "url": null, "reference": "2", "code": null, "icon": null, "css_class": null, "target": "_blank", "is_published": 1, "created_at": null, "updated_at": null}
|
||||
{"id": 17, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 3, "type": "custom_link", "title": "Shop", "url": "https://shop.example.test", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
|
||||
`
|
||||
)
|
||||
|
||||
// TestMenuImportSmoke copies a Winter export through menu:import-winter:
|
||||
// link becomes url, blog_categories becomes all-journal-categories, a page
|
||||
// entry and its child are dropped and reported, a stale reference is
|
||||
// dropped, ids and parent_id are kept, both sequences move past the copied
|
||||
// ids, a dry run writes nothing, a second copy is refused without --force,
|
||||
// and empty files report zero.
|
||||
func TestMenuImportSmoke(t *testing.T) {
|
||||
e := bootMenu(t)
|
||||
ctx := t.Context()
|
||||
reg, ok := e.app.Lookup[*signpost.Registry]()
|
||||
if !ok {
|
||||
t.Fatal("signpost registry is not published")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
menusFile := writeFile(t, dir, "menus.jsonl", winterMenus)
|
||||
entriesFile := writeFile(t, dir, "entries.jsonl", winterEntries)
|
||||
opts := console.WinterOptions{MenusFile: menusFile, EntriesFile: entriesFile, Maps: map[string]string{"custom_link": "url"}}
|
||||
|
||||
dry := opts
|
||||
dry.DryRun = true
|
||||
out, buf := testOutput()
|
||||
report, err := console.ImportWinterFiles(ctx, e.gdb, reg, dry, out)
|
||||
if err != nil || report.Menus != 2 || report.Entries != 5 || report.Dropped != 3 || report.Remapped != 4 {
|
||||
t.Fatalf("dry run: %+v %v\n%s", report, err, buf.String())
|
||||
}
|
||||
if m, n := e.counts(t); m != 0 || n != 0 {
|
||||
t.Fatalf("dry run wrote %d menus, %d entries", m, n)
|
||||
}
|
||||
|
||||
out, buf = testOutput()
|
||||
report, err = console.ImportWinterFiles(ctx, e.gdb, reg, opts, out)
|
||||
if err != nil || report.Entries != 5 {
|
||||
t.Fatalf("import: %+v %v\n%s", report, err, buf.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"entry 10 'Home' (menu 3, type 'link'): type 'link' mapped to 'url'",
|
||||
"entry 11 'About' (menu 3, type 'page', reference 'about'): dropped (page entries (Winter.Pages) are not supported)",
|
||||
"entry 12 'Team' (menu 3, type 'link'): dropped (dropped with its parent 11)",
|
||||
"entry 13 'Blog' (menu 3, type 'blog_categories'): type 'blog_categories' mapped to 'all-journal-categories'",
|
||||
"entry 15 'Gone' (menu 7, type 'acme-page', reference '99'): dropped (reference '99' is not among the current references of 'acme-page')",
|
||||
"entry 17 'Shop' (menu 7, type 'custom_link'): type 'custom_link' mapped to 'url'",
|
||||
} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("report lacks %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
var entries []models.MenuEntry
|
||||
if err := e.gdb.Order("id").Find(&entries).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []uint{}
|
||||
for _, en := range entries {
|
||||
ids = append(ids, en.ID)
|
||||
}
|
||||
if fmt.Sprint(ids) != "[10 13 14 16 17]" {
|
||||
t.Fatalf("copied ids = %v", ids)
|
||||
}
|
||||
if en := entries[0]; en.Type != "url" || en.MenuID != 3 || intOf(en.SortOrder) != 1 {
|
||||
t.Fatalf("entry 10 = %+v", en)
|
||||
}
|
||||
if en := entries[1]; en.Type != "all-journal-categories" || models.Text(en.URL) != "/blog" || models.Text(en.Code) != "blog" || models.Text(en.Target) != "_self" {
|
||||
t.Fatalf("entry 13 = %+v", en)
|
||||
}
|
||||
if en := entries[2]; en.ParentID == nil || *en.ParentID != 10 || en.IsPublished == nil || *en.IsPublished || en.Type != "divider" {
|
||||
t.Fatalf("entry 14 = %+v", en)
|
||||
}
|
||||
if en := entries[3]; en.Type != "acme-page" || models.Text(en.Reference) != "2" || en.MenuID != 7 || en.URLPattern != nil {
|
||||
t.Fatalf("entry 16 = %+v", en)
|
||||
}
|
||||
var footer models.Menu
|
||||
if err := e.gdb.First(&footer, 7).Error; err != nil || footer.IsActive || footer.Code != "footer" {
|
||||
t.Fatalf("menu 7 = %+v %v", footer, err)
|
||||
}
|
||||
|
||||
// Both sequences moved past the copied ids.
|
||||
token := e.admin(t, permManage)
|
||||
if id := e.create(t, token, menusPath, map[string]any{"name": "Side", "code": "side", "is_active": true}); id != 8 {
|
||||
t.Fatalf("new menu id = %d, want 8", id)
|
||||
}
|
||||
if id := e.create(t, token, entriesPath, map[string]any{"menu": 3, "type": "url", "title": "New", "url": "/new"}); id != 18 {
|
||||
t.Fatalf("new entry id = %d, want 18", id)
|
||||
}
|
||||
|
||||
// A second copy is refused without --force and changes nothing; --force
|
||||
// replaces both tables.
|
||||
out, buf = testOutput()
|
||||
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, opts, out); err == nil || !strings.Contains(err.Error(), "--force") {
|
||||
t.Fatalf("second import: %v\n%s", err, buf.String())
|
||||
}
|
||||
if m, n := e.counts(t); m != 3 || n != 6 {
|
||||
t.Fatalf("refused import changed the tables: %d menus, %d entries", m, n)
|
||||
}
|
||||
forced := opts
|
||||
forced.Force = true
|
||||
out, buf = testOutput()
|
||||
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, forced, out); err != nil {
|
||||
t.Fatalf("forced import: %v\n%s", err, buf.String())
|
||||
}
|
||||
if m, n := e.counts(t); m != 2 || n != 5 {
|
||||
t.Fatalf("forced import left %d menus, %d entries", m, n)
|
||||
}
|
||||
|
||||
// Empty files write nothing and report zero.
|
||||
empty := writeFile(t, dir, "empty.jsonl", "")
|
||||
out, buf = testOutput()
|
||||
if report, err := console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{MenusFile: empty, EntriesFile: empty}, out); err != nil || report.Menus != 0 || report.Entries != 0 || !strings.Contains(buf.String(), "0 menus, 0 entries") {
|
||||
t.Fatalf("empty files: %+v %v\n%s", report, err, buf.String())
|
||||
}
|
||||
if m, n := e.counts(t); m != 2 || n != 5 {
|
||||
t.Fatalf("empty files changed the tables: %d menus, %d entries", m, n)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user