material_manage_system/internal/service/material_test.go
文睿 张 18df4f48e4 修复 21 个测试与代码脱节导致的既有失败
go test ./... 从 21 个失败回到 0。根因都是测试没跟上代码改动,其中两处
被「自动补位」的补丁 hack 掩盖住了:

- handler 的 materialRow 在「列数 20、值 18」时自动插两个 nil,掩盖了
  materials 新增 supplier_id/manufacturer_id 后的列错位。它碰巧补对位置,
  而 order 测试的 activeMaterialRows 没走这个 helper,直接 panic。
  已删掉补丁、把测试数据补齐到 20 列。
- 导入测试的 buildTestWorkbookWithHeaders 按 7 列给数据补位,而表头这时
  已经扩到 9 列(多了供应商、制造商),把「大类/小类」静默塞进了
  「来源/备注」。已按 materialImportHeaders 的实际列序修正补位。

其余为非补丁式的期望过时:materials 的 INSERT 参数补到 15 个、物料列表
查询结果集补到 20 列、导入模板表头断言改为按 materialImportHeaders 取末列
而不是写死下标。

顺带清掉已废弃的升级路径残留:upgrades/ 目录在 85525c97 已删除,增量升级
不再使用,因此删除 scripts/sync-company-integrity.ps1(把 checks SQL 嵌入
upgrade SQL 的生成器)与它的测试,application.go 里指向 upgrades 的错误
文案改为只提 init.sql。
2026-10-14 16:27:18 +08:00

