package menu_test import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "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" ) // testTypesPluginID is the fake content plugin that registers menu item // types for the smoke tests, so the menu plugin needs no content plugin. const testTypesPluginID = "acme.menutypes" const permManage = "golem15.menu.manage_menu" // 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 } func (acmeTypes) Requires() []string { return nil } func (acmeTypes) Register(*backpack.App) error { return nil } func (acmeTypes) Boot(*backpack.App) error { return nil } func (acmeTypes) MenuItemTypes() []pact.MenuItemType { return []pact.MenuItemType{ { Key: "acme-page", Label: "Acme page", DefaultPattern: "/pages/:slug", References: func(context.Context) ([]pact.MenuItemReference, error) { return []pact.MenuItemReference{{Value: "1", Label: "Home", Items: []pact.MenuItemReference{{Value: "2", Label: "About"}}}}, nil }, Resolve: resolvePage, }, { Key: "acme-list", Label: "Acme pages", DefaultPattern: "/pages/:slug", PatternFrom: "acme-page", Nesting: true, DynamicItems: true, Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { var items []pact.ResolvedMenuItem for _, slug := range []string{"home", "about"} { url := strings.ReplaceAll(in.Pattern, ":slug", slug) items = append(items, pact.ResolvedMenuItem{ Title: slug, URL: url, Alternates: []pact.MenuItemAlternate{{Locale: "pl", URL: url, Title: slug}, {Locale: "en", URL: "/en" + url, Title: slug + " (en)"}}, }) } 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 }, }, } } // resolvePage resolves references 1 (home) and 2 (about) with the layered // pattern; any other reference no longer exists. func resolvePage(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { slugs := map[string]string{"1": "home", "2": "about"} slug, ok := slugs[in.Reference] if !ok { return nil, nil } url := strings.ReplaceAll(in.Pattern, ":slug", slug) return &pact.ResolvedMenuItem{ Title: slug, URL: url, Alternates: []pact.MenuItemAlternate{ {Locale: "pl", URL: url, Title: slug}, {Locale: "en", URL: "/en" + url, Title: slug + " (en)"}, }, }, nil } var _ pact.HasMenuItemTypes = acmeTypes{} func init() { party.Register(acmeTypes{}) } // menuEnv is a booted application with the menu plugin and the fake types. type menuEnv struct { h http.Handler app *backpack.App gdb *gorm.DB } func testConfig(t *testing.T) *compass.Config { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: menu-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{ Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=menu-smoke-test-only-hs256-secret-key-0001"}, }) if err != nil { t.Fatal(err) } for key, value := range map[string]any{ "http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608, "mail.driver": "log", "mail.from": "noreply@example.test", } { if err := cfg.Set(key, value); err != nil { t.Fatal(err) } } return cfg } func bootMenu(t *testing.T) *menuEnv { t.Helper() sqlDB := freshDB(t) gdb, err := lagoon.Use(t.Context(), sqlDB) if err != nil { t.Fatal(err) } app := backpack.New(testConfig(t)) if err := lagoon.Publish(app, sqlDB, gdb); err != nil { t.Fatal(err) } plugins, err := party.Activate(app, []string{testTypesPluginID, "golem15.menu"}) if err != nil { t.Fatal(err) } if err := lagoon.Migrate(gdb, plugins); err != nil { t.Fatal(err) } h, err := surf.Assemble(app, plugins) if err != nil { t.Fatal(err) } return &menuEnv{h: h, app: app, gdb: gdb} } var adminSeq atomic.Uint64 // call sends an admin API request with an optional bearer token and JSON // body. func (e *menuEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() var reader io.Reader if body != nil { raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } reader = bytes.NewReader(raw) } req := httptest.NewRequest(method, cabana.DefaultAdminPrefix+"/api/v1"+path, reader) req.Host = "api.example.test" req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1) req.Header.Set("Accept-Language", "en") if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } // admin signs in a new administrator whose role grants permissions. func (e *menuEnv) admin(t *testing.T, permissions ...string) string { t.Helper() stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1)) login, password := "admin-"+stamp, "admin-secret-"+stamp hash, err := bouncer.HashPassword(10, password) if err != nil { t.Fatal(err) } grants := map[string]int{} for _, code := range permissions { grants[code] = 1 } raw, _ := json.Marshal(grants) var roleID uint if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 { t.Fatalf("backend role: id=%d err=%v", roleID, err) } if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`, login, login+"@example.test", hash, roleID).Error; err != nil { t.Fatal(err) } rec := e.call(t, http.MethodPost, "/auth/login", "", map[string]string{"login": login, "password": password}) var body struct { Data struct { AccessToken string `json:"access_token"` } `json:"data"` } if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" { t.Fatalf("admin login: %d %s", rec.Code, rec.Body.String()) } return body.Data.AccessToken } // create posts a record and returns its id; it fails the test unless the // answer is 201. func (e *menuEnv) create(t *testing.T, token, path string, body map[string]any) uint { t.Helper() rec := e.call(t, http.MethodPost, path, token, body) var out struct { Data struct { ID uint `json:"id"` } `json:"data"` } if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.Data.ID == 0 { t.Fatalf("POST %s %v: %d %s", path, body, rec.Code, rec.Body.String()) } return out.Data.ID } func (e *menuEnv) entry(t *testing.T, id uint) models.MenuEntry { t.Helper() var entry models.MenuEntry if err := e.gdb.First(&entry, id).Error; err != nil { t.Fatal(err) } return entry } func intOf(p *int) int { if p == nil { return -1 } return *p } // expect422 posts or puts body and checks for a 422 naming field and, // when key is set, the phrase key. func (e *menuEnv) expect422(t *testing.T, token, method, path string, body map[string]any, field, key string) { t.Helper() rec := e.call(t, method, path, token, body) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+field+`":`) || (key != "" && !strings.Contains(rec.Body.String(), key)) { t.Fatalf("%s %s %v: %d %s, want 422 on %s %s", method, path, body, rec.Code, rec.Body.String(), field, key) } } const ( menusPath = "/golem15/menu/menus" entriesPath = "/golem15/menu/menuentries" ) // TestMenuAdminSmoke drives the menus and menu entries screens: the PHP save // rules, the D-05 type set, the reference recheck, the URL scheme and target // refusals, the parent cycle refusal and the tree reorder page scoped to one // menu. func TestMenuAdminSmoke(t *testing.T) { e := bootMenu(t) token := e.admin(t, permManage) mainID := e.create(t, token, menusPath, map[string]any{"name": "Main", "code": "main", "is_active": true}) e.expect422(t, token, http.MethodPost, menusPath, map[string]any{"name": "Again", "code": "main", "is_active": true}, "code", "") // The form offers the built-ins and the registry types; the reference // depends on the type. rec := e.call(t, http.MethodGet, entriesPath+"/schema/form", token, nil) for _, want := range []string{`"value":"url"`, `"value":"divider"`, `"value":"acme-page"`, `"value":"acme-list"`, `"dependsOn":"type"`} { if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) { t.Fatalf("form schema lacks %s: %d %s", want, rec.Code, rec.Body.String()) } } rec = e.call(t, http.MethodGet, entriesPath+"/fields/reference/dependent-options?values%5Btype%5D=acme-page", token, nil) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"value":"2"`) || !strings.Contains(rec.Body.String(), `"applies":true`) { t.Fatalf("dependent options acme-page: %d %s", rec.Code, rec.Body.String()) } rec = e.call(t, http.MethodGet, entriesPath+"/fields/reference/dependent-options?values%5Btype%5D=url", token, nil) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"applies":false`) { t.Fatalf("dependent options url: %d %s", rec.Code, rec.Body.String()) } // The first entry; the second has no sort_order and goes after it with // zero nest columns. home := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Home", "url": "/", "target": "_self"}) divider := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "divider"}) d := e.entry(t, divider) if intOf(d.SortOrder) != intOf(e.entry(t, home).SortOrder)+1 || intOf(d.NestLeft) != 0 || intOf(d.NestRight) != 0 || intOf(d.NestDepth) != 0 || models.Text(d.Title) != "" || d.IsPublished == nil || !*d.IsPublished { t.Fatalf("divider = %+v", d) } page := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "acme-page", "title": "About", "reference": "2", "url_pattern": "/p/:slug"}) // A child without a menu takes its parent's menu. child := e.create(t, token, entriesPath, map[string]any{"parent": home, "type": "url", "title": "Contact", "url": "mailto:hello@example.test", "target": "_blank"}) if c := e.entry(t, child); c.MenuID != mainID || c.ParentID == nil || *c.ParentID != home { t.Fatalf("child = %+v", c) } // Refusals: a reference the type does not offer, a javascript: URL, an // unknown target, an unknown type, no menu at all, a parent cycle. e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "acme-page", "title": "Gone", "reference": "99"}, "reference", "") e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Evil", "url": "javascript:alert(1)"}, "url", "golem15.menu::lang.errors.url_scheme") e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Top", "url": "/", "target": "_top"}, "target", "golem15.menu::lang.errors.target") e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "page", "title": "Old"}, "type", "") e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"type": "url", "title": "Nowhere", "url": "/"}, "menu", "golem15.menu::lang.errors.menu_required") e.expect422(t, token, http.MethodPut, fmt.Sprintf("%s/%d", entriesPath, home), map[string]any{"parent": child}, "parent", "golem15.menu::lang.errors.parent_cycle") var n int64 e.gdb.Model(&models.MenuEntry{}).Count(&n) if n != 4 { t.Fatalf("refusals left %d entries, want 4", n) } // The list filtered on the menu, and the reorder tree of that menu. footerID := e.create(t, token, menusPath, map[string]any{"name": "Footer", "code": "footer", "is_active": true}) foreign := e.create(t, token, entriesPath, map[string]any{"menu": footerID, "type": "url", "title": "Imprint", "url": "/imprint"}) rec = e.call(t, http.MethodGet, fmt.Sprintf("%s?filter[menu_id]=%d", entriesPath, mainID), token, nil) if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "Imprint") || !strings.Contains(rec.Body.String(), "Contact") { t.Fatalf("filtered list: %d %s", rec.Code, rec.Body.String()) } rec = e.call(t, http.MethodGet, fmt.Sprintf("%s/reorder?partition=%d", entriesPath, mainID), token, nil) if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "Imprint") || !strings.Contains(rec.Body.String(), `"title":"Reorder menu entries"`) { t.Fatalf("reorder tree: %d %s", rec.Code, rec.Body.String()) } // Nest the acme page under Home after Contact; the divider moves up. nodes := []map[string]any{ {"id": home, "parent_id": nil, "sort_order": 1}, {"id": child, "parent_id": home, "sort_order": 1}, {"id": page, "parent_id": home, "sort_order": 2}, {"id": divider, "parent_id": nil, "sort_order": 2}, } rec = e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes}) if rec.Code != http.StatusOK { t.Fatalf("reorder: %d %s", rec.Code, rec.Body.String()) } if p := e.entry(t, page); p.ParentID == nil || *p.ParentID != home || intOf(p.SortOrder) != 2 { t.Fatalf("reordered page = %+v", p) } if dv := e.entry(t, divider); dv.ParentID != nil || intOf(dv.SortOrder) != 2 { t.Fatalf("reordered divider = %+v", dv) } // A reorder naming an entry of another menu changes nothing. nodes = append(nodes, map[string]any{"id": foreign, "parent_id": nil, "sort_order": 3}) rec = e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes}) if rec.Code != http.StatusConflict { t.Fatalf("reorder with a foreign entry: %d %s", rec.Code, rec.Body.String()) } // Without the permission the screens answer 403. other := e.admin(t) for _, path := range []string{menusPath, entriesPath} { if rec := e.call(t, http.MethodGet, path, other, nil); rec.Code != http.StatusForbidden { t.Errorf("GET %s without permission: %d", path, rec.Code) } } } 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) } }