package repository import ( "database/sql" "testing" "github.com/wmms/wmms/internal/model" ) // seedApplicationIsolation 复用两公司物料/仓库基础数据,并补一个跨两公司都授权、可用同一 // 用户在 A/B 各自建申请的用户。company 101=公司A,102=公司B。 func seedApplicationIsolation(t *testing.T, db *sql.DB) { t.Helper() seedOrderIsolation(t, db) mustSQL(t, db, `INSERT INTO users(id,username,password) VALUES (7,'shared','x')`) } func newIsolationApplication(organizationID, materialID int64) *model.MaterialApplication { return &model.MaterialApplication{ OrganizationID: organizationID, UserID: 7, Purpose: "领用", Status: model.ApplicationStatusPending, Items: []model.ApplicationItem{{MaterialID: materialID, Quantity: 2}}, } } // 同一用户在 A/B 各自创建的申请在对应公司可见,跨公司读不到。 func TestApplicationRepoOrganizationIsolationReads(t *testing.T) { db := isolationDB(t, false) seedApplicationIsolation(t, db) repo := NewApplicationRepository() appA := newIsolationApplication(101, 101) appB := newIsolationApplication(102, 102) if err := repo.Create(appA); err != nil { t.Fatal(err) } if err := repo.Create(appB); err != nil { t.Fatal(err) } own, err := repo.GetByID(101, appA.ID) if err != nil || own.OrganizationID != 101 || len(own.Items) != 1 || own.Items[0].MaterialID != 101 { t.Fatalf("A 读自己的申请失败: %+v err=%v", own, err) } if _, err := repo.GetByID(101, appB.ID); err != sql.ErrNoRows { t.Fatalf("A 读 B 的申请必须 ErrNoRows, got %v", err) } if _, err := repo.GetByID(102, appA.ID); err != sql.ErrNoRows { t.Fatalf("B 读 A 的申请必须 ErrNoRows, got %v", err) } appsA, totalA, err := repo.ListAllApplications(101, "", 20, 0) if err != nil || totalA != 1 || len(appsA) != 1 || appsA[0].ID != appA.ID { t.Fatalf("A 审批列表=%+v total=%d err=%v", appsA, totalA, err) } appsB, totalB, err := repo.ListAllApplications(102, "", 20, 0) if err != nil || totalB != 1 || len(appsB) != 1 || appsB[0].ID != appB.ID { t.Fatalf("B 审批列表=%+v total=%d err=%v", appsB, totalB, err) } mineA, mineTotalA, err := repo.ListByUserID(101, 7, "", 20, 0) if err != nil || mineTotalA != 1 || mineA[0].ID != appA.ID { t.Fatalf("A 我的申请=%+v total=%d err=%v", mineA, mineTotalA, err) } mineB, mineTotalB, err := repo.ListByUserID(102, 7, "", 20, 0) if err != nil || mineTotalB != 1 || mineB[0].ID != appB.ID { t.Fatalf("B 我的申请=%+v total=%d err=%v", mineB, mineTotalB, err) } } // 明细经组织化申请头表关联物料:头表属于 A,混入的 B 公司物料会被关联条件挡掉。 func TestApplicationRepoCrossCompanyItemHidden(t *testing.T) { db := isolationDB(t, false) seedApplicationIsolation(t, db) repo := NewApplicationRepository() appA := newIsolationApplication(101, 101) if err := repo.Create(appA); err != nil { t.Fatal(err) } // 物料外键合法(102 存在),但属于公司 B,读取时的同组织关联会把它挡掉。 mustSQL(t, db, `INSERT INTO application_items(application_id, material_id, quantity) VALUES (?, ?, ?)`, appA.ID, 102, 9) loaded, err := repo.GetByID(101, appA.ID) if err != nil { t.Fatal(err) } if len(loaded.Items) != 1 || loaded.Items[0].MaterialID != 101 { t.Fatalf("跨公司物料明细必须被关联条件挡掉, got %+v", loaded.Items) } } // 状态更新带公司、ID 和原状态条件:别家公司改不动;本公司重复审批/取消非待审也会被条件挡下。 func TestApplicationRepoStatusUpdatesScopedAndConditional(t *testing.T) { db := isolationDB(t, false) seedApplicationIsolation(t, db) repo := NewApplicationRepository() appA := newIsolationApplication(101, 101) if err := repo.Create(appA); err != nil { t.Fatal(err) } // 别家公司操作 A 的申请:公司条件不匹配,一律冲突。 if err := repo.Approve(102, appA.ID, 5); err != ErrApplicationStatusConflict { t.Fatalf("B 审批 A 的申请必须冲突, got %v", err) } if err := repo.Reject(102, appA.ID, "x"); err != ErrApplicationStatusConflict { t.Fatalf("B 驳回 A 的申请必须冲突, got %v", err) } if err := repo.Cancel(102, appA.ID); err != ErrApplicationStatusConflict { t.Fatalf("B 取消 A 的申请必须冲突, got %v", err) } if status := applicationStatus(t, db, appA.ID); status != model.ApplicationStatusPending { t.Fatalf("跨公司操作改变了状态: %s", status) } // 本公司首次审批成功;重复审批因原状态已变(非 pending)被条件更新挡下。 if err := repo.Approve(101, appA.ID, 5); err != nil { t.Fatal(err) } if status := applicationStatus(t, db, appA.ID); status != model.ApplicationStatusApproved { t.Fatalf("审批后状态=%s, want approved", status) } if err := repo.Approve(101, appA.ID, 5); err != ErrApplicationStatusConflict { t.Fatalf("重复审批必须被条件挡下, got %v", err) } if err := repo.Cancel(101, appA.ID); err != ErrApplicationStatusConflict { t.Fatalf("取消非待审申请必须冲突, got %v", err) } } // 审批事务锁定当前公司的申请:锁别家公司的申请命中空行。 func TestApplicationRepoLockForUpdateScopesToOrganization(t *testing.T) { db := isolationDB(t, false) seedApplicationIsolation(t, db) repo := NewApplicationRepository() appA := newIsolationApplication(101, 101) if err := repo.Create(appA); err != nil { t.Fatal(err) } tx, err := BeginTx() if err != nil { t.Fatal(err) } defer tx.Rollback() if _, err := repo.GetByIDForUpdateTx(tx, 102, appA.ID); err != sql.ErrNoRows { t.Fatalf("锁别家公司申请必须 ErrNoRows, got %v", err) } locked, err := repo.GetByIDForUpdateTx(tx, 101, appA.ID) if err != nil || locked.ID != appA.ID { t.Fatalf("锁本公司申请失败: %+v err=%v", locked, err) } } func applicationStatus(t *testing.T, db *sql.DB, id int64) string { t.Helper() var status string if err := db.QueryRow(`SELECT status FROM material_applications WHERE id = ?`, id).Scan(&status); err != nil { t.Fatal(err) } return status } // 服务启动只检查所需列:新库通过;缺少 organization_id 的旧库必须报明确的升级错误, // 而不是在新环境按旧定义建一张缺列的申请表。 func TestCheckApplicationSchema(t *testing.T) { t.Run("fresh schema passes", func(t *testing.T) { isolationDB(t, false) if err := NewApplicationRepository().CheckApplicationSchema(); err != nil { t.Fatalf("新库不应报缺列: %v", err) } }) t.Run("legacy schema is rejected", func(t *testing.T) { isolationDB(t, true) if err := NewApplicationRepository().CheckApplicationSchema(); err == nil { t.Fatal("缺少 organization_id 的旧库必须报升级错误") } }) }