material_manage_system/internal/service/admin_master_test.go
2026-10-09 09:56:21 +08:00

558 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"errors"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/pkg/database"
)
// 前端表单在「新增」时可能残留上次编辑过的 id(PartnerList.vue 的
// openCreate 只做 Object.assign、不删多余键)。创建必须无视这个 id,
// 否则会走成 UPDATE 把旧记录覆盖掉——这正是本次要修的缺陷。
func TestPartnerCreateInsertsEvenWhenItemCarriesStaleID(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO business_partners").WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectExec("INSERT INTO organization_partners").WillReturnResult(sqlmock.NewResult(2, 1))
mock.ExpectCommit()
item := &model.BusinessPartner{OrganizationID: 1, ID: 1, PartnerCode: "SUP-002", LegalName: "新伙伴", IsSupplier: true}
if err := NewPartnerService().Create(item); err != nil {
t.Fatalf("Create: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 INSERT,实际 SQL 不是插入: %v", err)
}
}
// 更新接口漏传 id 时必须报错,否则会静默写 0 号记录(什么都不改)而不自知。
func TestPartnerUpdateRequiresID(t *testing.T) {
err := NewPartnerService().Update(&model.BusinessPartner{OrganizationID: 1, PartnerCode: "SUP-001", LegalName: "伙伴", IsSupplier: true})
if err == nil {
t.Fatal("缺少 id 的更新应当被拒绝")
}
}
// 拆方法不能把校验丢了。
func TestPartnerCreateStillValidatesName(t *testing.T) {
err := NewPartnerService().Create(&model.BusinessPartner{OrganizationID: 1, PartnerCode: "SUP-001", IsSupplier: true})
if err == nil {
t.Fatal("legal_name 为空应当被拒绝")
}
}
func TestPartnerCreateStillRequiresARole(t *testing.T) {
err := NewPartnerService().Create(&model.BusinessPartner{OrganizationID: 1, PartnerCode: "SUP-001", LegalName: "伙伴"})
if err == nil {
t.Fatal("既非供应商也非客户时应当被拒绝")
}
}
// MasterDataSettings.vue 的 openCreate 同样是 Object.assign 复用表单,
// 分类新增也会带上前一次编辑残留的 id,必须走 INSERT。
func TestCategoryCreateInsertsEvenWhenItemCarriesStaleID(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("INSERT INTO material_categories").WillReturnResult(sqlmock.NewResult(9, 1))
item := &model.MaterialCategory{OrganizationID: 1, ID: 1, Code: "A99", Name: "新分类"}
if err := NewMasterDataService().CreateCategory(item); err != nil {
t.Fatalf("CreateCategory: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 INSERT,实际 SQL 不是插入: %v", err)
}
}
func TestUnitUpdateRequiresID(t *testing.T) {
err := NewMasterDataService().UpdateUnit(&model.Unit{OrganizationID: 1, Code: "pcs", Name: "件"})
if err == nil {
t.Fatal("缺少 id 的更新应当被拒绝")
}
}
func TestCodeRuleUpdateRequiresID(t *testing.T) {
err := NewMasterDataService().UpdateCodeRule(&model.CodeRule{OrganizationID: 1, CategoryID: 1, Prefix: "A", SequenceLength: 4})
if err == nil {
t.Fatal("缺少 id 的更新应当被拒绝")
}
}
// 单位的小数位校验不能因为拆方法而丢失。
func TestUnitCreateStillValidatesPrecisionScale(t *testing.T) {
err := NewMasterDataService().CreateUnit(&model.Unit{OrganizationID: 1, Code: "kg", Name: "千克", PrecisionScale: 9})
if err == nil {
t.Fatal("precision_scale 超过 6 应当被拒绝")
}
}
func TestWarehouseCreateInsertsEvenWhenItemCarriesStaleID(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("INSERT INTO warehouses").WillReturnResult(sqlmock.NewResult(5, 1))
item := &model.Warehouse{OrganizationID: 1, ID: 3, Code: "WH2", Name: "二号仓"}
if err := NewWarehouseService().Create(item); err != nil {
t.Fatalf("Create: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 INSERT,实际 SQL 不是插入: %v", err)
}
}
func TestWarehouseUpdateRequiresID(t *testing.T) {
err := NewWarehouseService().Update(&model.Warehouse{OrganizationID: 1, Code: "WH1", Name: "一号仓"})
if err == nil {
t.Fatal("缺少 id 的更新应当被拒绝")
}
}
func TestLocationCreateRequiresWarehouseID(t *testing.T) {
err := NewWarehouseService().CreateLocation(&model.WarehouseLocation{Code: "L1", Name: "库位一"}, 1)
if err == nil {
t.Fatal("缺少 warehouse_id 的库位创建应当被拒绝")
}
}
// 上面的 UpdateRequiresID 只覆盖了拒绝分支,方法体里真正的 s.repo.Update 从未被执行。
// 六个 Update* 互为复制粘贴的模板,若某一处尾行被误写成 s.repo.Create,
// 生产行为会退化成「编辑即新增」——与本次修复的缺陷同级。这里补上走通的路径。
// Partner / Category / Unit / CodeRule / Warehouse / Location 六个现已全覆盖,
// 且逐个把尾行改成 Create* 验证过确实会被抓住,不是只跑绿。
func TestPartnerUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectBegin()
mock.ExpectQuery("SELECT business_partner_id FROM organization_partners").
WithArgs(int64(12), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(int64(7)))
mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE organization_partners").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
item := &model.BusinessPartner{
ID: 12,
OrganizationID: 1,
PartnerCode: "SUP-007",
LegalName: "伙伴改名",
IsSupplier: true,
}
if err := NewPartnerService().Update(item); err != nil {
t.Fatalf("Update: %v", err)
}
// 只登记了 SELECT ... FOR UPDATE 与两条 UPDATE。若尾行被误写成 Create,
// 事务里发出的 INSERT 与这里的期望不匹配:要么 Create 直接报错、要么
// 这两条 UPDATE 永不被消费,ExpectationsWereMet 必然失败。
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
func TestCategoryUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("UPDATE material_categories").
WithArgs("改名后的分类", "说明", nil, "active", int64(9), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.MaterialCategory{
ID: 9,
OrganizationID: 1,
Code: "A01",
Name: "改名后的分类",
Description: "说明",
}
if err := NewMasterDataService().UpdateCategory(item); err != nil {
t.Fatalf("UpdateCategory: %v", err)
}
// 这里只登记了 UPDATE;误写成 Create 时发出的 INSERT 不会被消费,
// Expectation 落空,ExpectationsWereMet 报错。
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是插入: %v", err)
}
}
func TestUnitUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("UPDATE units").
WithArgs("件(改名)", int64(2), "active", int64(4), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.Unit{
ID: 4,
OrganizationID: 1,
Code: "pcs",
Name: "件(改名)",
PrecisionScale: 2,
}
if err := NewMasterDataService().UpdateUnit(item); err != nil {
t.Fatalf("UpdateUnit: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
func TestCodeRuleUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
// category_id 必须出现在参数里:SET 漏掉它正是本次修的缺陷,
// 这里用 WithArgs 钉住参数个数和顺序,漏了或写错位置都会失败。
mock.ExpectExec("UPDATE code_rules").
WithArgs(int64(2), "B", int64(5), int64(7), "active", int64(3), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.CodeRule{
ID: 3,
OrganizationID: 1,
CategoryID: 2,
Prefix: "B",
SequenceLength: 5,
NextSequence: 7,
}
if err := NewMasterDataService().UpdateCodeRule(item); err != nil {
t.Fatalf("UpdateCodeRule: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
func TestWarehouseUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("UPDATE warehouses").
WithArgs("一号仓(改名)", "active", int64(5), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.Warehouse{
ID: 5,
OrganizationID: 1,
Code: "WH1",
Name: "一号仓(改名)",
}
if err := NewWarehouseService().Update(item); err != nil {
t.Fatalf("Update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
// 库位这条和上面三条不同:repository 的 UPDATE 是 JOIN warehouses 的写法,
// orgID 由方法参数传入、不在 item 上,SQL 模式写窄一点才能对上。
func TestLocationUpdateUpdatesInsteadOfInserting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
mock.ExpectExec("UPDATE warehouse_locations").
WithArgs("库位一(改名)", "active", int64(8), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.WarehouseLocation{
ID: 8,
WarehouseID: 3,
Code: "L1",
Name: "库位一(改名)",
}
if err := NewWarehouseService().UpdateLocation(item, 1); err != nil {
t.Fatalf("UpdateLocation: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err)
}
}
// 以下几组测试钉住「编码不可变」这条契约的两端:
// 更新侧——请求里不带 code 也必须能走通。旧实现复用 Create 的校验,要求 code
// 非空,而 repository 的 UPDATE 又不写这一列,形成「必须传、传了却被丢弃」。
// 新建侧——拆出 normalize* 时不能把 code 的必填校验一起丢了。
func newMasterDataMock(t *testing.T) sqlmock.Sqlmock {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
database.DB = db
t.Cleanup(func() { database.DB = nil; db.Close() })
return mock
}
func TestCategoryUpdateAcceptsRequestWithoutCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE material_categories").
WithArgs("只改名称", "", nil, "active", int64(9), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.MaterialCategory{ID: 9, OrganizationID: 1, Name: "只改名称"}
if err := NewMasterDataService().UpdateCategory(item); err != nil {
t.Fatalf("UpdateCategory: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err)
}
}
func TestUnitUpdateAcceptsRequestWithoutCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE units").
WithArgs("件(改名)", int64(2), "active", int64(4), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.Unit{ID: 4, OrganizationID: 1, Name: "件(改名)", PrecisionScale: 2}
if err := NewMasterDataService().UpdateUnit(item); err != nil {
t.Fatalf("UpdateUnit: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err)
}
}
func TestWarehouseUpdateAcceptsRequestWithoutCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE warehouses").
WithArgs("一号仓(改名)", "active", int64(5), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.Warehouse{ID: 5, OrganizationID: 1, Name: "一号仓(改名)"}
if err := NewWarehouseService().Update(item); err != nil {
t.Fatalf("Update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err)
}
}
func TestLocationUpdateAcceptsRequestWithoutCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE warehouse_locations").
WithArgs("库位一(改名)", "active", int64(8), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
item := &model.WarehouseLocation{ID: 8, WarehouseID: 3, Name: "库位一(改名)"}
if err := NewWarehouseService().UpdateLocation(item, 1); err != nil {
t.Fatalf("UpdateLocation: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err)
}
}
func TestPartnerUpdateAcceptsRequestWithoutPartnerCode(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectBegin()
mock.ExpectQuery("SELECT business_partner_id FROM organization_partners").
WithArgs(int64(12), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(int64(7)))
mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE organization_partners").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
item := &model.BusinessPartner{ID: 12, OrganizationID: 1, LegalName: "伙伴改名", IsSupplier: true}
if err := NewPartnerService().Update(item); err != nil {
t.Fatalf("Update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("不带 partner_code 的更新应当照常执行: %v", err)
}
}
// 更新侧放开 code 之后,名称这类可改字段的校验不能跟着一起没了。
func TestUpdatesStillRequireName(t *testing.T) {
cases := []struct {
name string
run func() error
}{
{"category", func() error {
return NewMasterDataService().UpdateCategory(&model.MaterialCategory{OrganizationID: 1, ID: 9, Code: "A01"})
}},
{"unit", func() error {
return NewMasterDataService().UpdateUnit(&model.Unit{OrganizationID: 1, ID: 4, Code: "pcs"})
}},
{"warehouse", func() error {
return NewWarehouseService().Update(&model.Warehouse{OrganizationID: 1, ID: 5, Code: "WH1"})
}},
{"location", func() error {
return NewWarehouseService().UpdateLocation(&model.WarehouseLocation{ID: 8, WarehouseID: 3, Code: "L1"}, 1)
}},
{"partner", func() error {
return NewPartnerService().Update(&model.BusinessPartner{OrganizationID: 1, ID: 12, PartnerCode: "SUP-007", IsSupplier: true})
}},
}
for _, c := range cases {
if err := c.run(); err == nil {
t.Errorf("%s: 名称为空的更新应当被拒绝", c.name)
}
}
}
// 新建侧仍然要求 code:拆方法不能把必填校验弄丢。
func TestCreatesStillRequireCode(t *testing.T) {
cases := []struct {
name string
run func() error
}{
{"category", func() error {
return NewMasterDataService().CreateCategory(&model.MaterialCategory{OrganizationID: 1, Name: "新分类"})
}},
{"unit", func() error {
return NewMasterDataService().CreateUnit(&model.Unit{OrganizationID: 1, Name: "件"})
}},
{"warehouse", func() error {
return NewWarehouseService().Create(&model.Warehouse{OrganizationID: 1, Name: "二号仓"})
}},
{"location", func() error {
return NewWarehouseService().CreateLocation(&model.WarehouseLocation{WarehouseID: 3, Name: "库位一"}, 1)
}},
{"partner", func() error {
return NewPartnerService().Create(&model.BusinessPartner{OrganizationID: 1, LegalName: "新伙伴", IsSupplier: true})
}},
}
for _, c := range cases {
if err := c.run(); err == nil {
t.Errorf("%s: 缺少 code 的新建应当被拒绝", c.name)
}
}
}
// 修好 category_id 之后,把规则改到已有规则的分类上会撞
// uk_code_rules_org_category。此前 SET 不写这一列,永远撞不上;
// 现在必须把这冲突翻成中文提示,否则前端只会显示「请求失败」。
func TestCodeRuleUpdateReportsDuplicateCategory(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("UPDATE code_rules").
WillReturnError(errors.New("Error 1062: Duplicate entry '1-2' for key 'uk_code_rules_org_category'"))
item := &model.CodeRule{ID: 3, OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1}
err := NewMasterDataService().UpdateCodeRule(item)
if !errors.Is(err, repository.ErrDuplicateCodeRule) {
t.Fatalf("撞唯一键应当翻成 ErrDuplicateCodeRule,实际 %v", err)
}
// 前端 index.js 只把中文消息照原样弹出,英文一律显示"请求失败"。
if !strings.Contains(err.Error(), "已有编码规则") {
t.Fatalf("错误消息应当是中文,实际 %q", err.Error())
}
}
func TestCodeRuleCreateReportsDuplicateCategory(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectExec("INSERT INTO code_rules").
WillReturnError(errors.New("Error 1062: Duplicate entry '1-2' for key 'uk_code_rules_org_category'"))
item := &model.CodeRule{OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1}
if err := NewMasterDataService().CreateCodeRule(item); !errors.Is(err, repository.ErrDuplicateCodeRule) {
t.Fatalf("新建撞唯一键也应当翻成 ErrDuplicateCodeRule,实际 %v", err)
}
}
// 记录不存在、或者存在但不属于当前组织时,UPDATE 影响 0 行。此前一律返回 nil,
// 接口提示"保存成功"而实际什么都没改。
//
// 这条检查依赖 DSN 的 clientFoundRows=true:不开的话「编辑弹窗里一个字段都没动」
// 同样是 0 行,正常的保存会被误判成记录不存在。开关的实测数据见
// pkg/database/database.go 里 normalizeDSN 的注释。
func TestUpdatesReportMissingRecord(t *testing.T) {
cases := []struct {
name string
sql string
run func() error
}{
{"category", "UPDATE material_categories", func() error {
return NewMasterDataService().UpdateCategory(&model.MaterialCategory{ID: 404, OrganizationID: 1, Name: "改名"})
}},
{"unit", "UPDATE units", func() error {
return NewMasterDataService().UpdateUnit(&model.Unit{ID: 404, OrganizationID: 1, Name: "件"})
}},
{"code rule", "UPDATE code_rules", func() error {
return NewMasterDataService().UpdateCodeRule(&model.CodeRule{
ID: 404, OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1,
})
}},
{"warehouse", "UPDATE warehouses", func() error {
return NewWarehouseService().Update(&model.Warehouse{ID: 404, OrganizationID: 1, Name: "一号仓"})
}},
{"location", "UPDATE warehouse_locations", func() error {
return NewWarehouseService().UpdateLocation(&model.WarehouseLocation{ID: 404, WarehouseID: 3, Name: "库位一"}, 1)
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
mock := newMasterDataMock(t)
// 第二个参数是受影响行数:0 行代表 id 不存在或组织对不上。
mock.ExpectExec(c.sql).WillReturnResult(sqlmock.NewResult(0, 0))
if err := c.run(); !errors.Is(err, repository.ErrRecordNotFound) {
t.Fatalf("影响 0 行应当返回 ErrRecordNotFound,实际 %v", err)
}
})
}
}
// 伙伴走的是另一条路:事务里先 SELECT ... FOR UPDATE,查不到就已经能判定不存在。
// 不翻译的话会以 sql.ErrNoRows 冒到接口,变成 500 加一句英文。
func TestPartnerUpdateReportsMissingRecord(t *testing.T) {
mock := newMasterDataMock(t)
mock.ExpectBegin()
mock.ExpectQuery("SELECT business_partner_id FROM organization_partners").
WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}))
mock.ExpectRollback()
item := &model.BusinessPartner{ID: 404, OrganizationID: 1, LegalName: "不存在的伙伴", IsSupplier: true}
if err := NewPartnerService().Update(item); !errors.Is(err, repository.ErrRecordNotFound) {
t.Fatalf("查不到伙伴应当返回 ErrRecordNotFound,实际 %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("查不到时应当回滚事务: %v", err)
}
}