From 28350a15bb0a7236e8b51d4af55023c13dfc646b Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Tue, 8 Sep 2026 19:51:22 +0800 Subject: [PATCH] =?UTF-8?q?fix=F0=9F=90=9B:=20repair=20the=20row=20a=20ret?= =?UTF-8?q?ry=20reuses=20instead=20of=20walking=20past=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idempotency check added in the previous commit stopped a retry inserting a second copy, and introduced a quieter failure in its place: a retry that found an existing sys_menu row skipped everything after the insert. Those are the sys_menu_api_rule bindings and the materialized path, and neither is written by the statement that writes the menu - paths is a separate UPDATE, and on MySQL an earlier DDL has already committed the transaction that was supposed to hold them together. So an install interrupted between those steps left a menu that exists, sits outside the tree with an empty path, and is bound to no API. What core's contract.md says about such a menu is that it is invisible to every role and its apis are authorized for no one - while the installer reports success. Reusing now repairs. paths is compared before it is written, so a row that is already right is not touched. Bindings are inserted with WHERE NOT EXISTS rather than deleted and rebuilt: an administrator can bind an api to a menu from the menu screen, and delete-then-rebuild would take that with it on the next retry - the same accident as sys_role.go's Association.Delete, pointing the other way. Confirmed as a defect before it was fixed, by building the half-written state and watching the assertions fail: dir.Paths = "", want "/0/1" binding count for list = 0, want 1 Four paths through the repair, each with a degradation that reds its own test and leaves the others green: missing bindings only, missing paths only, both, and neither. The fourth asserts no UPDATE is issued for a row already correct. A fifth covers what the repair must not do. Rebuilding bindings instead of inserting them leaves every other test green while silently deleting a binding this code did not create; that one now fails with "a retry silently deleted a binding it does not own". Bindings an older version of a manifest created and a newer one no longer lists are left alone. Removing them is a delete, and a delete needs the same certainty about ownership that uninstall does - this function cannot tell a stale binding from one somebody added by hand. --- app/admin/service/seed.go | 153 ++++++++++++--- app/admin/service/seed_test.go | 336 +++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+), 29 deletions(-) diff --git a/app/admin/service/seed.go b/app/admin/service/seed.go index 32849f97..e856566e 100644 --- a/app/admin/service/seed.go +++ b/app/admin/service/seed.go @@ -100,6 +100,13 @@ func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec, // inserting a second one - see the design doc §1.6: a migration retried // after a partial failure previously re-ran this as a bare tx.Create and // produced duplicate rows on the demo site. +// +// Unlike seedMenuTree's reuse branch, this one has nothing left to repair +// after finding an existing row: models.SysApi carries no association +// (nothing like SysMenu's many2many SysApi field) and this function writes +// nothing beyond the row itself - no second statement comparable to +// seedMenuTree's paths UPDATE follows tx.Create below. An interrupted retry +// can therefore only ever find this row complete or not find it at all. func seedApis(tx *gorm.DB, appCode string, apis []seed.ApiSpec) (map[string]models.SysApi, error) { seen := make(map[string]bool, len(apis)) rows := make(map[string]models.SysApi, len(apis)) @@ -176,30 +183,12 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma continue } - // Idempotency check, ahead of resolving the parent: an already - // existing row does not need to wait on anything else in this - // call, and this is what lets a retried, partially-failed - // migration ask "did I already write this node" instead of - // inserting a second one (design doc §1.6). The natural key is - // (app_code, seed_code) - menu_name's PascalCase concatenation - // is not injective and cannot be used for this (see menuName's - // doc comment and the design doc §1.6). Only a live row counts; - // the soft-delete plugin scopes deleted_at = 0 automatically on - // every query against models.SysMenu. - var existing models.SysMenu - err := tx.Where("app_code = ? AND seed_code = ?", appCode, s.Code).First(&existing).Error - switch { - case err == nil: - created[s.Code] = existing - ids = append(ids, existing.MenuId) - progressed = true - continue - case errors.Is(err, gorm.ErrRecordNotFound): - // Not written yet; fall through to create it below. - default: - return nil, fmt.Errorf("%q: checking for an existing row: %w", s.Code, err) - } - + // Resolved before the idempotency check below, whether or not + // this spec's own row turns out to already exist: repairing an + // existing-but-incomplete row's paths needs the parent's + // already-resolved Paths exactly as much as creating a fresh + // row does (see repairExistingMenu), so both have to wait for + // it the same way. var parentRow models.SysMenu if s.Parent != "" { parent, ok := created[s.Parent] @@ -212,6 +201,31 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma parentRow = parent } + // Idempotency check: does this node already have a row, from + // an earlier, possibly-interrupted attempt? The natural key is + // (app_code, seed_code) - menu_name's PascalCase concatenation + // is not injective and cannot be used for this (see menuName's + // doc comment and the design doc §1.6). Only a live row counts; + // the soft-delete plugin scopes deleted_at = 0 automatically on + // every query against models.SysMenu. + var existing models.SysMenu + err := tx.Where("app_code = ? AND seed_code = ?", appCode, s.Code).First(&existing).Error + switch { + case err == nil: + row, err := repairExistingMenu(tx, existing, s, parentRow, apiRows) + if err != nil { + return nil, fmt.Errorf("%q: repairing an existing row: %w", s.Code, err) + } + created[s.Code] = row + ids = append(ids, row.MenuId) + progressed = true + continue + case errors.Is(err, gorm.ErrRecordNotFound): + // Not written yet; fall through to create it below. + default: + return nil, fmt.Errorf("%q: checking for an existing row: %w", s.Code, err) + } + seedCode := s.Code row := models.SysMenu{ MenuName: menuName(appCode, s.Code), @@ -254,11 +268,7 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma // two-step create-then-update 1786700001000_demo_menu.go's // hand-assigned ids let it do in one literal, sequenced here // instead. - if s.Parent == "" { - row.Paths = "/0/" + strconv.Itoa(row.MenuId) - } else { - row.Paths = parentRow.Paths + "/" + strconv.Itoa(row.MenuId) - } + row.Paths = expectedPaths(row.MenuId, s.Parent, parentRow) if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId). Update("paths", row.Paths).Error; err != nil { return nil, fmt.Errorf("%q: writing paths: %w", s.Code, err) @@ -275,6 +285,91 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma return ids, nil } +// expectedPaths is the materialized path a fresh insert of menuID under +// parent (or at the root, if parent is "") computes - factored out so +// repairExistingMenu can ask the same question about a row it did not just +// create. +func expectedPaths(menuID int, parent string, parentRow models.SysMenu) string { + if parent == "" { + return "/0/" + strconv.Itoa(menuID) + } + return parentRow.Paths + "/" + strconv.Itoa(menuID) +} + +// repairExistingMenu brings a row seedMenuTree's idempotency check found up +// to what a fresh insert of the same spec would have produced. +// +// A row can be found and still be incomplete: tx.Create's own association +// write (the sys_menu_api_rule bindings from row.SysApi) and the paths +// UPDATE that follows it are each their own statement, and design doc §1.5 +// establishes that nothing after the first DDL in a migration function can +// be rolled back together - a process interrupted between the row insert +// and either of those two steps leaves exactly this row: present, findable +// by its natural key, but missing what makes it a working menu entry. A +// retry that only checked "does the row exist" and stopped there would +// report success while the sys_menu_api_rule binding stays missing (the +// api is granted to no one) or paths stays empty (a materialized-path +// break that orphans the rest of the subtree from the root) - as silent as +// the duplicate-row defect the idempotency check itself was written to +// close. +// +// Both checks are read-before-write, so a row that is already complete - +// the ordinary case on every retry after the first successful one - causes +// no writes at all: existing.Paths already equals what expectedPaths +// computes, and the sys_menu_api_rule INSERT is itself guarded by +// WHERE NOT EXISTS, the same idempotent-insert shape grantToAdminRole +// already uses for sys_role_menu/casbin_rule. Never DELETEs an existing +// binding to rebuild it - that is the FullSaveAssociations mistake +// sys_role.go's SysRole.Update makes for sys_role_menu/casbin_rule +// (app/admin/service/sys_role.go:148-153), the exact pattern this design +// went out of its way to avoid for the tables that do use it. +// +// Insert-only cuts both ways, deliberately. A binding an administrator +// added by hand through the menu management UI, for an api never in +// s.ApiCodes at all, is never touched by this loop and survives every +// later retry (TestSeedMenusPreservesAHandAddedBinding is the reproduction +// case for the opposite mistake: delete-then-reinsert wipes it silently, +// the same shape as sys_role_menu/casbin_rule getting zeroed by a role +// edit, just with this code as the actor instead of the victim). The +// converse case - a MenuSpec that used to list an ApiCode and no longer +// does - is not handled here either, and that half is intentional rather +// than an oversight: this loop only ever adds rows for codes the *current* +// call's ApiCodes names, so a binding for a code an earlier version +// granted and the current one dropped is left in place, stale. Reconciling +// that is deleting something, which needs the same certainty about +// ownership uninstall's design (see design doc §5) already requires - +// this function has no way to tell "stale, from an older version of this +// same app" apart from "hand-added, for a reason", and business rule 3 +// ("uninstall deletes only what it can attribute with certainty") applies +// here just as much as it does there. Reconciling stale seed-driven +// bindings, if it is ever wanted, belongs in the upgrade path with that +// same ownership check - not silently inside every retry of every install. +func repairExistingMenu(tx *gorm.DB, existing models.SysMenu, s seed.MenuSpec, parentRow models.SysMenu, apiRows map[string]models.SysApi) (models.SysMenu, error) { + want := expectedPaths(existing.MenuId, s.Parent, parentRow) + if existing.Paths != want { + if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", existing.MenuId). + Update("paths", want).Error; err != nil { + return models.SysMenu{}, fmt.Errorf("repairing paths: %w", err) + } + existing.Paths = want + } + + for _, code := range s.ApiCodes { + api, ok := apiRows[code] + if !ok { + return models.SysMenu{}, fmt.Errorf("ApiCodes references %q, which is not an ApiSpec.Code in this call", code) + } + if err := tx.Exec( + "INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_menu_api_rule WHERE sys_menu_menu_id = ? AND sys_api_id = ?)", + existing.MenuId, api.Id, existing.MenuId, api.Id, + ).Error; err != nil { + return models.SysMenu{}, fmt.Errorf("binding %q: %w", code, err) + } + } + + return existing, nil +} + // validateMenuSpec rejects the malformed input tools/checksilent's // menu-sort-overflow and Kind-adjacent checks would catch for an in-tree // seed but cannot for a third-party application's - see menuSortRange's doc diff --git a/app/admin/service/seed_test.go b/app/admin/service/seed_test.go index fac7d17d..9fc02557 100644 --- a/app/admin/service/seed_test.go +++ b/app/admin/service/seed_test.go @@ -1,13 +1,17 @@ package service import ( + "context" "errors" "strconv" "strings" + "sync" "testing" + "time" "github.com/glebarez/sqlite" "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models" "github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed" @@ -505,3 +509,335 @@ func assertRowCount(t *testing.T, db *gorm.DB, table string, want int64) { t.Errorf("%s has %d row(s), want %d", table, n, want) } } + +// A retried migration does not just risk inserting a second copy of a row +// it already wrote (that gap is closed above) - the reuse path itself has +// to leave the row in the same state a fresh insert would have. Before +// this defect was fixed, the reuse branch (seed.go's "case err == nil") +// stopped at reusing the row's id and skipped everything a fresh insert +// does afterwards: the sys_menu_api_rule binding gorm's association save +// writes as part of Create, and the paths UPDATE that follows Create as a +// separate statement. A row a prior attempt inserted but did not finish - +// exactly the shape design doc §1.5 says a non-transactional retry can +// leave behind - would be "found" and then left broken forever, with the +// migration reporting success. +// +// existingHalfWrittenMenu inserts a sys_menu row the way seedMenuTree's own +// tx.Create leaves one when interrupted immediately afterwards: the row +// exists with its natural key, but paths was never computed and no +// sys_menu_api_rule binding was ever written for it - Create's association +// save and the paths UPDATE are each a separate statement from the row +// insert itself. +func existingHalfWrittenMenu(t *testing.T, db *gorm.DB, appCode, seedCode string, parentID int) models.SysMenu { + t.Helper() + code := seedCode + row := models.SysMenu{ + MenuName: menuName(appCode, seedCode), + AppCode: appCode, + SeedCode: &code, + ParentId: parentID, + Visible: "0", + IsFrame: "1", + // Paths deliberately left "" - never computed, the same as a row + // whose Create succeeded but whose follow-up paths UPDATE never ran. + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed half-written menu %q: %v", seedCode, err) + } + return row +} + +func bindingCount(t *testing.T, db *gorm.DB, menuID, apiID int) int64 { + t.Helper() + var n int64 + if err := db.Table("sys_menu_api_rule"). + Where("sys_menu_menu_id = ? AND sys_api_id = ?", menuID, apiID).Count(&n).Error; err != nil { + t.Fatalf("count sys_menu_api_rule: %v", err) + } + return n +} + +// TestSeedMenusRepairsAnIncompleteExistingRow is the reproduction case: +// both paths and the api binding are missing on the row seedMenuTree finds +// through its idempotency check, the shape a real interrupted retry leaves +// behind. Run against the unfixed reuse branch, this must fail - that is +// what proves the defect is real rather than a three-way guess. +func TestSeedMenusRepairsAnIncompleteExistingRow(t *testing.T) { + db := newSeedTestDB(t) + useCompositeSeedCodeIndex(t, db) + seedAdminRole(t, db) + + apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}} + apiRows, err := seedApis(db, "order", apis) + if err != nil { + t.Fatalf("seedApis: %v", err) + } + + dir := existingHalfWrittenMenu(t, db, "order", "dir", 0) + list := existingHalfWrittenMenu(t, db, "order", "list", dir.MenuId) + + menus := []seed.MenuSpec{ + {Code: "dir", Kind: contractmodels.Directory, Title: "Order", Sort: 10}, + {Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Sort: 1, ApiCodes: []string{"list"}}, + } + + if err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", menus, apis) + }); err != nil { + t.Fatalf("SeedMenus: %v", err) + } + + wantDirPaths := "/0/" + strconv.Itoa(dir.MenuId) + wantListPaths := wantDirPaths + "/" + strconv.Itoa(list.MenuId) + + var gotDir, gotList models.SysMenu + if err := db.First(&gotDir, dir.MenuId).Error; err != nil { + t.Fatalf("read dir: %v", err) + } + if err := db.First(&gotList, list.MenuId).Error; err != nil { + t.Fatalf("read list: %v", err) + } + if gotDir.Paths != wantDirPaths { + t.Errorf("dir.Paths = %q, want %q - a retried install left a root menu with no materialized path", gotDir.Paths, wantDirPaths) + } + if gotList.Paths != wantListPaths { + t.Errorf("list.Paths = %q, want %q - a retried install left the seeded subtree with a broken materialized path", gotList.Paths, wantListPaths) + } + if n := bindingCount(t, db, list.MenuId, apiRows["list"].Id); n != 1 { + t.Errorf("sys_menu_api_rule binding count for list = %d, want 1 - a retried install left the menu with its api granted to no one", n) + } +} + +// soloMenuSpec is a single, parent-less menu with one api binding - the +// smallest shape that can exhibit "paths wrong" and "binding missing" +// independently of each other, used by the three tests below to isolate +// one repair path at a time from TestSeedMenusRepairsAnIncompleteExistingRow's +// combined (both broken) case. +func soloMenuSpec() []seed.MenuSpec { + return []seed.MenuSpec{{Code: "solo", Kind: contractmodels.Menu, Title: "Solo", Sort: 1, ApiCodes: []string{"list"}}} +} + +// Only the binding is missing; paths is already correct. The repair must +// add the binding and must not touch the already-correct paths value. +func TestSeedMenusRepairsOnlyAMissingBinding(t *testing.T) { + db := newSeedTestDB(t) + useCompositeSeedCodeIndex(t, db) + seedAdminRole(t, db) + + apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}} + apiRows, err := seedApis(db, "order", apis) + if err != nil { + t.Fatalf("seedApis: %v", err) + } + + solo := existingHalfWrittenMenu(t, db, "order", "solo", 0) + wantPaths := "/0/" + strconv.Itoa(solo.MenuId) + if err := db.Model(&models.SysMenu{}).Where("menu_id = ?", solo.MenuId). + Update("paths", wantPaths).Error; err != nil { + t.Fatalf("set paths: %v", err) + } + // The binding is deliberately left unwritten. + + if err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", soloMenuSpec(), apis) + }); err != nil { + t.Fatalf("SeedMenus: %v", err) + } + + var got models.SysMenu + if err := db.First(&got, solo.MenuId).Error; err != nil { + t.Fatalf("read solo: %v", err) + } + if got.Paths != wantPaths { + t.Errorf("paths changed from %q to %q; repairing a missing binding must not touch an already-correct path", wantPaths, got.Paths) + } + if n := bindingCount(t, db, solo.MenuId, apiRows["list"].Id); n != 1 { + t.Errorf("binding count = %d, want 1", n) + } +} + +// Only paths is missing; the binding already exists (as if Create's own +// association write had succeeded but the paths UPDATE that follows it +// never ran). The repair must fix paths and must not duplicate the +// already-correct binding. +func TestSeedMenusRepairsOnlyMissingPaths(t *testing.T) { + db := newSeedTestDB(t) + useCompositeSeedCodeIndex(t, db) + seedAdminRole(t, db) + + apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}} + apiRows, err := seedApis(db, "order", apis) + if err != nil { + t.Fatalf("seedApis: %v", err) + } + + solo := existingHalfWrittenMenu(t, db, "order", "solo", 0) + if err := db.Exec( + "INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) VALUES (?, ?)", + solo.MenuId, apiRows["list"].Id, + ).Error; err != nil { + t.Fatalf("seed binding: %v", err) + } + // solo.Paths is deliberately left "" by existingHalfWrittenMenu. + + if err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", soloMenuSpec(), apis) + }); err != nil { + t.Fatalf("SeedMenus: %v", err) + } + + wantPaths := "/0/" + strconv.Itoa(solo.MenuId) + var got models.SysMenu + if err := db.First(&got, solo.MenuId).Error; err != nil { + t.Fatalf("read solo: %v", err) + } + if got.Paths != wantPaths { + t.Errorf("paths = %q, want %q", got.Paths, wantPaths) + } + if n := bindingCount(t, db, solo.MenuId, apiRows["list"].Id); n != 1 { + t.Errorf("binding count = %d, want 1 - repairing paths must not duplicate an already-correct binding", n) + } +} + +// capturingLogger records every SQL statement gorm actually executes, so a +// test can assert that a fully-consistent retry performs no write at all - +// not just that its net effect happens to be zero rows changed. Mirrors +// common/actions/crud_shim_test.go's logger of the same name and shape; +// duplicated locally rather than exported and shared, matching how small +// gorm-facing test doubles are kept next to the test that needs them +// elsewhere in this repository. +type capturingLogger struct { + gormlogger.Interface + mu sync.Mutex + stmts []string +} + +func (l *capturingLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + sql, _ := fc() + l.mu.Lock() + l.stmts = append(l.stmts, sql) + l.mu.Unlock() +} + +func (l *capturingLogger) all() string { + l.mu.Lock() + defer l.mu.Unlock() + return strings.Join(l.stmts, "\n") +} + +// Both paths and the binding are already correct - the ordinary shape of +// every retry after the first one succeeds in full. Repairing an +// already-consistent row must not touch it: paths is read-before-write and +// so must not be UPDATEd at all (asserted directly, by statement, since the +// code gates that call behind a value comparison); the binding's own +// insert is guarded by WHERE NOT EXISTS the same way grantToAdminRole's +// already are, so its row count staying put is the meaningful claim - the +// guarded statement itself may still be sent, the same way it already is +// for sys_role_menu/casbin_rule. +func TestSeedMenusFullyConsistentRowCausesNoPathsUpdate(t *testing.T) { + db := newSeedTestDB(t) + useCompositeSeedCodeIndex(t, db) + seedAdminRole(t, db) + + apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}} + menus := soloMenuSpec() + + if err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", menus, apis) + }); err != nil { + t.Fatalf("SeedMenus (first): %v", err) + } + + var apiRows []models.SysApi + db.Where("app_code = ?", "order").Find(&apiRows) + var soloRow models.SysMenu + if err := db.Where("app_code = ? AND seed_code = ?", "order", "solo").First(&soloRow).Error; err != nil { + t.Fatalf("read solo after first call: %v", err) + } + if soloRow.Paths == "" { + t.Fatalf("solo.Paths is empty after the first call; the fixture itself is broken, not what this test means to check") + } + wantBindings := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id) + if wantBindings != 1 { + t.Fatalf("binding count after the first call = %d, want 1; the fixture itself is broken", wantBindings) + } + + capturing := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Info)} + captured := db.Session(&gorm.Session{Logger: capturing}) + + if err := captured.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", menus, apis) + }); err != nil { + t.Fatalf("SeedMenus (retry): %v", err) + } + + all := strings.ToUpper(capturing.all()) + if strings.Contains(all, "UPDATE") && strings.Contains(all, "SYS_MENU") && strings.Contains(all, "PATHS") { + t.Errorf("a fully consistent retry executed a paths UPDATE against sys_menu:\n%s", capturing.all()) + } + if got := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id); got != 1 { + t.Errorf("binding count after the retry = %d, want 1 (unchanged)", got) + } +} + +// An administrator can bind a menu to an additional api by hand through +// the menu management UI - a sys_menu_api_rule row for an api never in +// s.ApiCodes at all. A retried SeedMenus call must not touch it: deleting +// every binding for the menu and reinserting only what s.ApiCodes lists +// would wipe it out silently, the same shape as sys_role_menu/casbin_rule +// getting zeroed by SysRole.Update's FullSaveAssociations save - just with +// this code as the actor instead of the victim this time. +func TestSeedMenusPreservesAHandAddedBinding(t *testing.T) { + db := newSeedTestDB(t) + useCompositeSeedCodeIndex(t, db) + seedAdminRole(t, db) + + apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}} + menus := soloMenuSpec() + + if err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", menus, apis) + }); err != nil { + t.Fatalf("SeedMenus (first): %v", err) + } + + var soloRow models.SysMenu + if err := db.Where("app_code = ? AND seed_code = ?", "order", "solo").First(&soloRow).Error; err != nil { + t.Fatalf("read solo: %v", err) + } + + // An api this call's ApiSpec list never mentions - standing in for one + // belonging to some other feature entirely, bound to this menu by an + // administrator, not by any SeedMenus call. + handAdded := models.SysApi{Path: "/api/v1/order/export", Action: "GET", Type: "SYS", AppCode: "order"} + if err := db.Create(&handAdded).Error; err != nil { + t.Fatalf("seed the hand-added api: %v", err) + } + if err := db.Exec( + "INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) VALUES (?, ?)", + soloRow.MenuId, handAdded.Id, + ).Error; err != nil { + t.Fatalf("seed the hand-added binding: %v", err) + } + + // A retry with the exact same specs - solo's ApiCodes still names only + // "list". + if err := db.Transaction(func(tx *gorm.DB) error { + return adminSeeder{}.SeedMenus(tx, "order", menus, apis) + }); err != nil { + t.Fatalf("SeedMenus (retry): %v", err) + } + + if n := bindingCount(t, db, soloRow.MenuId, handAdded.Id); n != 1 { + t.Errorf("hand-added binding count = %d, want 1 - a retry silently deleted a binding it does not own", n) + } + + var apiRows []models.SysApi + db.Where("app_code = ? AND path = ?", "order", "/api/v1/order").Find(&apiRows) + if len(apiRows) != 1 { + t.Fatalf("seeded api not found as expected: %+v", apiRows) + } + if n := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id); n != 1 { + t.Errorf("the seed's own binding count = %d, want 1 - it must survive the retry too", n) + } +}