311 lines
12 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"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/pkg/database"
)
func TestMaterialServiceRejectsInvalidCategory(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
t.Cleanup(func() {
database.DB = nil
db.Close()
})
database.DB = db
query := `SELECT EXISTS(
SELECT 1 FROM material_categories
WHERE id = ? AND organization_id = ? AND parent_id IS NOT NULL AND status = 'active'
)`
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(12), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
categoryID := int64(12)
_, err = NewMaterialService().Create(&model.CreateMaterialRequest{
OrganizationID: 1,
MaterialCode: "MAT-001",
Name: "轴承",
CategoryID: &categoryID,
}, "tester")
if !errors.Is(err, ErrInvalidMaterialCategory) {
t.Fatalf("create error = %v, want ErrInvalidMaterialCategory", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceTreatsObsoleteMaterialAsInactive(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
t.Cleanup(func() {
database.DB = nil
db.Close()
})
database.DB = db
query := getMaterialQuery
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(1), int64(7)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "source", "remark", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, nil, nil, nil, nil, "", "", 12, 3, "件", "obsolete", 1, now, now),
)
active, err := NewMaterialService().IsActive(1, 7)
if err != nil {
t.Fatalf("check material activity: %v", err)
}
if active {
t.Fatal("obsolete material must not be active")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceUpdatesWithoutChangingDisabledCategory(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
materialRows := func(name string) *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "source", "remark", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, "MAT-007", name, nil, "", nil, nil, nil, nil, nil, "", "", 12, nil, "pcs", "active", 1, now, now)
}
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(materialRows("原名称"))
mock.ExpectExec(regexp.QuoteMeta("UPDATE materials SET name = ?, category_id = ?, version = version + 1 WHERE organization_id = ? AND id = ?")).
WithArgs("新名称", int64(12), int64(1), int64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(materialRows("新名称"))
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
WithArgs("UPDATE", int64(7), sqlmock.AnyArg(), sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
categoryID := int64(12)
material, err := NewMaterialService().Update(1, 7, &model.UpdateMaterialRequest{Name: "新名称", CategoryID: &categoryID}, "tester")
if err != nil {
t.Fatalf("update material with unchanged disabled category: %v", err)
}
if material.CategoryID == nil || *material.CategoryID != 12 || material.Name != "新名称" {
t.Fatalf("updated material = %+v, want category 12 and new name", material)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 以下常量必须与 repository 里的 SQL 字面量逐字一致,sqlmock 才匹配得上。
const (
activeSubcategoryQuery = `SELECT EXISTS(
SELECT 1 FROM material_categories
WHERE id = ? AND organization_id = ? AND parent_id IS NOT NULL AND status = 'active'
)`
lockCodeRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`
codeTakenQuery = `SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND material_code = ?)`
insertMaterialStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, source, remark, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)`
advanceSequenceStmt = `UPDATE code_rules SET next_sequence = ? WHERE id = ?`
materialLogStmt = `INSERT INTO material_logs (operation_type, material_id, operator_id, old_value, new_value, operator, description) VALUES (?, ?, NULL, ?, ?, ?, ?)`
getMaterialQuery = `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model, m.source, m.remark,
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
FROM materials m
LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
WHERE m.organization_id = ? AND m.id = ?`
)
func newMaterialServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
database.DB = db
return mock, func() {
database.DB = nil
db.Close()
}
}
func expectActiveSubcategory(mock sqlmock.Sqlmock) {
mock.ExpectQuery(regexp.QuoteMeta(activeSubcategoryQuery)).WithArgs(int64(12), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
}
// expectLockedCodeRule 开始事务并锁住小类 12 上的编码规则;nextSequence 为 nil
// 表示该小类没有规则。
func expectLockedCodeRule(mock sqlmock.Sqlmock, nextSequence *int64) {
mock.ExpectBegin()
rows := sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})
if nextSequence != nil {
rows.AddRow(5, 1, 12, "A01", 4, *nextSequence)
}
mock.ExpectQuery(regexp.QuoteMeta(lockCodeRuleQuery)).WithArgs(int64(1), int64(12)).WillReturnRows(rows)
}
func expectCodeTaken(mock sqlmock.Sqlmock, code string, taken bool) {
mock.ExpectQuery(regexp.QuoteMeta(codeTakenQuery)).WithArgs(int64(1), code).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(taken))
}
func expectMaterialInsert(mock sqlmock.Sqlmock, code string) {
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
WithArgs(int64(1), code, "轴承", nil, "", nil, "", "", nil, nil, int64(12), nil, "个", "active", 1).
WillReturnResult(sqlmock.NewResult(7, 1))
}
func expectCreatedMaterial(mock sqlmock.Sqlmock, code string) {
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "source", "remark", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, code, "轴承", nil, "", nil, nil, nil, nil, nil, "", "", 12, nil, "个", "active", 1, now, now),
)
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
}
func createMaterialWithCode(t *testing.T, code string) (*model.Material, error) {
t.Helper()
categoryID := int64(12)
return NewMaterialService().Create(&model.CreateMaterialRequest{
OrganizationID: 1,
MaterialCode: code,
Name: "轴承",
CategoryID: &categoryID,
AllowDuplicate: true,
}, "tester")
}
func TestMaterialServiceAdvancesSequenceForAutoGeneratedCode(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
next := int64(201)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
expectCodeTaken(mock, "A010201", false)
expectMaterialInsert(mock, "A010201")
mock.ExpectExec(regexp.QuoteMeta(advanceSequenceStmt)).WithArgs(int64(202), int64(5)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
expectCreatedMaterial(mock, "A010201")
material, err := createMaterialWithCode(t, "A010201")
if err != nil {
t.Fatalf("create material: %v", err)
}
if material.MaterialCode != "A010201" {
t.Fatalf("material code = %s, want A010201", material.MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceSkipsTakenSequenceNumbers(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
next := int64(201)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
expectCodeTaken(mock, "A010201", true)
expectCodeTaken(mock, "A010202", false)
expectMaterialInsert(mock, "A010202")
mock.ExpectExec(regexp.QuoteMeta(advanceSequenceStmt)).WithArgs(int64(203), int64(5)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
expectCreatedMaterial(mock, "A010202")
material, err := createMaterialWithCode(t, "A010201")
if err != nil {
t.Fatalf("create material: %v", err)
}
if material.MaterialCode != "A010202" {
t.Fatalf("material code = %s, want A010202", material.MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceKeepsManuallyEditedCodeWithoutAdvancingSequence(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
next := int64(201)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
// 手改过的编码不等于规则产物,既不该探测占用,也不该推进号段。
expectMaterialInsert(mock, "CUSTOM-001")
mock.ExpectCommit()
expectCreatedMaterial(mock, "CUSTOM-001")
material, err := createMaterialWithCode(t, "CUSTOM-001")
if err != nil {
t.Fatalf("create material: %v", err)
}
if material.MaterialCode != "CUSTOM-001" {
t.Fatalf("material code = %s, want CUSTOM-001", material.MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceCreatesMaterialWhenCategoryHasNoCodeRule(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, nil)
expectMaterialInsert(mock, "MANUAL-009")
mock.ExpectCommit()
expectCreatedMaterial(mock, "MANUAL-009")
material, err := createMaterialWithCode(t, "MANUAL-009")
if err != nil {
t.Fatalf("create material: %v", err)
}
if material.MaterialCode != "MANUAL-009" {
t.Fatalf("material code = %s, want MANUAL-009", material.MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceReportsDuplicateManuallyEditedCode(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
next := int64(201)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, "", "", nil, nil, int64(12), nil, "个", "active", 1).
WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'"))
mock.ExpectRollback()
_, err := createMaterialWithCode(t, "CUSTOM-001")
if !errors.Is(err, repository.ErrDuplicateMaterialCode) {
t.Fatalf("create error = %v, want ErrDuplicateMaterialCode", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}